Files
ACP/CurveModule/ViewModels/CurveRecallViewModel.cs
2026-07-31 09:03:09 +08:00

735 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Logger;
using OxyPlot;
using OxyPlot.Annotations;
using OxyPlot.Axes;
using OxyPlot.Legends;
using OxyPlot.Series;
using Prism.Commands;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Text;
using System.Windows.Input;
using CurveModule.ViewModels.Dialogs;
using UIShare.ViewModelBase;
using UIShare.GlobalVariable;
namespace CurveModule.ViewModels
{
/// <summary>
/// 曲线回读:加载 RecordView 导出的 CSV 文件,按 (MonitorName, Scope) 分组绘制曲线。
/// </summary>
public class CurveRecallViewModel : NavigateViewModelBase, IRegionMemberLifetime, IDisposable
{
#region
private GlobalConfig _globalConfig;
#endregion
#region
public bool KeepAlive => true;
public PlotModel Plot { get; }
/// <summary>已加载的曲线通道列表</summary>
public ObservableCollection<CurveRecallChannelVM> Channels { get; } = new();
private CurveRecallChannelVM? _selectedChannel;
public CurveRecallChannelVM? SelectedChannel
{
get => _selectedChannel;
set => SetProperty(ref _selectedChannel, value);
}
private string _statusMessage = "请加载 CSV 文件以回读曲线";
public string StatusMessage
{
get => _statusMessage;
set => SetProperty(ref _statusMessage, value);
}
#endregion
#region
public ICommand LoadCsvCommand { get; }
public ICommand ResetViewCommand { get; }
public ICommand ClearCommand { get; }
public ICommand StatisticsCommand { get; }
public ICommand SaveCommand { get; }
public ICommand LoadCommand { get; }
public ICommand CaptureCommand { get; }
#endregion
#region
// 颜色调色盘(与 MonitorView 一致)
private static readonly OxyColor[] _palette =
{
OxyColors.SteelBlue, OxyColors.IndianRed, OxyColors.SeaGreen,
OxyColors.DarkOrange, OxyColors.MediumPurple, OxyColors.Goldenrod,
OxyColors.Teal, OxyColors.Crimson, OxyColors.OliveDrab
};
private int _colorIndex;
// 双光标截取
private LineAnnotation? _cursor1;
private LineAnnotation? _cursor2;
private LineAnnotation? _draggingCursor;
private List<MonitorRecord> _allRecords = new();
#endregion
/// <summary>截取按钮文本(随状态切换)</summary>
private string _captureButtonText = "开始截取图形";
public string CaptureButtonText
{
get => _captureButtonText;
set => SetProperty(ref _captureButtonText, value);
}
public CurveRecallViewModel(IContainerProvider containerProvider) : base(containerProvider)
{
_globalConfig = containerProvider.Resolve<GlobalConfig>();
Plot = BuildEmptyPlot();
LoadCsvCommand = new DelegateCommand<string>(OnLoadCsv);
ResetViewCommand = new DelegateCommand(OnResetView);
ClearCommand = new DelegateCommand(OnClear);
StatisticsCommand = new DelegateCommand(OnStatistics);
SaveCommand = new DelegateCommand(OnSave);
LoadCommand = new DelegateCommand(Onload);
CaptureCommand = new DelegateCommand(OnToggleCapture);
}
#region
private void OnSave()
{
ConfigService.SaveGlobalConfig(_globalConfig);
}
private void Onload()
{
if (File.Exists(_globalConfig.DefaultCSVFilePath))
{
OnLoadCsv(_globalConfig.DefaultCSVFilePath);
}
}
/// <summary>
/// 打开 CSV 文件对话框,解析数据并按 (MonitorName, Scope) 分组绘制曲线。
/// </summary>
private void OnLoadCsv(string filename)
{
try
{
// 1. 统一处理 filename
if (string.IsNullOrEmpty(filename))
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Filter = "CSV 文件 (*.csv)|*.csv|所有文件|*.*",
Title = "选择要加载的监测数据 CSV 文件"
};
if (dlg.ShowDialog() != true)
return;
// 将弹窗选中的路径统一赋给 filename
filename = dlg.FileName;
_globalConfig.DefaultCSVFilePath = filename;
}
// 2. 统一解析数据
List<MonitorRecord> records = ParseCsv(filename);
_allRecords = records;
if (records.Count == 0)
{
StatusMessage = "CSV 文件为空或格式不正确";
return;
}
// 3. 按 (MonitorName, Scope) 分组
var groups = records.GroupBy(r => (r.MonitorName, r.Scope))
.OrderBy(g => g.Key.MonitorName)
.ThenBy(g => g.Key.Scope);
// 清空旧数据
ClearPlotAndChannels();
foreach (var group in groups)
{
var color = _palette[_colorIndex % _palette.Length];
_colorIndex++;
string displayName = string.IsNullOrEmpty(group.Key.Scope)
? group.Key.MonitorName
: $"{group.Key.MonitorName} [{group.Key.Scope}]";
var channel = new CurveRecallChannelVM
{
MonitorName = group.Key.MonitorName,
Scope = group.Key.Scope,
DisplayName = displayName,
Color = color,
IsDisplayed = true
};
// 创建 LineSeries 并填充数据点
channel.Series = new LineSeries
{
Title = displayName,
Color = color,
StrokeThickness = 1.5
};
// 按时间排序
var sortedPoints = group.OrderBy(r => r.CreateTime);
foreach (var point in sortedPoints)
{
channel.Series.Points.Add(new DataPoint(point.CreateTime.ToOADate(), point.MonitorValue));
}
channel.PropertyChanged += OnChannelPropertyChanged;
Plot.Series.Add(channel.Series);
Channels.Add(channel);
}
Plot.InvalidatePlot(true);
// 4. 统一使用 filename 变量进行日志和状态更新
StatusMessage = $"已加载 {Path.GetFileName(filename)},共 {Channels.Count} 条曲线,{records.Count} 个数据点";
LoggerHelper.Info($"曲线回读:加载 {filename}{Channels.Count} 条曲线,{records.Count} 个数据点");
}
catch (Exception ex)
{
LoggerHelper.Error($"加载 CSV 失败:{ex.Message}");
StatusMessage = $"加载失败:{ex.Message}";
}
}
private void OnResetView()
{
Plot.ResetAllAxes();
Plot.InvalidatePlot(false);
StatusMessage = "视图已复原";
}
private void OnClear()
{
RemoveCursors();
CaptureButtonText = "开始截取图形";
ClearPlotAndChannels();
_allRecords.Clear();
Plot.InvalidatePlot(true);
StatusMessage = "已清空,请重新加载 CSV 文件";
}
/// <summary>
/// 对每条曲线计算数学统计(最大、最小、平均、斜率、积分),弹窗显示。
/// </summary>
private void OnStatistics()
{
if (Channels.Count == 0)
{
ShowInfoMessageBox("请先加载 CSV 文件", () => { });
return;
}
var results = new ObservableCollection<CurveStatisticsResult>();
foreach (var channel in Channels)
{
if (channel.Series == null || channel.Series.Points.Count == 0) continue;
results.Add(ComputeStatistics(channel));
}
if (results.Count == 0)
{
ShowInfoMessageBox("没有可统计的曲线数据", () => { });
return;
}
var parameters = new DialogParameters();
parameters.Add("Results", results);
_dialogService.ShowDialog("CurveStatistics", parameters, _ => { });
StatusMessage = $"已计算 {results.Count} 条曲线的数学统计";
}
#endregion
#region CSV
/// <summary>
/// 解析 RecordView 导出的 CSV 文件,返回监测记录列表。
/// </summary>
private static List<MonitorRecord> ParseCsv(string path)
{
var results = new List<MonitorRecord>();
using var sr = new StreamReader(path, Encoding.UTF8);
// 读取表头
string? headerLine = sr.ReadLine();
if (headerLine == null) return results;
var headers = ParseCsvLine(headerLine);
int monitorNameIdx = headers.FindIndex(h => h.Trim() == "MonitorName");
int monitorValueIdx = headers.FindIndex(h => h.Trim() == "MonitorValue");
int scopeIdx = headers.FindIndex(h => h.Trim() == "Scope");
int createTimeIdx = headers.FindIndex(h => h.Trim() == "CreateTime");
if (monitorNameIdx < 0 || monitorValueIdx < 0 || scopeIdx < 0 || createTimeIdx < 0)
return results;
int maxIdx = Math.Max(monitorNameIdx, Math.Max(monitorValueIdx, Math.Max(scopeIdx, createTimeIdx)));
string? line;
while ((line = sr.ReadLine()) != null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
var fields = ParseCsvLine(line);
if (fields.Count <= maxIdx) continue;
if (!double.TryParse(fields[monitorValueIdx].Trim(), out double value)) continue;
if (!DateTime.TryParse(fields[createTimeIdx].Trim(), out DateTime createTime)) continue;
results.Add(new MonitorRecord
{
MonitorName = fields[monitorNameIdx].Trim(),
MonitorValue = value,
Scope = fields[scopeIdx].Trim(),
CreateTime = createTime
});
}
return results;
}
/// <summary>
/// 解析单行 CSV正确处理引号转义。
/// </summary>
private static List<string> ParseCsvLine(string line)
{
var fields = new List<string>();
bool inQuotes = false;
var sb = new StringBuilder();
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (inQuotes)
{
if (c == '"')
{
if (i + 1 < line.Length && line[i + 1] == '"')
{
sb.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
sb.Append(c);
}
}
else
{
if (c == '"')
{
inQuotes = true;
}
else if (c == ',')
{
fields.Add(sb.ToString());
sb.Clear();
}
else
{
sb.Append(c);
}
}
}
fields.Add(sb.ToString());
return fields;
}
#endregion
#region OxyPlot
/// <summary>
/// 构建空的 OxyPlot 模型(与 MonitorView 完全一致的配置)。
/// </summary>
private static PlotModel BuildEmptyPlot()
{
var pm = new PlotModel
{
Title = "曲线回读",
PlotAreaBorderColor = OxyColors.LightGray,
Background = OxyColors.White
};
pm.Axes.Add(new DateTimeAxis
{
Position = AxisPosition.Bottom,
Title = "时间",
StringFormat = "HH:mm:ss",
MajorGridlineStyle = LineStyle.Dot,
MinorGridlineStyle = LineStyle.None
});
pm.Axes.Add(new LinearAxis
{
Position = AxisPosition.Left,
Title = "值",
MajorGridlineStyle = LineStyle.Dot,
MinorGridlineStyle = LineStyle.None
});
pm.Legends.Add(new Legend
{
LegendPosition = LegendPosition.RightTop,
LegendBackground = OxyColor.FromAColor(200, OxyColors.White),
LegendBorder = OxyColors.LightGray
});
return pm;
}
#endregion
#region
/// <summary>
/// 对单条曲线计算数学统计:最大值、最小值、平均值、线性回归斜率、梯形积分。
/// 斜率单位:值/秒;积分单位:值×秒。
/// </summary>
private static CurveStatisticsResult ComputeStatistics(CurveRecallChannelVM channel)
{
var points = channel.Series!.Points;
int n = points.Count;
if (n == 0)
return new CurveStatisticsResult
{
DisplayName = channel.DisplayName,
PointCount = 0
};
double max = double.MinValue;
double min = double.MaxValue;
double sumY = 0;
// 线性回归累计量
double sumX = 0, sumXY = 0, sumX2 = 0;
// 梯形积分累计量
double integral = 0;
for (int i = 0; i < n; i++)
{
double x = points[i].X; // OADate 天数
double y = points[i].Y;
if (y > max) max = y;
if (y < min) min = y;
sumY += y;
sumX += x;
sumXY += x * y;
sumX2 += x * x;
if (i > 0)
{
double dx = x - points[i - 1].X;
integral += dx * (y + points[i - 1].Y) / 2.0;
}
}
double average = sumY / n;
// 线性回归斜率(单位:值 / OADate 天)
double slopePerDay = 0;
double denominator = n * sumX2 - sumX * sumX;
if (Math.Abs(denominator) > 1e-30)
{
slopePerDay = (n * sumXY - sumX * sumY) / denominator;
}
// 转换为 值/秒
double slopePerSecond = slopePerDay / 86400.0;
// 积分从 OADate·值 转换为 秒·值
double integralSeconds = integral * 86400.0;
return new CurveStatisticsResult
{
DisplayName = channel.DisplayName,
PointCount = n,
Max = max,
Min = min,
Average = average,
Slope = slopePerSecond,
Integral = integralSeconds
};
}
private void OnChannelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(CurveRecallChannelVM.IsDisplayed)) return;
if (sender is not CurveRecallChannelVM channel) return;
if (channel.IsDisplayed)
{
if (channel.Series != null && !Plot.Series.Contains(channel.Series))
{
Plot.Series.Add(channel.Series);
}
}
else
{
if (channel.Series != null)
{
Plot.Series.Remove(channel.Series);
}
}
Plot.InvalidatePlot(true);
}
private void ClearPlotAndChannels()
{
foreach (var ch in Channels)
{
ch.PropertyChanged -= OnChannelPropertyChanged;
}
Plot.Series.Clear();
Channels.Clear();
_colorIndex = 0;
}
#endregion
#region
/// <summary>
/// 切换截取模式:添加/移除双光标,结束时导出选中范围 CSV。
/// </summary>
private void OnToggleCapture()
{
if (_cursor1 == null)
{
CreateCursors();
CaptureButtonText = "结束截取图形";
}
else
{
double x1 = _cursor1.X;
double x2 = _cursor2!.X;
double minX = Math.Min(x1, x2);
double maxX = Math.Max(x1, x2);
RemoveCursors();
CaptureButtonText = "开始截取图形";
ExportCapturedCsv(minX, maxX);
}
}
/// <summary>
/// 在图表上添加两条可拖拽的竖直光标。
/// </summary>
private void CreateCursors()
{
if (Channels.Count == 0) return;
var dateAxis = Plot.Axes.OfType<DateTimeAxis>().FirstOrDefault();
if (dateAxis == null) return;
double xMin = dateAxis.ActualMinimum;
double xMax = dateAxis.ActualMaximum;
double range = xMax - xMin;
if (range <= 0) return;
_cursor1 = new LineAnnotation
{
Type = LineAnnotationType.Vertical,
X = xMin + range * 0.3,
Color = OxyColors.SteelBlue,
StrokeThickness = 2,
LineStyle = LineStyle.Dash,
Text = "▼",
TextColor = OxyColors.SteelBlue,
TextHorizontalAlignment = HorizontalAlignment.Center,
TextVerticalAlignment = VerticalAlignment.Top
};
_cursor2 = new LineAnnotation
{
Type = LineAnnotationType.Vertical,
X = xMin + range * 0.7,
Color = OxyColors.SteelBlue,
StrokeThickness = 2,
LineStyle = LineStyle.Dash,
Text = "▼",
TextColor = OxyColors.SteelBlue,
TextHorizontalAlignment = HorizontalAlignment.Center,
TextVerticalAlignment = VerticalAlignment.Top
};
_cursor1.MouseDown += OnCursorMouseDown;
_cursor1.MouseUp += OnCursorMouseUp;
_cursor2.MouseDown += OnCursorMouseDown;
_cursor2.MouseUp += OnCursorMouseUp;
Plot.MouseDown += OnPlotMouseMove;
Plot.Annotations.Add(_cursor1);
Plot.Annotations.Add(_cursor2);
Plot.InvalidatePlot(true);
StatusMessage = "已添加双光标,拖拽竖线选择范围,完成后点击「结束截取图形」";
}
/// <summary>
/// 移除双光标并清理事件。
/// </summary>
private void RemoveCursors()
{
if (_cursor1 != null)
{
_cursor1.MouseDown -= OnCursorMouseDown;
_cursor1.MouseUp -= OnCursorMouseUp;
Plot.Annotations.Remove(_cursor1);
_cursor1 = null;
}
if (_cursor2 != null)
{
_cursor2.MouseDown -= OnCursorMouseDown;
_cursor2.MouseUp -= OnCursorMouseUp;
Plot.Annotations.Remove(_cursor2);
_cursor2 = null;
}
Plot.MouseDown -= OnPlotMouseMove;
_draggingCursor = null;
Plot.InvalidatePlot(true);
}
private void OnCursorMouseDown(object sender, OxyMouseDownEventArgs e)
{
if (sender is LineAnnotation line)
_draggingCursor = line;
}
private void OnCursorMouseUp(object sender, OxyMouseEventArgs e)
{
_draggingCursor = null;
}
private void OnPlotMouseMove(object sender, OxyMouseDownEventArgs e)
{
if (_draggingCursor == null) return;
var position = ((OxyMouseEventArgs)e).Position;
var axis = Plot.Axes.OfType<DateTimeAxis>().FirstOrDefault();
if (axis == null) return;
double dataX = axis.InverseTransform(position.X, position.Y, null).X;
// 限制光标不超出数据范围
double xMin = axis.ActualMinimum;
double xMax = axis.ActualMaximum;
dataX = Math.Max(xMin, Math.Min(xMax, dataX));
_draggingCursor.X = dataX;
_draggingCursor.Text = $"▼ {DateTime.FromOADate(dataX):HH:mm:ss}";
Plot.InvalidatePlot(true);
}
/// <summary>
/// 导出双光标选定时间范围内的原始记录到 CSV。
/// </summary>
private void ExportCapturedCsv(double minXOaDate, double maxXOaDate)
{
if (_allRecords.Count == 0)
{
ShowInfoMessageBox("没有加载数据,无法导出", () => { });
return;
}
var startTime = DateTime.FromOADate(minXOaDate);
var endTime = DateTime.FromOADate(maxXOaDate);
var filtered = _allRecords
.Where(r => r.CreateTime >= startTime && r.CreateTime <= endTime)
.OrderBy(r => r.CreateTime)
.ToList();
if (filtered.Count == 0)
{
ShowInfoMessageBox("选定范围内没有数据", () => { });
return;
}
var dlg = new Microsoft.Win32.SaveFileDialog
{
Filter = "CSV 文件 (*.csv)|*.csv",
Title = "导出截取范围数据",
FileName = $"captured_{DateTime.Now:yyyyMMdd_HHmmss}.csv"
};
if (dlg.ShowDialog() != true) return;
using var sw = new StreamWriter(dlg.FileName, false, Encoding.UTF8);
sw.WriteLine("MonitorName,MonitorValue,Scope,CreateTime");
foreach (var r in filtered)
{
sw.WriteLine($"{EscapeCsvField(r.MonitorName)},{r.MonitorValue},{EscapeCsvField(r.Scope)},{r.CreateTime:yyyy-MM-dd HH:mm:ss.fff}");
}
StatusMessage = $"已导出 {filtered.Count} 条记录到 {Path.GetFileName(dlg.FileName)}";
LoggerHelper.Info($"导出截取范围 CSV{dlg.FileName}{filtered.Count} 条记录");
}
private static string EscapeCsvField(string field)
{
if (string.IsNullOrEmpty(field)) return "";
if (field.Contains(',') || field.Contains('"') || field.Contains('\n'))
return $"\"{field.Replace("\"", "\"\"")}\"";
return field;
}
#endregion
#region
public override void OnNavigatedTo(NavigationContext navigationContext)
{
}
public void Dispose()
{
RemoveCursors();
ClearPlotAndChannels();
}
#endregion
}
/// <summary>
/// CSV 监测记录(单行数据)。
/// </summary>
internal class MonitorRecord
{
public string MonitorName { get; set; } = string.Empty;
public double MonitorValue { get; set; }
public string Scope { get; set; } = string.Empty;
public DateTime CreateTime { get; set; }
}
/// <summary>
/// 曲线回读通道MonitorName + Scope 唯一标识一条曲线。
/// </summary>
public class CurveRecallChannelVM : Prism.Mvvm.BindableBase
{
public string MonitorName { get; set; } = string.Empty;
public string Scope { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public OxyColor Color { get; set; } = OxyColors.SteelBlue;
private bool _isDisplayed = true;
public bool IsDisplayed
{
get => _isDisplayed;
set => SetProperty(ref _isDisplayed, value);
}
public LineSeries? Series { get; set; }
}
}