写在前面:咱们做工业软件的,谁还没被那些丑到爆的监控界面折磨过?今天就来聊聊如何用C#打造一个让甲方爸爸都夸赞的实时数据可视化系统。
说起工业监控软件的界面...emmm,怎么说呢?
大多数时候是这样的:灰突突的背景,花花绿绿的按钮,还有那些让人眼花缭乱的数据表格。更要命的是——卡!顿!崩!溃!
我记得之前接手一个流量监控项目。客户很直接:"小张啊,你看这界面,员工都不愿意用。能不能搞得专业一点?"
专业一点?这可难倒我了。
直到遇见了ScottPlot...
咱们先看数据:
这差距,简直是"五菱宏光"和"特斯拉"的区别。

csharp// 传统方式(想想都头疼)
chart.Series[0].Points.Clear();
foreach(var point in dataPoints) {
chart.Series[0].Points.AddXY(point.X, point.Y);
}
chart.Invalidate(); // 还要手动刷新...
// ScottPlot方式(一行搞定)
plot.Add.Signal(dataArray);
plot.Refresh(); // 就这么简单
看到没?这就是"人性化"设计的力量。
我们要搞的不是玩具,而是能上生产环境的"工业级"系统。
MVVM模式 + 实时数据流 + 高性能图表
这套组合拳,稳!
csharppublic class FlowData
{
public DateTime Timestamp { get; set; }
public double InstantFlow { get; set; } // 瞬时流量
public double TotalFlow { get; set; } // 累计流量
public FlowStatus Status { get; set; } // 四级状态
public double Pressure { get; set; } // 管道压力
}
简单?对,就是要简单!复杂的业务逻辑交给服务层去处理。
csharppublic enum FlowStatus
{
Normal, // ✅ 正常
Warning, // ⚠️ 警告
Alarm, // 🚨 报警
Low // ⬇️ 低流量
}
四个状态,对应四种颜色,对应四种处理策略。
工业现场就是这么直接。
csharppublic const int BUFFER_SIZE = 1000;
private double[] InstantFlowBuffer;
private int DataIndex = 0;
// 环形写入,永不溢出
int writeIndex = DataIndex % BUFFER_SIZE;
InstantFlowBuffer[writeIndex] = data.InstantFlow;
DataIndex++;
传统做法是List无限追加,内存占用越来越大。
我们用环形缓冲区——固定内存,无限数据。
这就是"以不变应万变"的智慧。
csharp// 创建Signal对象(直接引用数组)
_instantSignal = plt.Add.Signal(_vm.InstantFlowBuffer);
_instantSignal.Color = ScottPlot.Color.FromHex("#00C853");
_instantSignal.LineWidth = 2f;
// 数据更新后直接刷新(无需重绘)
InstantFlowPlot.Refresh();
Signal的核心思想:一次创建,持续更新。
不像传统控件每次都要重新绘制所有点。
csharp// 数据采集:200ms(5Hz)
_config.SampleIntervalMs = 200;
// UI刷新:50ms(20Hz)
_uiRefreshTimer.Interval = TimeSpan.FromMilliseconds(50);
为什么这样设计?
采集太快浪费资源,太慢响应不及时。
刷新太快眼睛跟不上,太慢感觉卡顿。
这个比例,刚好。
csharpplt.FigureBackground.Color = new ScottPlot.Color(30, 30, 30);
plt.DataBackground.Color = new ScottPlot.Color(45, 45, 48);
工业现场光线复杂,暗色主题:
这不是审美问题,这是功能性设计。
csharpvar color = _vm.FlowStatusColor switch
{
"#DC322F" => "🚨 报警状态", // 红色,立即处理
"#FFB900" => "⚠️ 警告状态", // 黄色,密切关注
"#42A5F5" => "⬇️ 低流量", // 蓝色,检查原因
_ => "✅ 正常运行" // 绿色,一切OK
};
工业界面的颜色不是随便选的。
红色=危险,黄色=警告,蓝色=信息,绿色=正常。
这是国际通用标准。
csharpprivate void SetupMouseInteraction()
{
// 瞬时流量图表鼠标交互
InstantFlowPlot.MouseMove += (s, e) =>
{
var pixel = e.GetPosition(InstantFlowPlot);
var location = InstantFlowPlot.Plot.GetCoordinates(
(float)pixel.X, (float)pixel.Y);
_instantCrosshair.Position = location;
_instantCrosshair.IsVisible = true;
// 标题显示当前坐标
InstantFlowPlot.Plot.Title(
$"瞬时流量 | 采样点: {location.X:F0} 流量: {location.Y:F2} m³/h",
size: 13);
InstantFlowPlot.Refresh();
};
InstantFlowPlot.MouseLeave += (s, e) =>
{
_instantCrosshair.IsVisible = false;
InstantFlowPlot.Plot.Title("瞬时流量实时曲线", size: 13);
InstantFlowPlot.Refresh();
};
// 累计流量图表鼠标交互
TotalFlowPlot.MouseMove += (s, e) =>
{
var pixel = e.GetPosition(TotalFlowPlot);
var location = TotalFlowPlot.Plot.GetCoordinates(
(float)pixel.X, (float)pixel.Y);
_totalCrosshair.Position = location;
_totalCrosshair.IsVisible = true;
TotalFlowPlot.Plot.Title(
$"累计流量 | 采样点: {location.X:F0} 累计: {location.Y:F4} m³",
size: 13);
TotalFlowPlot.Refresh();
};
TotalFlowPlot.MouseLeave += (s, e) =>
{
_totalCrosshair.IsVisible = false;
TotalFlowPlot.Plot.Title("累计流量趋势", size: 13);
TotalFlowPlot.Refresh();
};
}
#endregion
鼠标悬停就能看到精确数值。
这种交互,比表格查数据爽多了。
csharpif (currentMax > limits.Top * 0.8) {
// 当数据接近上限时,自动扩展范围
TotalFlowPlot.Plot.Axes.SetLimitsY(0, currentMax * 1.5);
}
累计流量会不断增长,固定范围肯定不行。
但频繁调整又会让图表"跳动"。
我们的策略:到达80%阈值时,扩展到150%。
既保证显示效果,又避免频繁调整。
csharp// 基准流量:额定值 + 正弦波 + 随机噪声
double baseFlow = _config.NominalFlow;
double sineWave = 15.0 * Math.Sin(_currentTime * 0.05);
double noise = (_random.NextDouble() - 0.5) * 8.0;
double instantFlow = baseFlow + sineWave + noise;
// 异常工况:流量突增
if (_isAnomalyMode) {
instantFlow += 35.0 + _random.NextDouble() * 20.0;
}
真实的工业数据不是简单的随机数。
它有周期性变化(设备运转周期)、有随机波动(测量误差)、有异常情况(故障工况)。
我们的模拟器考虑了这些因素。
csharpprivate FlowStatus GetFlowStatus(double flow)
{
if (flow >= _config.AlarmHigh) return FlowStatus.Alarm;
if (flow >= _config.WarningHigh) return FlowStatus.Warning;
if (flow < _config.FlowLow) return FlowStatus.Low;
return FlowStatus.Normal;
}
多级阈值判断,这是工业监控的标配。
不是简单的"正常/异常"二元判断,而是渐进式预警机制。
csharp// 数据生成线程
private readonly ConcurrentQueue<FlowData> _dataQueue;
// UI更新线程
App.Current.Dispatcher.InvokeAsync(() => {
CurrentInstantFlow = data.InstantFlow;
// ... 其他UI属性更新
});
数据采集在后台线程,UI更新在主线程。
用ConcurrentQueue做线程安全的数据传递。
用Dispatcher确保UI更新在正确线程。
这套模式,稳如泰山。
csharpprivate readonly object _bufferLock = new object();
lock (_bufferLock) {
int writeIndex = DataIndex % BUFFER_SIZE;
InstantFlowBuffer[writeIndex] = data.InstantFlow;
DataIndex++;
}
锁的粒度要合适:太大影响性能,太小容易死锁。
我们只锁核心数据写入部分。
读取操作不加锁(数组访问本身是原子的)。
xml<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="ScottPlot.WPF" Version="5.1.57" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
ScottPlot 5.x相比4.x:
MVVM Toolkit让数据绑定变得轻松。
生产环境记得加上这些监控指标:
c#using System;
using System.Windows;
using System.Windows.Threading;
using ScottPlot;
using ScottPlot.Plottables;
using AppScottPlot13.Models;
using AppScottPlot13.ViewModels;
namespace AppScottPlot13
{
public partial class MainWindow : Window
{
#region ===== 私有字段 =====
private readonly FlowMonitorViewModel _vm;
private readonly DispatcherTimer _uiRefreshTimer;
// 瞬时流量图表对象
private Signal _instantSignal;
private HorizontalLine _warningLine;
private HorizontalLine _alarmLine;
private HorizontalLine _lowLine;
private Crosshair _instantCrosshair;
// 累计流量图表对象
private Signal _totalSignal;
private Crosshair _totalCrosshair;
// 图表刷新控制
private bool _instantChartInitialized = false;
private bool _totalChartInitialized = false;
#endregion
public MainWindow()
{
InitializeComponent();
_vm = new FlowMonitorViewModel();
DataContext = _vm;
// 初始化两个图表
InitializeInstantFlowChart();
InitializeTotalFlowChart();
// 绑定鼠标交互
SetupMouseInteraction();
// UI 定时刷新(20Hz = 50ms)
_uiRefreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50)
};
_uiRefreshTimer.Tick += OnUiRefreshTick;
_uiRefreshTimer.Start();
// 自动启动监控
_vm.StartMonitorCommand.Execute(null);
}
#region ===== 图表初始化 =====
/// <summary>初始化瞬时流量图表</summary>
private void InitializeInstantFlowChart()
{
var plt = InstantFlowPlot.Plot;
// ---- 工业暗色主题 ----
ApplyIndustrialTheme(plt);
// ---- 创建信号曲线(存数组引用,修改数据后直接 Refresh)----
_instantSignal = plt.Add.Signal(_vm.InstantFlowBuffer);
_instantSignal.Color = ScottPlot.Color.FromHex("#00C853");
_instantSignal.LineWidth = 2f;
_instantSignal.LegendText = "瞬时流量 (m³/h)";
_instantSignal.MarkerSize = 0;
// ---- 报警阈值线(保存引用以便动态更新)----
_alarmLine = plt.Add.HorizontalLine(_vm.Config.AlarmHigh);
_alarmLine.Color = ScottPlot.Color.FromHex("#DC322F");
_alarmLine.LineWidth = 2f;
_alarmLine.LinePattern = LinePattern.Solid;
_alarmLine.LegendText = $"报警上限 ({_vm.Config.AlarmHigh} m³/h)";
_warningLine = plt.Add.HorizontalLine(_vm.Config.WarningHigh);
_warningLine.Color = ScottPlot.Color.FromHex("#FFB900");
_warningLine.LineWidth = 1.5f;
_warningLine.LinePattern = LinePattern.Dashed;
_warningLine.LegendText = $"警告上限 ({_vm.Config.WarningHigh} m³/h)";
_lowLine = plt.Add.HorizontalLine(_vm.Config.FlowLow);
_lowLine.Color = ScottPlot.Color.FromHex("#42A5F5");
_lowLine.LineWidth = 1.5f;
_lowLine.LinePattern = LinePattern.Dashed;
_lowLine.LegendText = $"低流量下限 ({_vm.Config.FlowLow} m³/h)";
// ---- 额定流量参考线 ----
var nominalLine = plt.Add.HorizontalLine(_vm.Config.NominalFlow);
nominalLine.Color = ScottPlot.Color.FromHex("#505050");
nominalLine.LineWidth = 1f;
nominalLine.LinePattern = LinePattern.Dotted;
// ---- 十字光标 ----
_instantCrosshair = plt.Add.Crosshair(0, 0);
_instantCrosshair.LineColor = ScottPlot.Color.FromHex("#C8C8C8");
_instantCrosshair.LineWidth = 1f;
_instantCrosshair.LinePattern = LinePattern.Dotted;
_instantCrosshair.IsVisible = false;
// ---- 坐标轴配置 ----
// 固定Y轴范围(避免AutoScale计算开销)
plt.Axes.SetLimitsY(-5, 180);
plt.Axes.SetLimitsX(0, FlowMonitorViewModel.BUFFER_SIZE);
plt.Axes.Bottom.Label.Text = "采样点";
plt.Axes.Left.Label.Text = "流量 (m³/h)";
plt.Title("瞬时流量", size: 14);
// ---- Y轴精度格式化 ----
var leftGen = new ScottPlot.TickGenerators.NumericAutomatic
{
LabelFormatter = v => $"{v:F1}"
};
plt.Axes.Left.TickGenerator = leftGen;
// ---- 图例 ----
plt.Legend.IsVisible = true;
plt.Legend.Alignment = Alignment.UpperRight;
plt.Legend.BackgroundColor = ScottPlot.Color.FromHex("#2D2D30");
plt.Legend.FontColor = ScottPlot.Color.FromHex("#C8C8C8");
plt.Legend.OutlineColor = ScottPlot.Color.FromHex("#505050");
// ---- 关闭抗锯齿(大数据量性能优化)----
// plt.RenderManager.AntiAliasingLevel = 0;
InstantFlowPlot.Refresh();
_instantChartInitialized = true;
}
/// <summary>初始化累计流量图表</summary>
private void InitializeTotalFlowChart()
{
var plt = TotalFlowPlot.Plot;
// ---- 工业暗色主题 ----
ApplyIndustrialTheme(plt);
// ---- 创建累计流量信号曲线 ----
_totalSignal = plt.Add.Signal(_vm.TotalFlowBuffer);
_totalSignal.Color = ScottPlot.Color.FromHex("#42A5F5");
_totalSignal.LineWidth = 2f;
_totalSignal.LegendText = "累计流量 (m³)";
_totalSignal.MarkerSize = 0;
// ---- 十字光标 ----
_totalCrosshair = plt.Add.Crosshair(0, 0);
_totalCrosshair.LineColor = ScottPlot.Color.FromHex("#C8C8C8");
_totalCrosshair.LineWidth = 1f;
_totalCrosshair.LinePattern = LinePattern.Dotted;
_totalCrosshair.IsVisible = false;
// ---- 坐标轴配置 ----
plt.Axes.SetLimitsX(0, FlowMonitorViewModel.BUFFER_SIZE);
// 累计流量Y轴范围动态扩展,初始给一个合理范围
plt.Axes.SetLimitsY(0, 50);
plt.Axes.Bottom.Label.Text = "采样点";
plt.Axes.Left.Label.Text = "累计流量 (m³)";
plt.Title("累计流量", size: 14);
// ---- Y轴精度格式化(保留3位小数)----
var leftGen = new ScottPlot.TickGenerators.NumericAutomatic
{
LabelFormatter = v => $"{v:F3}"
};
plt.Axes.Left.TickGenerator = leftGen;
// ---- 图例 ----
plt.Legend.IsVisible = true;
plt.Legend.Alignment = Alignment.UpperLeft;
plt.Legend.BackgroundColor = ScottPlot.Color.FromHex("#2D2D30");
plt.Legend.FontColor = ScottPlot.Color.FromHex("#C8C8C8");
plt.Legend.OutlineColor = ScottPlot.Color.FromHex("#505050");
TotalFlowPlot.Refresh();
_totalChartInitialized = true;
}
private static void ApplyIndustrialTheme(Plot plt)
{
plt.Font.Set("Microsoft YaHei");
plt.Axes.Bottom.Label.FontName = "Microsoft YaHei";
plt.Axes.Left.Label.FontName = "Microsoft YaHei";
// 暗色背景
plt.FigureBackground.Color = new ScottPlot.Color(30, 30, 30);
plt.DataBackground.Color = new ScottPlot.Color(45, 45, 48);
// 层次化网格
plt.Grid.MajorLineColor = ScottPlot.Colors.Gray.WithAlpha(100);
plt.Grid.MajorLineWidth = 1f;
plt.Grid.MinorLineColor = ScottPlot.Colors.Gray.WithAlpha(40);
plt.Grid.MinorLineWidth = 0.5f;
// 坐标轴颜色
plt.Axes.Color(ScottPlot.Color.FromHex("#C8C8C8"));
// 坐标轴框线
plt.Axes.Bottom.FrameLineStyle.Color =
ScottPlot.Colors.Gray.WithAlpha(150);
plt.Axes.Bottom.FrameLineStyle.Width = 1.5f;
plt.Axes.Left.FrameLineStyle.Color =
ScottPlot.Colors.Gray.WithAlpha(150);
plt.Axes.Left.FrameLineStyle.Width = 1.5f;
// 刻度标签字体大小
plt.Axes.Bottom.TickLabelStyle.FontSize = 11;
plt.Axes.Left.TickLabelStyle.FontSize = 11;
// 坐标轴标签样式
plt.Axes.Bottom.Label.FontSize = 12;
plt.Axes.Bottom.Label.Bold = true;
plt.Axes.Left.Label.FontSize = 12;
plt.Axes.Left.Label.Bold = true;
}
#endregion
#region ===== 定时刷新 =====
private void OnUiRefreshTick(object? sender, EventArgs e)
{
if (!_instantChartInitialized || !_totalChartInitialized) return;
if (!_vm.IsRunning) return;
// ---- 刷新瞬时流量图表 ----
UpdateInstantFlowChartColor();
InstantFlowPlot.Refresh();
// ---- 刷新累计流量图表 ----
// 累计流量单调递增,动态扩展Y轴上限
DynamicExpandTotalFlowYAxis();
TotalFlowPlot.Refresh();
}
private void UpdateInstantFlowChartColor()
{
var color = _vm.FlowStatusColor switch
{
"#DC322F" => ScottPlot.Color.FromHex("#DC322F"),
"#FFB900" => ScottPlot.Color.FromHex("#FFB900"),
"#42A5F5" => ScottPlot.Color.FromHex("#42A5F5"),
_ => ScottPlot.Color.FromHex("#00C853")
};
_instantSignal.Color = color;
}
private void DynamicExpandTotalFlowYAxis()
{
double currentMax = _vm.CurrentTotalFlow;
if (currentMax <= 0) return;
// 获取当前Y轴上限
var limits = TotalFlowPlot.Plot.Axes.GetLimits();
if (currentMax > limits.Top * 0.8)
{
// 扩展为当前最大值的 1.5 倍
TotalFlowPlot.Plot.Axes.SetLimitsY(0, currentMax * 1.5);
}
}
#endregion
#region ===== 鼠标交互 =====
private void SetupMouseInteraction()
{
// 瞬时流量图表鼠标交互
InstantFlowPlot.MouseMove += (s, e) =>
{
var pixel = e.GetPosition(InstantFlowPlot);
var location = InstantFlowPlot.Plot.GetCoordinates(
(float)pixel.X, (float)pixel.Y);
_instantCrosshair.Position = location;
_instantCrosshair.IsVisible = true;
// 标题显示当前坐标
InstantFlowPlot.Plot.Title(
$"瞬时流量 | 采样点: {location.X:F0} 流量: {location.Y:F2} m³/h",
size: 13);
InstantFlowPlot.Refresh();
};
InstantFlowPlot.MouseLeave += (s, e) =>
{
_instantCrosshair.IsVisible = false;
InstantFlowPlot.Plot.Title("瞬时流量实时曲线", size: 13);
InstantFlowPlot.Refresh();
};
// 累计流量图表鼠标交互
TotalFlowPlot.MouseMove += (s, e) =>
{
var pixel = e.GetPosition(TotalFlowPlot);
var location = TotalFlowPlot.Plot.GetCoordinates(
(float)pixel.X, (float)pixel.Y);
_totalCrosshair.Position = location;
_totalCrosshair.IsVisible = true;
TotalFlowPlot.Plot.Title(
$"累计流量 | 采样点: {location.X:F0} 累计: {location.Y:F4} m³",
size: 13);
TotalFlowPlot.Refresh();
};
TotalFlowPlot.MouseLeave += (s, e) =>
{
_totalCrosshair.IsVisible = false;
TotalFlowPlot.Plot.Title("累计流量趋势", size: 13);
TotalFlowPlot.Refresh();
};
}
#endregion
#region ===== 窗口关闭 =====
protected override void OnClosed(EventArgs e)
{
_uiRefreshTimer?.Stop();
_vm?.Dispose();
base.OnClosed(e);
}
#endregion
}
}
c#using System;
using System.Collections.Generic;
using System.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using AppScottPlot13.Models;
using AppScottPlot13.Services;
using System.Windows.Media;
namespace AppScottPlot13.ViewModels
{
public partial class FlowMonitorViewModel : ObservableObject, IDisposable
{
#region ===== 属性定义 =====
[ObservableProperty] private double _currentInstantFlow;
[ObservableProperty] private double _currentTotalFlow;
[ObservableProperty] private double _currentPressure;
[ObservableProperty] private string _flowStatusText = "正常";
[ObservableProperty] private string _flowStatusColor = "#00C853";
[ObservableProperty] private bool _isRunning = false;
[ObservableProperty] private double _maxInstantFlow;
[ObservableProperty] private double _minInstantFlow = double.MaxValue;
[ObservableProperty] private double _avgInstantFlow;
[ObservableProperty] private int _dataPointCount;
[ObservableProperty] private string _runTimeText = "00:00:00";
public bool IsNotRunning => !IsRunning;
public Brush FlowStatusBrush => new SolidColorBrush(ParseColor(FlowStatusColor));
// 供 View 中图表使用的共享数据
public double[] InstantFlowBuffer { get; private set; }
public double[] TotalFlowBuffer { get; private set; }
public int DataIndex { get; private set; } = 0;
public FlowMeterConfig Config { get; } = new FlowMeterConfig();
#endregion
#region ===== 私有字段 =====
private readonly FlowMeterSimulator _simulator;
private readonly object _bufferLock = new object();
private DateTime _startTime;
// 统计数据
private double _flowSum = 0;
private int _flowCount = 0;
// 环形缓冲区大小
public const int BUFFER_SIZE = 1000;
#endregion
public FlowMonitorViewModel()
{
InstantFlowBuffer = new double[BUFFER_SIZE];
TotalFlowBuffer = new double[BUFFER_SIZE];
_simulator = new FlowMeterSimulator(Config);
_simulator.OnDataReceived += OnFlowDataReceived;
}
#region ===== 命令 =====
[RelayCommand]
private void StartMonitor()
{
if (IsRunning) return;
_startTime = DateTime.Now;
_simulator.Start();
IsRunning = true;
}
[RelayCommand]
private void StopMonitor()
{
if (!IsRunning) return;
_simulator.Stop();
IsRunning = false;
}
[RelayCommand]
private void ResetTotal()
{
lock (_bufferLock)
{
Array.Clear(TotalFlowBuffer, 0, TotalFlowBuffer.Length);
Array.Clear(InstantFlowBuffer, 0, InstantFlowBuffer.Length);
DataIndex = 0;
_flowSum = 0;
_flowCount = 0;
MaxInstantFlow = 0;
MinInstantFlow = double.MaxValue;
AvgInstantFlow = 0;
DataPointCount = 0;
}
}
#endregion
#region ===== 数据处理 =====
partial void OnIsRunningChanged(bool value)
{
OnPropertyChanged(nameof(IsNotRunning));
}
partial void OnFlowStatusColorChanged(string value)
{
OnPropertyChanged(nameof(FlowStatusBrush));
}
private static Color ParseColor(string? hex)
{
if (!string.IsNullOrWhiteSpace(hex))
{
try
{
var parsed = System.Windows.Media.ColorConverter.ConvertFromString(hex);
if (parsed is Color color)
return color;
}
catch
{
}
}
return Colors.White;
}
private void OnFlowDataReceived(FlowData data)
{
lock (_bufferLock)
{
// 环形写入
int writeIndex = DataIndex % BUFFER_SIZE;
InstantFlowBuffer[writeIndex] = data.InstantFlow;
TotalFlowBuffer[writeIndex] = data.TotalFlow;
DataIndex++;
// 统计
_flowSum += data.InstantFlow;
_flowCount++;
if (data.InstantFlow > MaxInstantFlow)
MaxInstantFlow = data.InstantFlow;
if (data.InstantFlow < MinInstantFlow)
MinInstantFlow = data.InstantFlow;
AvgInstantFlow = Math.Round(_flowSum / _flowCount, 2);
}
// 更新 UI 绑定属性(需在主线程更新)
App.Current.Dispatcher.InvokeAsync(() =>
{
CurrentInstantFlow = data.InstantFlow;
CurrentTotalFlow = data.TotalFlow;
CurrentPressure = data.Pressure;
DataPointCount++;
RunTimeText = (DateTime.Now - _startTime).ToString(@"hh\:mm\:ss");
// 更新状态颜色
(FlowStatusText, FlowStatusColor) = data.Status switch
{
FlowStatus.Alarm => ("🚨 报警", "#DC322F"),
FlowStatus.Warning => ("⚠ 警告", "#FFB900"),
FlowStatus.Low => ("⬇ 低流量", "#42A5F5"),
_ => ("✅ 正常", "#00C853")
};
});
}
public void Dispose()
{
_simulator?.Dispose();
}
#endregion
}
}
c#using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using AppScottPlot13.Models;
namespace AppScottPlot13.Services
{
/// <summary>
/// 流量计数据模拟器(生产环境替换为真实 OPC UA / Modbus 采集)
/// </summary>
public class FlowMeterSimulator : IDisposable
{
private readonly FlowMeterConfig _config;
private readonly ConcurrentQueue<FlowData> _dataQueue;
private CancellationTokenSource _cts;
private double _totalFlow = 0.0;
private double _currentTime = 0.0;
private readonly Random _random = new Random(42);
private bool _isAnomalyMode = false; // 模拟异常工况
private double _anomalyTimer = 0.0;
/// <summary>数据队列(线程安全)</summary>
public ConcurrentQueue<FlowData> DataQueue => _dataQueue;
public event Action<FlowData>? OnDataReceived;
public FlowMeterSimulator(FlowMeterConfig config)
{
_config = config;
_dataQueue = new ConcurrentQueue<FlowData>();
}
/// <summary>启动数据采集</summary>
public void Start()
{
_cts = new CancellationTokenSource();
Task.Run(() => SimulateLoop(_cts.Token), _cts.Token);
}
/// <summary>停止数据采集</summary>
public void Stop() => _cts?.Cancel();
private async Task SimulateLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
var data = GenerateFlowData();
_dataQueue.Enqueue(data);
OnDataReceived?.Invoke(data);
await Task.Delay(
TimeSpan.FromMilliseconds(_config.SampleIntervalMs),
token);
}
catch (OperationCanceledException)
{
break;
}
}
}
private FlowData GenerateFlowData()
{
_currentTime += _config.SampleIntervalMs / 1000.0;
// 随机切换异常工况(每隔30~60秒出现一次)
_anomalyTimer += _config.SampleIntervalMs / 1000.0;
if (_anomalyTimer > 30 + _random.NextDouble() * 30)
{
_isAnomalyMode = !_isAnomalyMode;
_anomalyTimer = 0;
}
// 基准流量:正弦波 + 随机噪声
double baseFlow = _config.NominalFlow;
double sineWave = 15.0 * Math.Sin(_currentTime * 0.05);
double noise = (_random.NextDouble() - 0.5) * 8.0;
double instantFlow = baseFlow + sineWave + noise;
// 模拟异常工况:流量突增
if (_isAnomalyMode)
{
instantFlow += 35.0 + _random.NextDouble() * 20.0;
}
// 偶发性低流量(模拟管道堵塞)
if (_random.NextDouble() < 0.02)
{
instantFlow = _config.FlowLow * 0.5;
}
instantFlow = Math.Max(0, instantFlow);
// 累计流量:积分计算 (m³)
_totalFlow += instantFlow * (_config.SampleIntervalMs / 3600000.0);
// 管道压力(与流量正相关)
double pressure = 0.3 + (instantFlow / _config.NominalFlow) * 0.4
+ (_random.NextDouble() - 0.5) * 0.05;
// 判断状态
var status = GetFlowStatus(instantFlow);
return new FlowData
{
Timestamp = DateTime.Now,
InstantFlow = Math.Round(instantFlow, 2),
TotalFlow = Math.Round(_totalFlow, 4),
Pressure = Math.Round(pressure, 3),
Status = status
};
}
private FlowStatus GetFlowStatus(double flow)
{
if (flow >= _config.AlarmHigh) return FlowStatus.Alarm;
if (flow >= _config.WarningHigh) return FlowStatus.Warning;
if (flow < _config.FlowLow) return FlowStatus.Low;
return FlowStatus.Normal;
}
public void Dispose() => Stop();
}
}


环形缓冲区 + Signal绘图 + 合理的刷新频率
这套组合能让你的界面在大数据量下依然丝滑流畅。
暗色主题 + 状态颜色 + 交互反馈
不是为了"酷炫",而是为了实用。
MVVM模式 + 线程安全 + 配置化设计
好的架构让后期维护变得轻松。
最后想说:做工业软件,技术很重要,但理解业务场景更重要。
用户要的不是华丽的特效,而是稳定、清晰、高效的数据呈现。
ScottPlot + WPF这个组合,能让我们专注于业务逻辑,而不用在界面性能上费心思。
你觉得呢?欢迎评论区聊聊你在工业软件开发中遇到的"坑"~
#C#开发 #工业软件 #数据可视化 #ScottPlot #实时监控
相关信息
通过网盘分享的文件:AppScottPlot13.zip 链接: https://pan.baidu.com/s/1jimZCWDjthSgm37hd-B8bw?pwd=a5v9 提取码: a5v9 --来自百度网盘超级会员v9的分享
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!