SSPC-Tester/SSPCTester.UI/ViewModels/CurveTestViewModel.cs
2026-07-05 13:33:55 +08:00

574 lines
21 KiB
C#
Raw Permalink 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 System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Options;
using SSPCTester.Devices.Config;
using SSPCTester.Devices.Interfaces;
using SSPCTester.Logic.Calculation;
using SSPCTester.Logic.Models;
using SSPCTester.Logic.Storage;
using SSPCTester.Logic.Testing;
using SSPCTester.UI.Services;
namespace SSPCTester.UI.ViewModels;
/// <summary>曲线测试页 VM。</summary>
public partial class CurveTestViewModel : ObservableObject
{
private readonly ISspc _sspc;
private readonly IDaq _daq;
private readonly DaqOptions _daqOpts;
private readonly UiLogSink _log;
private readonly ProjectStore _store;
private int _loadVersion;
private int _viewWindowSaveVersion;
private bool _syncingViewWindow;
public ObservableCollection<CurveResultRow> Rows { get; } = new();
public ObservableCollection<int> Channels { get; } = new();
[ObservableProperty] private Project? _project;
[ObservableProperty] private int _selectedChannel;
[ObservableProperty] private CurveData? _risingCurve;
[ObservableProperty] private CurveData? _fallingCurve;
[ObservableProperty] private double _vMax = 28.0;
[ObservableProperty] private bool _busy;
[ObservableProperty] private CurveResultRow? _selectedRow;
[ObservableProperty] private double? _risingViewStartMs;
[ObservableProperty] private double? _risingViewEndMs;
[ObservableProperty] private double? _fallingViewStartMs;
[ObservableProperty] private double? _fallingViewEndMs;
public CurveTestViewModel(ISspc sspc, IDaq daq, IOptions<DeviceOptions> opt, UiLogSink log, ProjectStore store)
{
_sspc = sspc; _daq = daq; _daqOpts = opt.Value.Daq; _log = log;
_store = store;
for (int i = 1; i <= _sspc.ChannelCount; i++) Channels.Add(i);
SelectedChannel = 1;
}
public CurveThresholds Thresholds => Project?.CurveThresholds ?? new CurveThresholds();
public void SetProject(Project p)
{
Project = p;
Rows.Clear();
if (p.CurveResults.Count == 0)
{
for (int i = 1; i <= _sspc.ChannelCount; i++)
p.CurveResults.Add(new CurveResultRow { Channel = i });
}
foreach (var r in p.CurveResults) Rows.Add(r);
SyncSelectedRow();
OnPropertyChanged(nameof(Thresholds));
_ = LoadSelectedChannelCurvesAsync();
}
public void AttachSession(TestSessionViewModel s)
{
s.CurveReady += (ch, rising, falling) =>
{
if (ch == SelectedChannel)
{
RisingCurve = rising;
FallingCurve = falling;
}
};
}
partial void OnSelectedChannelChanged(int value)
{
SyncSelectedRow();
_ = LoadSelectedChannelCurvesAsync();
}
partial void OnSelectedRowChanged(CurveResultRow? value)
{
if (_syncingViewWindow || value == null) return;
if (SelectedChannel != value.Channel)
SelectedChannel = value.Channel;
}
partial void OnRisingViewStartMsChanged(double? value)
{
if (_syncingViewWindow || SelectedRow == null) return;
SelectedRow.RisingViewStartMs = value;
SaveProjectQuietly();
}
partial void OnRisingViewEndMsChanged(double? value)
{
if (_syncingViewWindow || SelectedRow == null) return;
SelectedRow.RisingViewEndMs = value;
SaveProjectQuietly();
}
partial void OnFallingViewStartMsChanged(double? value)
{
if (_syncingViewWindow || SelectedRow == null) return;
SelectedRow.FallingViewStartMs = value;
SaveProjectQuietly();
}
partial void OnFallingViewEndMsChanged(double? value)
{
if (_syncingViewWindow || SelectedRow == null) return;
SelectedRow.FallingViewEndMs = value;
SaveProjectQuietly();
}
private void SyncSelectedRow()
{
_syncingViewWindow = true;
try
{
SelectedRow = Rows.FirstOrDefault(x => x.Channel == SelectedChannel)
?? Project?.CurveResults.FirstOrDefault(x => x.Channel == SelectedChannel);
RisingViewStartMs = SelectedRow?.RisingViewStartMs;
RisingViewEndMs = SelectedRow?.RisingViewEndMs;
FallingViewStartMs = SelectedRow?.FallingViewStartMs;
FallingViewEndMs = SelectedRow?.FallingViewEndMs;
}
finally
{
_syncingViewWindow = false;
}
}
[RelayCommand]
private void ResetRisingCurveView()
{
if (SelectedRow == null) return;
RisingViewStartMs = null;
RisingViewEndMs = null;
SelectedRow.RisingViewStartMs = null;
SelectedRow.RisingViewEndMs = null;
SaveProjectQuietly();
}
[RelayCommand]
private void ZoomRisingCurveToMarks()
{
if (SelectedRow == null || RisingCurve == null) return;
double vMax = CurveMetrics.EstimateVMax(RisingCurve.Voltage);
if (!TryBuildMarkWindow(CurveMetrics.AnalyzeRising(RisingCurve, vMax), RisingCurve, out double start, out double end))
return;
RisingViewStartMs = start;
RisingViewEndMs = end;
SelectedRow.RisingViewStartMs = start;
SelectedRow.RisingViewEndMs = end;
SaveProjectQuietly();
}
[RelayCommand]
private void ResetFallingCurveView()
{
if (SelectedRow == null) return;
FallingViewStartMs = null;
FallingViewEndMs = null;
SelectedRow.FallingViewStartMs = null;
SelectedRow.FallingViewEndMs = null;
SaveProjectQuietly();
}
[RelayCommand]
private void ZoomFallingCurveToMarks()
{
if (SelectedRow == null || FallingCurve == null) return;
double vMax = RisingCurve != null
? CurveMetrics.EstimateVMax(RisingCurve.Voltage)
: CurveMetrics.EstimateVMax(FallingCurve.Voltage);
if (!TryBuildMarkWindow(CurveMetrics.AnalyzeFalling(FallingCurve, vMax), FallingCurve, out double start, out double end))
return;
FallingViewStartMs = start;
FallingViewEndMs = end;
SelectedRow.FallingViewStartMs = start;
SelectedRow.FallingViewEndMs = end;
SaveProjectQuietly();
}
private static bool TryBuildMarkWindow(CurveTransitionMark mark, CurveData curve, out double start, out double end)
{
start = 0;
end = 0;
if (!mark.IsValid || !double.IsFinite(mark.StartTimeMs) || !double.IsFinite(mark.EndTimeMs))
return false;
double span = Math.Abs(mark.EndTimeMs - mark.StartTimeMs);
double padding = Math.Max(span * 0.15, 0.05);
double minTimeMs = -curve.TriggerTimeSec * 1000.0;
double maxTimeMs = (curve.DurationSec - curve.TriggerTimeSec) * 1000.0;
start = Math.Max(minTimeMs, Math.Min(mark.StartTimeMs, mark.EndTimeMs) - padding);
end = Math.Min(maxTimeMs, Math.Max(mark.StartTimeMs, mark.EndTimeMs) + padding);
return double.IsFinite(start) && double.IsFinite(end) && end > start;
}
private void SaveProjectQuietly()
{
var project = Project;
if (project == null) return;
int version = Interlocked.Increment(ref _viewWindowSaveVersion);
_ = SaveProjectQuietlyAsync(project, version);
}
private async Task SaveProjectQuietlyAsync(Project project, int version)
{
try
{
await Task.Delay(300);
if (version != _viewWindowSaveVersion) return;
await _store.SaveAsync(project);
}
catch (Exception ex) { _log.Add(UiLogLevel.Warning, $"曲线视图窗口保存失败:{ex.Message}"); }
}
private async Task LoadSelectedChannelCurvesAsync()
{
int version = ++_loadVersion;
var project = Project;
if (project == null)
{
RisingCurve = null;
FallingCurve = null;
return;
}
var row = Rows.FirstOrDefault(x => x.Channel == SelectedChannel)
?? project.CurveResults.FirstOrDefault(x => x.Channel == SelectedChannel);
if (row == null)
{
RisingCurve = null;
FallingCurve = null;
return;
}
try
{
var rising = await WaveformStore.LoadAsync(project, row.RisingWaveformPath);
var falling = await WaveformStore.LoadAsync(project, row.FallingWaveformPath);
if (version != _loadVersion) return;
RisingCurve = rising;
FallingCurve = falling;
if (rising != null)
VMax = CurveMetrics.EstimateVMax(rising.Voltage);
}
catch (Exception ex)
{
if (version != _loadVersion) return;
RisingCurve = null;
FallingCurve = null;
_log.Add(UiLogLevel.Warning, $"CH{SelectedChannel} 历史曲线加载失败:{ex.Message}");
}
}
/// <summary>仅用于"浏览历史项目"时的可视化预览:调用 MockDaq 合成一段波形,不操作 SSPC。</summary>
private async Task PreviewSelectedAsync()
{
if (Busy) return;
try
{
Busy = true;
var request = new DaqActionCaptureRequest(_daqOpts.PreTriggerMs, _daqOpts.PostTriggerMs, true);
var rising = await _daq.CaptureAroundActionAsync(request, _ => Task.CompletedTask);
var falling = await _daq.CaptureAroundActionAsync(request with { IsRising = false }, _ => Task.CompletedTask);
VMax = CurveMetrics.EstimateVMax(rising.Voltage);
RisingCurve = rising;
FallingCurve = falling;
}
catch { /* 预览失败不影响主流程 */ }
finally { Busy = false; }
}
[RelayCommand]
private void OpenPopup(string? curveKind)
{
var win = new Views.CurvePopupWindow(this, curveKind) { Owner = System.Windows.Application.Current?.MainWindow };
win.Show();
}
[RelayCommand]
private Task CaptureRising() => CaptureSingleCurveAsync(CurveCaptureKind.Rising);
[RelayCommand]
private Task CaptureFalling() => CaptureSingleCurveAsync(CurveCaptureKind.Falling);
private async Task CaptureSingleCurveAsync(CurveCaptureKind kind)
{
if (Project == null || Busy) return;
Busy = true;
try
{
int ch = SelectedChannel;
if (!ValidateCurveCaptureConfig(out var configMessage))
{
_log.Add(UiLogLevel.Error, $"CH{ch} 曲线采集被阻止:{configMessage}");
return;
}
await EnsureDevicesConnectedAsync();
var row = Rows.First(r => r.Channel == ch);
row.Status = TestStatus.Running;
bool rising = kind == CurveCaptureKind.Rising;
if (rising)
{
_log.Add(UiLogLevel.Info, $"CH{ch} 设置继电器为关断状态,准备采集开启曲线。");
await _sspc.TurnOffAsync(ch);
await BasicTestCriteria.WaitForStableOffAsync(_sspc, ch);
}
else
{
_log.Add(UiLogLevel.Info, $"CH{ch} 设置继电器为开启状态,准备采集关断曲线。");
await _sspc.TurnOnAsync(ch);
await Task.Delay(BasicTestCriteria.SwitchSettleDelayMs);
}
var request = new DaqActionCaptureRequest(_daqOpts.PreTriggerMs, _daqOpts.PostTriggerMs, rising);
_log.Add(UiLogLevel.Info, rising
? $"CH{ch} 开始预采集,触发后发送开启命令。"
: $"CH{ch} 开始预采集,触发后发送关断命令。");
var curve = await _daq.CaptureAroundActionAsync(
request,
async token =>
{
if (rising)
{
_log.Add(UiLogLevel.Info, $"CH{ch} 触发开启动作命令。");
await _sspc.TurnOnAsync(ch, token);
}
else
{
_log.Add(UiLogLevel.Info, $"CH{ch} 触发关断动作命令。");
await _sspc.TurnOffAsync(ch, token);
}
});
if (rising)
{
ApplyRisingCurve(row, curve);
row.RisingWaveformPath = await WaveformStore.SaveAsync(Project, ch, "on", curve);
RisingCurve = curve;
_log.Add(UiLogLevel.Success, $"CH{ch} 开启曲线采集完成。");
}
else
{
ApplyFallingCurve(row, curve);
row.FallingWaveformPath = await WaveformStore.SaveAsync(Project, ch, "off", curve);
FallingCurve = curve;
_log.Add(UiLogLevel.Success, $"CH{ch} 关断曲线采集完成。");
}
UpdateFinalStatusIfComplete(row);
await _store.SaveAsync(Project);
}
catch (Exception ex)
{
_log.Add(UiLogLevel.Error,
$"曲线采集失败:{ex.GetType().Name} - {ex.Message}CH{SelectedChannel}SSPC连接={_sspc.IsConnected}DAQ连接={_daq.IsConnected}");
try { await _sspc.TurnOffAsync(SelectedChannel); } catch { }
}
finally { Busy = false; }
}
private async Task EnsureDevicesConnectedAsync()
{
try
{
if (!_sspc.IsConnected) await _sspc.ConnectAsync();
}
catch (Exception ex)
{
throw new InvalidOperationException($"Modbus继电器端连接失败{ex.Message}。请在设置中检查串口与连接,否则继电器无法动作。", ex);
}
try
{
if (!_daq.IsConnected) await _daq.ConnectAsync();
}
catch (Exception ex)
{
throw new InvalidOperationException($"高速采集卡连接失败:{ex.Message}。请在设置中检查采集卡。", ex);
}
}
private void ApplyRisingCurve(CurveResultRow row, CurveData curve)
{
double vMax = CurveMetrics.EstimateVMax(curve.Voltage);
VMax = vMax;
row.OnTimeMs = CurveMetrics.OnTimeMs(curve, vMax);
row.RiseTimeMs = CurveMetrics.RiseTimeMs(curve, vMax);
}
private void ApplyFallingCurve(CurveResultRow row, CurveData curve)
{
double vMax = RisingCurve != null
? CurveMetrics.EstimateVMax(RisingCurve.Voltage)
: CurveMetrics.EstimateVMax(curve.Voltage);
VMax = vMax;
row.OffTimeMs = CurveMetrics.OffTimeMs(curve, vMax);
row.FallTimeMs = CurveMetrics.FallTimeMs(curve, vMax);
}
private void UpdateFinalStatusIfComplete(CurveResultRow row)
{
if (string.IsNullOrWhiteSpace(row.RisingWaveformPath) ||
string.IsNullOrWhiteSpace(row.FallingWaveformPath))
{
row.Status = TestStatus.Running;
return;
}
var th = Thresholds;
bool ok = row.OnTimeMs <= th.MaxOnTimeMs && row.RiseTimeMs <= th.MaxRiseTimeMs
&& row.OffTimeMs <= th.MaxOffTimeMs && row.FallTimeMs <= th.MaxFallTimeMs
&& !double.IsNaN(row.OnTimeMs) && !double.IsNaN(row.RiseTimeMs)
&& !double.IsNaN(row.OffTimeMs) && !double.IsNaN(row.FallTimeMs);
row.Status = ok ? TestStatus.Pass : TestStatus.Fail;
}
private enum CurveCaptureKind
{
Rising,
Falling
}
[RelayCommand]
private async Task CaptureCurrent()
{
if (Project == null) return;
Busy = true;
try
{
int ch = SelectedChannel;
if (!ValidateCurveCaptureConfig(out var configMessage))
{
_log.Add(UiLogLevel.Error, $"CH{ch} 曲线采集被阻止:{configMessage}");
return;
}
_log.Add(UiLogLevel.Info, $"CH{ch} 准备采集:先关断并等待稳定。");
try
{
if (!_sspc.IsConnected) await _sspc.ConnectAsync();
}
catch (Exception ex)
{
throw new InvalidOperationException($"Modbus继电器端连接失败{ex.Message}。请在设置中检查串口与连接,否则继电器无法动作。", ex);
}
try
{
if (!_daq.IsConnected) await _daq.ConnectAsync();
}
catch (Exception ex)
{
throw new InvalidOperationException($"高速采集卡连接失败:{ex.Message}。请在设置中检查采集卡。", ex);
}
await _sspc.TurnOffAsync(ch);
await BasicTestCriteria.WaitForStableOffAsync(_sspc, ch);
var request = new DaqActionCaptureRequest(_daqOpts.PreTriggerMs, _daqOpts.PostTriggerMs, true);
_log.Add(UiLogLevel.Info, $"CH{ch} 开始预采集,待触发后发送开启动作。");
var rising = await _daq.CaptureAroundActionAsync(
request,
async token =>
{
_log.Add(UiLogLevel.Info, $"CH{ch} 触发开启动作命令。");
await _sspc.TurnOnAsync(ch, token);
});
await Task.Delay(100);
_log.Add(UiLogLevel.Info, $"CH{ch} 开始预采集,待触发后发送关断动作。");
var falling = await _daq.CaptureAroundActionAsync(
request with { IsRising = false },
async token =>
{
_log.Add(UiLogLevel.Info, $"CH{ch} 触发关断动作命令。");
await _sspc.TurnOffAsync(ch, token);
});
double vMax = CurveMetrics.EstimateVMax(rising.Voltage);
VMax = vMax;
var row = Rows.First(r => r.Channel == ch);
row.OnTimeMs = CurveMetrics.OnTimeMs(rising, vMax);
row.RiseTimeMs = CurveMetrics.RiseTimeMs(rising, vMax);
row.OffTimeMs = CurveMetrics.OffTimeMs(falling, vMax);
row.FallTimeMs = CurveMetrics.FallTimeMs(falling, vMax);
var th = Thresholds;
bool ok = row.OnTimeMs <= th.MaxOnTimeMs && row.RiseTimeMs <= th.MaxRiseTimeMs
&& row.OffTimeMs <= th.MaxOffTimeMs && row.FallTimeMs <= th.MaxFallTimeMs
&& !double.IsNaN(row.OnTimeMs) && !double.IsNaN(row.OffTimeMs);
row.Status = ok ? TestStatus.Pass : TestStatus.Fail;
row.RisingWaveformPath = await WaveformStore.SaveAsync(Project, ch, "on", rising);
row.FallingWaveformPath = await WaveformStore.SaveAsync(Project, ch, "off", falling);
RisingCurve = rising;
FallingCurve = falling;
await _store.SaveAsync(Project);
_log.Add(UiLogLevel.Success, $"CH{ch} 曲线采集完成。");
}
catch (Exception ex)
{
_log.Add(UiLogLevel.Error,
$"曲线采集失败:{ex.GetType().Name} - {ex.Message}CH{SelectedChannel}SSPC连接={_sspc.IsConnected}DAQ连接={_daq.IsConnected}");
try { await _sspc.TurnOffAsync(SelectedChannel); } catch { }
}
finally { Busy = false; }
}
private bool ValidateCurveCaptureConfig(out string message)
{
if (_daqOpts.UseMock)
{
message = "Mock 模式,曲线采集配置有效。";
return true;
}
if (_daqOpts.ChannelCount is not (1 or 2 or 4 or 8))
{
message = "DAQ 启用通道数只能为 1、2、4 或 8。";
return false;
}
var enabled = _daqOpts.Channels.Where(x => x.PhysicalChannel < _daqOpts.ChannelCount).ToArray();
if (enabled.Length == 0)
{
message = "DAQ 没有可用的启用通道。";
return false;
}
var outputVoltage = enabled.Where(x => string.Equals(x.Role, nameof(DaqChannelRole.OutputVoltage), StringComparison.OrdinalIgnoreCase)).ToArray();
if (outputVoltage.Length != 1)
{
message = $"曲线测试必须配置且只能配置一个 OutputVoltage 角色,当前配置了 {outputVoltage.Length} 个。";
return false;
}
var voltageChannel = outputVoltage[0];
if (!voltageChannel.IsCalibrated)
{
message = $"CH{voltageChannel.PhysicalChannel} OutputVoltage 尚未勾选已标定。";
return false;
}
if (!double.IsFinite(voltageChannel.Scale) || voltageChannel.Scale == 0)
{
message = $"CH{voltageChannel.PhysicalChannel} 输出电压通道 Scale 无效。";
return false;
}
if (!double.IsFinite(voltageChannel.Offset))
{
message = $"CH{voltageChannel.PhysicalChannel} 输出电压通道 Offset 无效。";
return false;
}
message = "曲线采集配置有效。";
return true;
}
}