前不久在某风电场的状态监测系统项目中,遇到了个让人头疼的问题:3台风机,每台16个振动传感器,采样频率2kHz,也就是每秒钟有近10万个数据点涌入系统。原来用WPF Chart控件做的监控界面,跑了不到10分钟就开始卡顿,CPU直接飙到85%,客户现场工程师看着一帧一帧跳动的波形图,直接问:"这是实时监控还是慢动作回放?"
最终切换到ScottPlot 5.x后,同样的数据量下,界面刷新延迟从800ms降到35ms以内,CPU占用稳定在18%,48路振动信号同时流畅显示。这背后的性能提升不只是换个图表库这么简单,更多的是对多轴数据处理、内存管理和渲染优化的深入理解。
读完这篇文章,你将掌握:
振动分析不同于常规的温度、压力监控,它的数据密度要高出几个数量级。一台典型的旋转设备可能需要监测:
这意味着单台设备每秒产生5000+数据点,多台设备并发时数据流量呈指数增长。传统Chart控件的"来一个画一个"模式在这种场景下完全崩盘。
我在某石化装置的压缩机监测系统中实测过,6轴振动数据+转速信号同时显示时:
| 方案 | 刷新延迟 | CPU占用 | 内存增长速率 |
|---|---|---|---|
| WPF Chart | 1200ms | 72% | 120MB/小时 |
| LiveCharts | 680ms | 58% | 85MB/小时 |
| ScottPlot 5.x | 35ms | 18% | 稳定 |
振动分析中,不同轴向的数据必须严格时间对齐才有分析价值。比如轴承故障诊断时,需要对比X、Y轴的相位关系来判断不平衡类型。如果各轴数据的时间戳有哪怕几毫秒的偏差,分析结果都会失真。
更复杂的是坐标系设置:
csharp// ❌ 错误做法:各轴使用独立的坐标系
foreach(var axis in axes)
{
axis.Plot.Axes.AutoScale(); // 每个轴独立缩放,失去对比意义
}
// ✅ 正确做法:统一坐标系管理
var globalTimeRange = GetGlobalTimeRange();
var globalAmplitudeRange = GetGlobalAmplitudeRange();
foreach(var axis in axes)
{
axis.Plot.Axes.SetLimits(globalTimeRange.Min, globalTimeRange.Max,
globalAmplitudeRange.Min, globalAmplitudeRange.Max);
}
振动分析往往需要时域波形和频域频谱同时显示。当时域数据更新时,频谱也要实时计算并刷新。这涉及到FFT计算、数据缓冲、多图表联动等复杂逻辑。
在某齿轮箱监测项目中,我们需要同时显示:
如果处理不当,界面很容易因为计算复杂度过高而卡死。
ScottPlot 5.x支持在单个WpfPlot控件中管理多个子图表,这为多轴显示提供了天然优势:
csharp// 核心架构:一个容器控件管理多个子图表
WpfPlot mainPlot = new WpfPlot();
var subplot1 = mainPlot.Plot.Add.Subplot(0.0, 1.0, 0.7, 1.0); // 上半部分
var subplot2 = mainPlot.Plot.Add.Subplot(0.0, 1.0, 0.3, 0.7); // 中间部分
var subplot3 = mainPlot.Plot.Add.Subplot(0.0, 1.0, 0.0, 0.3); // 下半部分
这种设计的优势是所有子图共享时间轴,天然解决了时间同步问题。
针对振动数据的特点,ScottPlot提供了几种优化的数据容器:
对于振动监测,推荐使用DataStreamer:
csharp// DataStreamer自动管理数据窗口,无内存泄漏风险
var streamer = myPlot.Plot.Add.DataStreamer(capacity: 2000);
streamer.Color = Colors.Blue;
streamer.LineWidth = 1.5f;
多轴显示的性能瓶颈往往在内存管理,关键原则:
适用场景:单台设备、低频采样(<100Hz)、快速验证需求
csharpusing ScottPlot;
using ScottPlot.WPF;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
namespace AppScottPlot10
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
// 振动数据包:包含时间戳,确保多设备同步
public record VibrationPacket(DateTime Timestamp, string DeviceId, string Channel, double Value);
public partial class MainWindow : Window
{
private readonly Channel<VibrationPacket> _dataChannel;
private readonly Dictionary<string, Queue<(double time, double value)>> _deviceBuffers;
private readonly Dictionary<string, ScottPlot.Plottables.SignalXY> _signalPlots;
private readonly PeriodicTimer _refreshTimer;
private CancellationTokenSource _cts;
private DateTime _startTime;
private const double DISPLAY_TIME_WINDOW = 30.0; // 显示最近30秒
private const int MAX_POINTS_PER_DEVICE = 3000;
// 多设备多通道配置
private readonly (string DeviceId, string Channel, string Color)[] _channels =
{
("风机1号", "轴承X", "#E74C3C"),
("风机1号", "轴承Y", "#FF6B68"),
("风机1号", "轴承Z", "#C0392B"),
("风机2号", "轴承X", "#3498DB"),
("风机2号", "轴承Y", "#5DADE2"),
("风机2号", "轴承Z", "#2980B9"),
};
public MainWindow()
{
InitializeComponent();
// 首先初始化CancellationTokenSource
_cts = new CancellationTokenSource();
// 创建高性能数据通道
_dataChannel = Channel.CreateBounded<VibrationPacket>(new BoundedChannelOptions(10000)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleWriter = false,
SingleReader = true
});
_deviceBuffers = new Dictionary<string, Queue<(double, double)>>();
_signalPlots = new Dictionary<string, ScottPlot.Plottables.SignalXY>();
InitializeAdvancedCharts();
// 高频刷新定时器
_refreshTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(40)); // 25Hz刷新
// 启动异步处理
StartAsyncDataProcessing();
Task.Run(RefreshLoop);
}
private void InitializeAdvancedCharts()
{
var plt = VibrationPlot.Plot;
// 专业工业主题
plt.Font.Set("Microsoft YaHei");
plt.FigureBackground.Color = Color.FromHex("#1E1E1E");
plt.DataBackground.Color = Color.FromHex("#2D2D30");
// 高对比度网格
plt.Grid.MajorLineColor = Colors.Gray.WithAlpha(100);
plt.Grid.MajorLineWidth = 1;
plt.Grid.MinorLineColor = Colors.Gray.WithAlpha(40);
plt.Grid.MinorLineWidth = 0.5f;
// 为每个通道创建SignalXY(支持自定义时间轴)
foreach (var (deviceId, channel, color) in _channels)
{
string key = $"{deviceId}_{channel}";
// 初始化缓冲区
_deviceBuffers[key] = new Queue<(double, double)>();
// 创建SignalXY对象
var plot = plt.Add.SignalXY(
new double[] { 0 },
new double[] { 0 }
);
plot.Color = Color.FromHex(color);
plot.LineWidth = 1.2f;
plot.LegendText = $"{deviceId}-{channel}";
plot.MarkerSize = 0;
_signalPlots[key] = plot;
}
// 坐标轴配置
plt.Title("多设备振动联合监控系统", size: 18);
plt.XLabel("时间 (秒)");
plt.YLabel("振动幅值 (m/s²)");
plt.Legend.IsVisible = true;
plt.Legend.Alignment = Alignment.UpperRight;
// 添加报警线
var alarmLine = plt.Add.HorizontalLine(8.0);
alarmLine.Color = Colors.Red;
alarmLine.LinePattern = LinePattern.Dashed;
alarmLine.LineWidth = 2;
alarmLine.LegendText = "报警阈值";
VibrationPlot.Refresh();
}
private void StartAsyncDataProcessing()
{
// 模拟多设备异步数据采集
Task.Run(async () =>
{
var random = new Random();
_startTime = DateTime.Now;
while (!_cts.Token.IsCancellationRequested)
{
foreach (var (deviceId, channel, _) in _channels)
{
// 模拟不同设备的采样时间差异
var timestamp = DateTime.Now;
// 生成特征频率振动信号
double time = (timestamp - _startTime).TotalSeconds;
double baseFreq = deviceId.Contains("1号") ? 25 : 30; // 不同设备不同转频
double harmonics = 2 * Math.Sin(2 * Math.PI * baseFreq * 2 * time); // 2倍频
double noise = (random.NextDouble() - 0.5) * 1.5;
double value = 5 * Math.Sin(2 * Math.PI * baseFreq * time) + harmonics + noise;
// 发送到数据通道
var packet = new VibrationPacket(timestamp, deviceId, channel, value);
await _dataChannel.Writer.WriteAsync(packet, _cts.Token);
// 模拟采样间隔
await Task.Delay(random.Next(5, 15), _cts.Token);
}
}
}, _cts.Token);
}
private async Task RefreshLoop()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
// 批量处理数据包
var processedCount = 0;
var hasData = false;
while (_dataChannel.Reader.TryRead(out var packet) && processedCount < 100)
{
string key = $"{packet.DeviceId}_{packet.Channel}";
double relativeTime = (packet.Timestamp - _startTime).TotalSeconds;
// 更新缓冲区
var buffer = _deviceBuffers[key];
buffer.Enqueue((relativeTime, packet.Value));
// 清理过期数据
while (buffer.Count > 0 &&
relativeTime - buffer.Peek().time > DISPLAY_TIME_WINDOW)
{
buffer.Dequeue();
}
// 限制数据点数量
while (buffer.Count > MAX_POINTS_PER_DEVICE)
{
buffer.Dequeue();
}
processedCount++;
hasData = true;
}
// 如果处理了数据,更新图表
if (hasData)
{
await UpdateChartsAsync();
}
// 等待下一个刷新周期
await _refreshTimer.WaitForNextTickAsync(_cts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
// 记录异常但继续运行
System.Diagnostics.Debug.WriteLine($"RefreshLoop error: {ex.Message}");
}
}
}
private async Task UpdateChartsAsync()
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
foreach (var (key, buffer) in _deviceBuffers)
{
if (buffer.Count == 0) continue;
var timeArray = buffer.Select(p => p.time).ToArray();
var valueArray = buffer.Select(p => p.value).ToArray();
var signal = _signalPlots[key];
VibrationPlot.Plot.Remove(signal);
var newSignal = VibrationPlot.Plot.Add.SignalXY(timeArray, valueArray);
newSignal.Color = signal.Color;
newSignal.LineWidth = signal.LineWidth;
newSignal.LegendText = signal.LegendText;
newSignal.MarkerSize = signal.MarkerSize;
_signalPlots[key] = newSignal;
}
// 自动调整时间轴范围
double currentTime = (DateTime.Now - _startTime).TotalSeconds;
VibrationPlot.Plot.Axes.SetLimitsX(currentTime - DISPLAY_TIME_WINDOW, currentTime);
VibrationPlot.Refresh();
});
}
protected override void OnClosed(EventArgs e)
{
_cts?.Cancel();
_refreshTimer?.Dispose();
base.OnClosed(e);
}
}
}

