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; /// 曲线测试页 VM。 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 Rows { get; } = new(); public ObservableCollection 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 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}"); } } /// 仅用于"浏览历史项目"时的可视化预览:调用 MockDaq 合成一段波形,不操作 SSPC。 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); [RelayCommand] private void Clear() { foreach (var row in Rows) { row.OnTimeMs = 0; row.RiseTimeMs = 0; row.OffTimeMs = 0; row.FallTimeMs = 0; row.RisingWaveformPath = null; row.FallingWaveformPath = null; row.ViewStartMs = null; row.ViewEndMs = null; row.RisingViewStartMs = null; row.RisingViewEndMs = null; row.FallingViewStartMs = null; row.FallingViewEndMs = null; row.Status = TestStatus.Untested; } RisingCurve = null; FallingCurve = null; RisingViewStartMs = null; RisingViewEndMs = null; FallingViewStartMs = null; FallingViewEndMs = null; SaveProjectQuietly(); _log.Add(UiLogLevel.Info, "开启与关断曲线结果已清空。"); } 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; } }