SSPC-Tester/SSPCTester.UI/ViewModels/CurveTestViewModel.cs

604 lines
22 KiB
C#
Raw Permalink Normal View History

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();
}
2026-07-05 13:33:55 +08:00
[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();
}
2026-07-05 13:33:55 +08:00
[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);
2026-07-10 18:32:20 +08:00
[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;
}
}