适用场景:多台设备、中高频采样(100-1kHz)、需要时间同步的生产环境
这个方案解决了实际工程中的复杂需求:多台设备数据需要时间对齐、支持历史数据回放、具备异常检测功能。
csharpusing ScottPlot;
using ScottPlot.WPF;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
namespace AppScottPlot11
{
/// <summary>
/// 振动数据包:包含时间戳,确保多设备同步
/// </summary>
public record VibrationPacket(DateTime Timestamp, string DeviceId, string Channel, double Value);
public partial class MainWindow : Window
{
// ── 数据通道:生产者-消费者解耦 ──────────────────────────────
private readonly Channel<VibrationPacket> _dataChannel;
// ── 每个通道独立维护滑动窗口队列 ─────────────────────────────
private readonly Dictionary<string, Queue<(double time, double value)>> _deviceBuffers = new();
private readonly Dictionary<string, ScottPlot.Plottables.SignalXY> _signalPlots = new();
// ── 异常检测:记录每个通道的最新状态 ──────────────────────────
private readonly Dictionary<string, bool> _alarmState = new();
// ── 历史回放:存储全量数据 ─────────────────────────────────────
private readonly Dictionary<string, List<(double time, double value)>> _historyData = new();
private bool _isPlayback = false;
private double _playbackStartOffset = 0;
// ── 定时器与生命周期 ──────────────────────────────────────────
private DispatcherTimer _uiRefreshTimer; // UI 刷新(20Hz,主线程安全)
private CancellationTokenSource _cts;
private DateTime _startTime;
private const double DISPLAY_TIME_WINDOW = 30.0; // 显示最近 30 秒
private const int MAX_POINTS_PER_CHANNEL = 3000; // 每通道最大点数
private const double ALARM_THRESHOLD = 8.0; // 报警阈值 m/s²
// ── 通道配置 ──────────────────────────────────────────────────
private readonly (string DeviceId, string Channel, string Color, double BaseFreq)[] _channels =
{
("风机1号", "轴承X", "#E74C3C", 25.0),
("风机1号", "轴承Y", "#FF6B68", 25.0),
("风机1号", "轴承Z", "#C0392B", 25.0),
("风机2号", "轴承X", "#3498DB", 30.0),
("风机2号", "轴承Y", "#5DADE2", 30.0),
("风机2号", "轴承Z", "#2980B9", 30.0),
};
public MainWindow()
{
InitializeComponent();
// 有界通道:满时丢弃最旧数据,防止内存膨胀
_dataChannel = Channel.CreateBounded<VibrationPacket>(
new BoundedChannelOptions(10000)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleWriter = false,
SingleReader = true // 单消费者,无锁读取
});
_cts = new CancellationTokenSource();
_startTime = DateTime.Now;
InitializeAdvancedCharts();
StartAsyncDataCollection();
StartAsyncDataProcessing();
StartUIRefreshTimer();
}
// 1. 图表初始化
private void InitializeAdvancedCharts()
{
var plt = VibrationPlot.Plot;
// ISA-101 工业暗色主题
plt.Font.Set("Microsoft YaHei");
plt.FigureBackground.Color = Color.FromHex("#1E1E1E");
plt.DataBackground.Color = Color.FromHex("#2D2D30");
plt.Axes.Color(Color.FromHex("#C8C8C8"));
// 层次化网格
plt.Grid.MajorLineColor = Colors.Gray.WithAlpha(100);
plt.Grid.MajorLineWidth = 1f;
plt.Grid.MinorLineColor = Colors.Gray.WithAlpha(40);
plt.Grid.MinorLineWidth = 0.5f;
// 为每个通道初始化缓冲区 + SignalXY 对象
foreach (var (deviceId, channel, color, _) in _channels)
{
string key = $"{deviceId}_{channel}";
_deviceBuffers[key] = new Queue<(double, double)>();
_historyData[key] = new List<(double, double)>();
_alarmState[key] = false;
// SignalXY:支持非均匀时间轴(各设备采样时刻不同)
var plot = plt.Add.SignalXY(
new double[] { 0 },
new double[] { 0 }
);
plot.Color = Color.FromHex(color);
plot.LineWidth = 1.5f;
plot.LegendText = $"{deviceId}-{channel}";
plot.MarkerSize = 0;
_signalPlots[key] = plot;
}
// 报警阈值线(保存引用,支持运行时动态修改)
var alarmLine = plt.Add.HorizontalLine(ALARM_THRESHOLD);
alarmLine.Color = Colors.OrangeRed;
alarmLine.LinePattern = LinePattern.Dashed;
alarmLine.LineWidth = 2f;
alarmLine.LegendText = $"报警阈值 ({ALARM_THRESHOLD} m/s²)";
var alarmLineNeg = plt.Add.HorizontalLine(-ALARM_THRESHOLD);
alarmLineNeg.Color = Colors.OrangeRed;
alarmLineNeg.LinePattern = LinePattern.Dashed;
alarmLineNeg.LineWidth = 2f;
// 坐标轴
plt.Title("多设备振动联合监控系统", size: 16);
plt.XLabel("时间 (秒)");
plt.YLabel("振动幅值 (m/s²)");
plt.Axes.SetLimitsY(-12, 12);
plt.Axes.SetLimitsX(0, DISPLAY_TIME_WINDOW);
// 图例暗色适配
plt.Legend.IsVisible = true;
plt.Legend.Alignment = Alignment.UpperRight;
plt.Legend.BackgroundColor = Color.FromHex("#2D2D30");
plt.Legend.FontColor = Color.FromHex("#C8C8C8");
plt.Legend.OutlineColor = Color.FromHex("#505050");
VibrationPlot.Refresh();
}
// 2. 异步数据采集(后台线程模拟多设备非均匀采样)
private void StartAsyncDataCollection()
{
Task.Run(async () =>
{
var random = new Random();
while (!_cts.Token.IsCancellationRequested)
{
foreach (var (deviceId, channel, _, baseFreq) in _channels)
{
if (_cts.Token.IsCancellationRequested) break;
var timestamp = DateTime.Now;
double t = (timestamp - _startTime).TotalSeconds;
// 真实振动信号 = 基频 + 二倍频 + 噪声(模拟轴承故障特征)
double fundamental = 5.0 * Math.Sin(2 * Math.PI * baseFreq * t);
double harmonic2x = 2.0 * Math.Sin(2 * Math.PI * baseFreq * 2 * t);
double harmonic3x = 0.8 * Math.Sin(2 * Math.PI * baseFreq * 3 * t);
double noise = (random.NextDouble() - 0.5) * 1.5;
// 偶发冲击模拟故障
double impulse = random.NextDouble() < 0.005
? (random.NextDouble() > 0.5 ? 1 : -1) * (8 + random.NextDouble() * 4)
: 0;
double value = fundamental + harmonic2x + harmonic3x + noise + impulse;
var packet = new VibrationPacket(timestamp, deviceId, channel, value);
// WriteAsync:通道满时等待(BoundedChannelFullMode.DropOldest 兜底)
await _dataChannel.Writer.WriteAsync(packet, _cts.Token);
}
// 模拟各设备采样间隔略有差异(非均匀采样)
await Task.Delay(random.Next(8, 20), _cts.Token);
}
}, _cts.Token);
}
// 3. 后台消费者:批量消费队列,更新内存缓冲区
// 与 UI 刷新完全解耦
private void StartAsyncDataProcessing()
{
Task.Run(async () =>
{
await foreach (var packet in _dataChannel.Reader.ReadAllAsync(_cts.Token))
{
string key = $"{packet.DeviceId}_{packet.Channel}";
double relTime = (packet.Timestamp - _startTime).TotalSeconds;
// ── 写入滑动窗口缓冲区 ──────────────────────────
var buffer = _deviceBuffers[key];
buffer.Enqueue((relTime, packet.Value));
// 清除超出时间窗口的旧数据
double cutoff = relTime - DISPLAY_TIME_WINDOW;
while (buffer.Count > 0 && buffer.Peek().time < cutoff)
buffer.Dequeue();
// 限制最大点数
while (buffer.Count > MAX_POINTS_PER_CHANNEL)
buffer.Dequeue();
// ── 写入历史数据(供回放使用)───────────────────
var history = _historyData[key];
history.Add((relTime, packet.Value));
// 历史数据最多保留 10 分钟
if (history.Count > 60000)
history.RemoveRange(0, 1000);
// ── 异常检测(超阈值立即标记)────────────────────
bool wasAlarm = _alarmState[key];
bool isAlarm = Math.Abs(packet.Value) >= ALARM_THRESHOLD;
_alarmState[key] = isAlarm;
// 新发生报警时通知 UI(降低通知频率)
if (isAlarm && !wasAlarm)
{
string msg = $"[报警] {packet.DeviceId}-{packet.Channel} " +
$"振动值 {packet.Value:F2} m/s² 超过阈值!";
// 派发到 UI 线程更新状态栏
Application.Current.Dispatcher.InvokeAsync(
() => UpdateAlarmStatus(key, msg),
System.Windows.Threading.DispatcherPriority.Background);
}
}
}, _cts.Token);
}
// 4. UI 刷新定时器(20Hz,DispatcherTimer 天然在主线程)
private void StartUIRefreshTimer()
{
_uiRefreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50) // 20Hz
};
_uiRefreshTimer.Tick += OnUIRefresh;
_uiRefreshTimer.Start();
}
private void OnUIRefresh(object sender, EventArgs e)
{
if (_isPlayback) return; // 回放模式下由回放逻辑驱动刷新
double currentTime = (DateTime.Now - _startTime).TotalSeconds;
foreach (var (key, buffer) in _deviceBuffers)
{
if (buffer.Count < 2) continue;
var points = buffer.ToArray(); // Queue 快照,避免迭代冲突
var timeArray = points.Select(p => p.time).ToArray();
var valueArray = points.Select(p => p.value).ToArray();
// SignalXY 重建(时间戳不定长,必须重新创建)
var oldPlot = _signalPlots[key];
VibrationPlot.Plot.Remove(oldPlot);
var newPlot = VibrationPlot.Plot.Add.SignalXY(timeArray, valueArray);
newPlot.Color = oldPlot.Color;
newPlot.LineWidth = oldPlot.LineWidth;
newPlot.LegendText = oldPlot.LegendText;
newPlot.MarkerSize = 0;
// 报警通道高亮
if (_alarmState[key])
newPlot.LineWidth = 2.5f;
_signalPlots[key] = newPlot;
}
// 滑动 X 轴
VibrationPlot.Plot.Axes.SetLimitsX(
Math.Max(0, currentTime - DISPLAY_TIME_WINDOW),
currentTime + 1);
VibrationPlot.Refresh();
UpdateStatusBar(currentTime);
}
// 5. 历史数据回放
private DispatcherTimer _playbackTimer;
private void StartPlayback(double fromSeconds, double toSeconds)
{
_isPlayback = true;
_playbackStartOffset = fromSeconds;
double playbackCursor = fromSeconds;
_playbackTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50)
};
_playbackTimer.Tick += (s, e) =>
{
playbackCursor += 0.5; // 每帧前进 0.5 秒
if (playbackCursor > toSeconds)
{
StopPlayback();
return;
}
// 截取历史窗口
double windowStart = playbackCursor - DISPLAY_TIME_WINDOW;
foreach (var (key, history) in _historyData)
{
var slice = history
.Where(p => p.time >= windowStart && p.time <= playbackCursor)
.ToArray();
if (slice.Length < 2) continue;
var oldPlot = _signalPlots[key];
VibrationPlot.Plot.Remove(oldPlot);
var newPlot = VibrationPlot.Plot.Add.SignalXY(
slice.Select(p => p.time).ToArray(),
slice.Select(p => p.value).ToArray());
newPlot.Color = oldPlot.Color;
newPlot.LineWidth = 1.5f;
newPlot.LegendText = oldPlot.LegendText;
newPlot.MarkerSize = 0;
_signalPlots[key] = newPlot;
}
VibrationPlot.Plot.Axes.SetLimitsX(windowStart, playbackCursor + 1);
PlaybackProgressText.Text =
$"回放中... {playbackCursor:F1}s / {toSeconds:F1}s";
VibrationPlot.Refresh();
};
_playbackTimer.Start();
}
private void StopPlayback()
{
_playbackTimer?.Stop();
_isPlayback = false;
PlaybackProgressText.Text = "实时监控";
}
// 6. UI 辅助方法
private void UpdateAlarmStatus(string key, string message)
{
AlarmText.Text = message;
AlarmText.Foreground = System.Windows.Media.Brushes.OrangeRed;
}
private void UpdateStatusBar(double currentTime)
{
int totalAlarms = _alarmState.Count(kv => kv.Value);
int totalPoints = _deviceBuffers.Sum(kv => kv.Value.Count);
StatusText.Text = $"运行时间: {currentTime:F1}s | " +
$"数据点: {totalPoints} | " +
$"报警通道: {totalAlarms}";
}
// ── 按钮事件 ──────────────────────────────────────────────────
private void BtnPlayback_Click(object sender, RoutedEventArgs e)
{
double currentTime = (DateTime.Now - _startTime).TotalSeconds;
if (currentTime < 5)
{
AlarmText.Text = "历史数据不足,至少需要运行 5 秒";
return;
}
// 回放最近 60 秒(如有)
double from = Math.Max(0, currentTime - 60);
StartPlayback(from, currentTime);
}
private void BtnStopPlayback_Click(object sender, RoutedEventArgs e)
{
StopPlayback();
}
protected override void OnClosed(EventArgs e)
{
_cts?.Cancel();
_uiRefreshTimer?.Stop();
_playbackTimer?.Stop();
_dataChannel.Writer.TryComplete();
base.OnClosed(e);
}
}
}

适用场景:关键设备监控、需要频谱分析、故障诊断功能的专业系统
这个方案提供了完整的振动分析功能:时域波形、实时频谱、趋势分析、报警管理。
csharpusing System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Threading;
using ScottPlot;
using MediaColor = System.Windows.Media.Color;
using MediaColors = System.Windows.Media.Colors;
namespace AppScottPlot12
{
public partial class MainWindow : Window
{
// 数据缓冲区
private readonly Dictionary<string, Queue<double>> _timeDataBuffers = new();
private readonly Dictionary<string, double[]> _trendRmsBuffer = new();
private int _trendIndex = 0;
private const int TREND_SIZE = 300; // 趋势缓冲点数
// 时域:用 SignalXY 支持自定义 X 轴(时间戳)
private readonly Dictionary<string, ScottPlot.Plottables.SignalXY> _timePlots = new();
// 频域
private readonly Dictionary<string, ScottPlot.Plottables.SignalXY> _spectrumPlots = new();
// 趋势:每通道一条 Signal(固定数组引用,环形写入)
private readonly Dictionary<string, ScottPlot.Plottables.Signal> _trendPlots = new();
// 定时器────
private readonly DispatcherTimer _dataTimer; // 数据模拟:10ms(100Hz)
private readonly DispatcherTimer _renderTimer; // 时域刷新:50ms(20Hz)
private readonly DispatcherTimer _analysisTimer; // 频谱分析:200ms(5Hz)
private readonly DispatcherTimer _trendTimer; // 趋势更新:1s
// 常量──────
private const int FFT_SIZE = 1024;
private const double SAMPLE_RATE = 100.0; // 10ms 定时采样 => 100 Hz
private const int TIME_WINDOW = 500; // 时域显示最近 500 点
// 传感器通道配置 ───────────────────────────────────────
private readonly (string Name, string Color, double BaseFreq)[] _channels =
{
("轴承X", "#E74C3C", 30.0),
("轴承Y", "#3498DB", 45.0),
("轴承Z", "#2ECC71", 60.0),
};
// 报警历史──
private readonly List<string> _alarmHistory = new();
private DateTime _lastAlarmTime = DateTime.MinValue;
// 随机数(模拟用) ─────────────────────────────────────
private readonly Random _random = new();
private double _simTime = 0;
public MainWindow()
{
InitializeComponent();
// 初始化各通道缓冲区
foreach (var (name, _, _) in _channels)
{
_timeDataBuffers[name] = new Queue<double>();
_trendRmsBuffer[name] = new double[TREND_SIZE];
}
InitializeComplexDashboard();
SampleRateText.Text = $" | 采样率:{SAMPLE_RATE:0} Hz | FFT:{FFT_SIZE}点";
// ── 数据模拟定时器(100 Hz)
_dataTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(10)
};
_dataTimer.Tick += OnSimulateData;
_dataTimer.Start();
// ── 时域刷新(20 Hz)
_renderTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50)
};
_renderTimer.Tick += OnRenderTimeDomain;
_renderTimer.Start();
// ── 频谱分析(5 Hz)
_analysisTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(200)
};
_analysisTimer.Tick += OnSpectrumAnalysis;
_analysisTimer.Start();
// ── 趋势更新(1 Hz)
_trendTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(1)
};
_trendTimer.Tick += OnTrendUpdate;
_trendTimer.Start();
}
// 图表初始化
private void InitializeComplexDashboard()
{
// ── 公共工业暗色主题 helper
void ApplyDarkTheme(Plot plt)
{
plt.FigureBackground.Color = ScottPlot.Color.FromHex("#1E1E1E");
plt.DataBackground.Color = ScottPlot.Color.FromHex("#2D2D30");
plt.Grid.MajorLineColor = ScottPlot.Colors.Gray.WithAlpha(80);
plt.Grid.MinorLineColor = ScottPlot.Colors.Gray.WithAlpha(30);
plt.Grid.MajorLineWidth = 1f;
plt.Grid.MinorLineWidth = 0.5f;
plt.Axes.Color(ScottPlot.Color.FromHex("#C8C8C8"));
plt.Legend.BackgroundColor = ScottPlot.Color.FromHex("#2D2D30");
plt.Legend.FontColor = ScottPlot.Color.FromHex("#C8C8C8");
plt.Legend.OutlineColor = ScottPlot.Color.FromHex("#505050");
}
// ── 时域图表
var timePlot = TimeDomainPlot.Plot;
timePlot.Font.Set("Microsoft YaHei");
timePlot.Axes.Bottom.Label.FontName = "Microsoft YaHei";
timePlot.Axes.Left.Label.FontName = "Microsoft YaHei";
timePlot.Title("时域振动波形", size: 15);
timePlot.XLabel("时间 (秒)");
timePlot.YLabel("加速度 (m/s²)");
timePlot.Axes.SetLimitsY(-5, 5);
timePlot.Legend.IsVisible = true;
ApplyDarkTheme(timePlot);
// ── 频域图表
var specPlot = SpectrumPlot.Plot;
specPlot.Font.Set("Microsoft YaHei");
specPlot.Axes.Bottom.Label.FontName = "Microsoft YaHei";
specPlot.Axes.Left.Label.FontName = "Microsoft YaHei";
specPlot.Title("实时频谱分析", size: 15);
specPlot.XLabel("频率 (Hz)");
specPlot.YLabel("幅值 (m/s²)");
specPlot.Axes.SetLimitsX(0, SAMPLE_RATE / 2);
specPlot.Axes.SetLimitsY(0, 2.5);
specPlot.Legend.IsVisible = true;
ApplyDarkTheme(specPlot);
// ── 趋势图表
var trendPlot = TrendPlot.Plot;
trendPlot.Font.Set("Microsoft YaHei");
trendPlot.Axes.Bottom.Label.FontName = "Microsoft YaHei";
trendPlot.Axes.Left.Label.FontName = "Microsoft YaHei";
trendPlot.Title("RMS 振动趋势", size: 13);
trendPlot.XLabel("采样点");
trendPlot.YLabel("RMS (m/s²)");
trendPlot.Axes.SetLimitsY(0, 3);
trendPlot.Legend.IsVisible = true;
ApplyDarkTheme(trendPlot);
// 报警上限水平线
var alarmLine = trendPlot.Add.HorizontalLine(2.0);
alarmLine.Color = ScottPlot.Color.FromHex("#DC322F");
alarmLine.LineWidth = 1.5f;
alarmLine.LinePattern = LinePattern.Dashed;
alarmLine.LegendText = "报警上限";
// ── 各通道曲线创建
foreach (var (name, color, _) in _channels)
{
// 时域 SignalXY(初始单点占位)
var tc = timePlot.Add.SignalXY(new double[] { 0 }, new double[] { 0 });
tc.Color = ScottPlot.Color.FromHex(color);
tc.LegendText = name;
tc.LineWidth = 1.5f;
tc.MarkerSize = 0;
_timePlots[name] = tc;
// 频谱 SignalXY
var sc = specPlot.Add.SignalXY(new double[] { 0 }, new double[] { 0 });
sc.Color = ScottPlot.Color.FromHex(color);
sc.LegendText = $"{name} 频谱";
sc.LineWidth = 1.2f;
sc.MarkerSize = 0;
_spectrumPlots[name] = sc;
// 趋势 Signal(固定数组引用,环形写入)
var trend = trendPlot.Add.Signal(_trendRmsBuffer[name]);
trend.Color = ScottPlot.Color.FromHex(color);
trend.LegendText = $"{name} RMS";
trend.LineWidth = 1.5f;
trend.MarkerSize = 0;
_trendPlots[name] = trend;
}
TimeDomainPlot.Refresh();
SpectrumPlot.Refresh();
TrendPlot.Refresh();
}
// 数据模拟(100 Hz)
// 实际项目替换为 PLC/OPC-UA/Modbus 数据源
private void OnSimulateData(object sender, EventArgs e)
{
_simTime += 0.01; // 每 10ms 推进 0.01s
foreach (var (name, _, baseFreq) in _channels)
{
// 叠加:基频 + 二倍频 + 随机噪声 + 偶发冲击
double signal = 1.0 * Math.Sin(2 * Math.PI * baseFreq * _simTime)
+ 0.3 * Math.Sin(2 * Math.PI * 2 * baseFreq * _simTime)
+ 0.1 * (_random.NextDouble() - 0.5);
// 10% 概率触发冲击(模拟轴承点蚀)
if (_random.NextDouble() < 0.02)
signal += (_random.NextDouble() > 0.5 ? 1 : -1) * 3.0;
var q = _timeDataBuffers[name];
q.Enqueue(signal);
// 保留最近 FFT_SIZE * 2 个点,避免无限增长
while (q.Count > FFT_SIZE * 2)
q.Dequeue();
}
}
// 时域刷新(20 Hz)
private void OnRenderTimeDomain(object sender, EventArgs e)
{
bool anyUpdate = false;
foreach (var (name, _, _) in _channels)
{
var q = _timeDataBuffers[name];
if (q.Count < 2) continue;
// 取最近 TIME_WINDOW 个点
var yArr = q.TakeLast(TIME_WINDOW).ToArray();
int n = yArr.Length;
var xArr = new double[n];
double dt = 1.0 / SAMPLE_RATE;
for (int i = 0; i < n; i++)
xArr[i] = _simTime - (n - 1 - i) * dt;
var oldPlot = _timePlots[name];
TimeDomainPlot.Plot.Remove(oldPlot);
string channelColor = _channels.First(c => c.Name == name).Color;
var newPlot = TimeDomainPlot.Plot.Add.SignalXY(xArr, yArr);
newPlot.Color = ScottPlot.Color.FromHex(channelColor);
newPlot.LegendText = name;
newPlot.LineWidth = 1.5f;
newPlot.MarkerSize = 0;
_timePlots[name] = newPlot;
anyUpdate = true;
}
if (anyUpdate)
{
// 滚动 X 轴窗口
double windowSec = TIME_WINDOW / SAMPLE_RATE;
TimeDomainPlot.Plot.Axes.SetLimitsX(
_simTime - windowSec,
_simTime + 0.05);
TimeDomainPlot.Refresh();
}
}
// 频谱分析(5 Hz)
private void OnSpectrumAnalysis(object sender, EventArgs e)
{
foreach (var (name, _, _) in _channels)
{
var q = _timeDataBuffers[name];
if (q.Count < 64) continue;
int sampleCount = Math.Min(FFT_SIZE, q.Count);
var timeData = q.TakeLast(sampleCount).ToArray();
// 加汉宁窗,抑制频谱泄漏
ApplyHanningWindow(timeData);
// 计算 FFT
var (frequencies, amplitudes) = SpectrumAnalyzer.ComputeFFT(timeData, SAMPLE_RATE);
var oldPlot = _spectrumPlots[name];
SpectrumPlot.Plot.Remove(oldPlot);
string channelColor = _channels.First(c => c.Name == name).Color;
var newPlot = SpectrumPlot.Plot.Add.SignalXY(frequencies, amplitudes);
newPlot.Color = ScottPlot.Color.FromHex(channelColor);
newPlot.LegendText = $"{name} 频谱";
newPlot.LineWidth = 1.2f;
newPlot.MarkerSize = 0;
_spectrumPlots[name] = newPlot;
// 故障特征频率检测
DetectFaultFrequencies(name, frequencies, amplitudes);
}
SpectrumPlot.Refresh();
}
private static void ApplyHanningWindow(double[] data)
{
int n = data.Length;
for (int i = 0; i < n; i++)
{
double w = 0.5 * (1 - Math.Cos(2 * Math.PI * i / (n - 1)));
data[i] *= w;
}
}
private void DetectFaultFrequencies(string channel,
double[] frequencies, double[] amplitudes)
{
double rotFreq = 25.0; // 转频 25 Hz
var faults = new Dictionary<string, double>
{
["不平衡"] = rotFreq,
["不对中"] = 2 * rotFreq,
["轴承外圈"] = 6.2 * rotFreq,
["轴承内圈"] = 9.8 * rotFreq,
};
foreach (var (faultType, targetFreq) in faults)
{
int idx = Array.BinarySearch(frequencies, targetFreq);
if (idx < 0) idx = ~idx;
double maxAmp = 0;
for (int i = Math.Max(0, idx - 5);
i < Math.Min(amplitudes.Length, idx + 5); i++)
{
maxAmp = Math.Max(maxAmp, amplitudes[i]);
}
if (maxAmp > 2.0)
TriggerFaultAlarm(channel, faultType, maxAmp);
}
}
private void TriggerFaultAlarm(string channel, string faultType, double amplitude)
{
// 限制报警频率(同一类型 3 秒内不重复触发)
if ((DateTime.Now - _lastAlarmTime).TotalSeconds < 3) return;
_lastAlarmTime = DateTime.Now;
string msg = $"⚠ [{DateTime.Now:HH:mm:ss}] {channel} 检测到「{faultType}」征象 " +
$"幅值: {amplitude:F2} m/s²";
_alarmHistory.Add(msg);
Dispatcher.InvokeAsync(() =>
{
StatusText.Text = msg;
StatusText.Foreground = new SolidColorBrush(MediaColors.OrangeRed);
});
}
// 趋势更新(1 Hz)
private void OnTrendUpdate(object sender, EventArgs e)
{
int writeIdx = _trendIndex % TREND_SIZE;
double totalRms = 0;
foreach (var (name, _, _) in _channels)
{
var q = _timeDataBuffers[name];
if (q.Count == 0) continue;
// 计算当前窗口 RMS
double sumSq = q.TakeLast(Math.Min(200, q.Count)).Sum(v => v * v);
double rms = Math.Sqrt(sumSq / Math.Min(200, q.Count));
// 环形写入,Signal 存数组引用,只改元素即可[^55]
_trendRmsBuffer[name][writeIdx] = rms;
totalRms += rms;
}
_trendIndex++;
// 更新 RMS 显示标签
double avgRms = totalRms / _channels.Length;
RmsValueText.Text = $"RMS\n{avgRms:F3}\nm/s²";
RmsValueText.Foreground = avgRms > 2.0
? new SolidColorBrush(MediaColors.OrangeRed)
: new SolidColorBrush(MediaColor.FromArgb(255, 46, 204, 113)); // #2ECC71
TrendPlot.Refresh();
}
// 按钮事件
private void ClearAlarm_Click(object sender, RoutedEventArgs e)
{
StatusText.Text = "✅ 报警已清除,系统监控中...";
StatusText.Foreground = new SolidColorBrush(MediaColor.FromArgb(255, 46, 204, 113));
}
private void ExportData_Click(object sender, RoutedEventArgs e)
{
try
{
var dlg = new Microsoft.Win32.SaveFileDialog
{
Filter = "CSV 文件 (*.csv)|*.csv",
DefaultExt = "csv",
FileName = $"振动数据_{DateTime.Now:yyyyMMdd_HHmmss}.csv"
};
if (dlg.ShowDialog() != true) return;
var sb = new StringBuilder();
sb.AppendLine("通道,当前RMS,数据点数,时间");
foreach (var (name, _, _) in _channels)
{
var q = _timeDataBuffers[name];
double rms = q.Count > 0
? Math.Sqrt(q.TakeLast(200).Sum(v => v * v) / Math.Min(200, q.Count))
: 0;
sb.AppendLine($"{name},{rms:F4},{q.Count},{DateTime.Now:yyyy-MM-dd HH:mm:ss}");
}
// 附加报警历史
sb.AppendLine();
sb.AppendLine("── 报警历史 ──");
foreach (var alarm in _alarmHistory)
sb.AppendLine(alarm);
File.WriteAllText(dlg.FileName, sb.ToString(), Encoding.UTF8);
MessageBox.Show($"数据已导出至:\n{dlg.FileName}", "导出成功",
MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show($"导出失败:{ex.Message}", "错误",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// 窗口关闭:释放定时器
protected override void OnClosed(EventArgs e)
{
_dataTimer?.Stop();
_renderTimer?.Stop();
_analysisTimer?.Stop();
_trendTimer?.Stop();
base.OnClosed(e);
}
}
}

| 评估维度 | 方案一(基础版) | 方案二(生产级) | 方案三(企业级) |
|---|---|---|---|
| 开发难度 | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 性能表现 | 8-12% CPU | 15-22% CPU | 25-35% CPU |
| 功能完整度 | 基础监控 | 多设备联网 | 专业分析 |
| 维护成本 | 低 | 中等 | 较高 |
| 适用场景 | 单机验证 | 中型工厂 | 关键设备 |
| 投资回报 | 快速上线 | 长期稳定 | 专业价值 |
项目规模判断 ├─ 单台设备,<100Hz采样 → 选择方案一 ├─ 多设备监控,100Hz-1kHz → 选择方案二 └─ 关键设备,需要故障诊断 → 选择方案三
回顾整篇文章,咱们从振动监测的技术难点出发,通过三个渐进式方案解决了多轴实时数据显示的核心问题。关键收获:
架构设计决定性能上限:ScottPlot的DataStreamer + SignalXY组合,天然适配振动数据的高频特性。预分配缓冲区、循环覆写、批量刷新,这套组合拳能将性能提升一个数量级。
时间同步是多轴监控的灵魂:工业现场不是理想的实验室环境,设备采样时间必然有微小差异。用统一的时间戳管理、基于Channel的异步队列,确保数据在时间维度上的严格对齐。
频谱分析是振动诊断的核心价值:单纯显示波形只是第一步,真正的价值在于从频域特征中识别设备故障。FFT计算、特征频率检测、趋势分析,这些才是工业软件的技术壁垒。
技术话题一:你在项目中用过哪种振动传感器?加速度计、位移传感器还是速度传感器?不同传感器的数据处理有什么差异?
技术话题二:对于旋转设备的键相位信号,你是怎么处理转速计算和相位分析的?欢迎分享你的算法思路。
实战挑战:尝试在方案二的基础上,增加一个"数据回放"功能——可以加载历史振动数据文件,按时间轴播放并显示。这在故障分析和培训场景下很有用,期待看到你的实现代码!
#C#开发 #WPF #ScottPlot #振动分析 #实时监控 #FFT算法 #工业软件 #数据可视化 #性能优化 #故障诊断
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!