diff --git a/SSPCTester.Logic/Calculation/CurveMetrics.cs b/SSPCTester.Logic/Calculation/CurveMetrics.cs index e6983c4..13c9397 100644 --- a/SSPCTester.Logic/Calculation/CurveMetrics.cs +++ b/SSPCTester.Logic/Calculation/CurveMetrics.cs @@ -25,10 +25,8 @@ public static class CurveMetrics /// public static double OnTimeMs(CurveData curve, double vMax, double thresholdRatio = 0.9) { - int idx = FirstReachIndex(curve.Voltage, vMax * thresholdRatio, rising: true); - if (idx < 0) return double.NaN; - double t = curve.TimeAt(idx) - curve.TriggerTimeSec; - return t * 1000.0; + var mark = AnalyzeRising(curve, vMax, 0.1, thresholdRatio); + return mark.IsValid ? mark.EndTimeMs : double.NaN; } /// @@ -36,10 +34,8 @@ public static class CurveMetrics /// public static double OffTimeMs(CurveData curve, double vMax, double thresholdRatio = 0.1) { - int idx = FirstReachIndex(curve.Voltage, vMax * thresholdRatio, rising: false); - if (idx < 0) return double.NaN; - double t = curve.TimeAt(idx) - curve.TriggerTimeSec; - return t * 1000.0; + var mark = AnalyzeFalling(curve, vMax, 0.9, thresholdRatio); + return mark.IsValid ? mark.EndTimeMs : double.NaN; } /// @@ -65,15 +61,21 @@ public static class CurveMetrics { double startThreshold = 0; double endThreshold = vMax * highRatio; + if (!CanAnalyze(curve, vMax, startThreshold, endThreshold)) + return CurveTransitionMark.Invalid(startThreshold, endThreshold); + int triggerIndex = TriggerIndex(curve); - int endIndex = FirstReachIndex(curve.Voltage, endThreshold, rising: true, startIndex: triggerIndex); - if (endIndex < 0) return CurveTransitionMark.Invalid(startThreshold, endThreshold); + double[] filtered = Smooth(curve.Voltage); + int preliminaryEndIndex = FindStableReachIndex(filtered, endThreshold, rising: true, triggerIndex, filtered.Length - 1); + if (preliminaryEndIndex < 0) + preliminaryEndIndex = FirstReachIndex(filtered, endThreshold, rising: true, startIndex: triggerIndex); + if (preliminaryEndIndex < 0) return CurveTransitionMark.Invalid(startThreshold, endThreshold); - int startIndex = LastReachIndex(curve.Voltage, startThreshold, rising: false, startIndex: triggerIndex, endIndex: endIndex); - if (startIndex < 0) startIndex = FirstReachIndex(curve.Voltage, startThreshold, rising: true, startIndex: triggerIndex); - if (startIndex < 0 || startIndex > endIndex) return CurveTransitionMark.Invalid(startThreshold, endThreshold); + int startIndex = FindDepartureStartIndex(filtered, curve.SampleRateHz, triggerIndex, preliminaryEndIndex, rising: true, vMax); + int endIndex = FirstReachIndex(curve.Voltage, endThreshold, rising: true, startIndex: startIndex); + if (endIndex < 0 || startIndex > endIndex) return CurveTransitionMark.Invalid(startThreshold, endThreshold); - return BuildTransitionMark(curve, startIndex, endIndex, startThreshold, endThreshold); + return BuildTransitionMark(curve, startIndex, endIndex, curve.Voltage[startIndex], endThreshold); } /// Falling curve 90% - 10% transition mark. @@ -81,15 +83,21 @@ public static class CurveMetrics { double startThreshold = vMax; double endThreshold = vMax * lowRatio; + if (!CanAnalyze(curve, vMax, startThreshold, endThreshold)) + return CurveTransitionMark.Invalid(startThreshold, endThreshold); + int triggerIndex = TriggerIndex(curve); - int endIndex = FirstReachIndex(curve.Voltage, endThreshold, rising: false, startIndex: triggerIndex); - if (endIndex < 0) return CurveTransitionMark.Invalid(startThreshold, endThreshold); + double[] filtered = Smooth(curve.Voltage); + int preliminaryEndIndex = FindStableReachIndex(filtered, endThreshold, rising: false, triggerIndex, filtered.Length - 1); + if (preliminaryEndIndex < 0) + preliminaryEndIndex = FirstReachIndex(filtered, endThreshold, rising: false, startIndex: triggerIndex); + if (preliminaryEndIndex < 0) return CurveTransitionMark.Invalid(startThreshold, endThreshold); - int startIndex = LastReachIndex(curve.Voltage, vMax * 0.98, rising: true, startIndex: triggerIndex, endIndex: endIndex); - if (startIndex < 0) startIndex = triggerIndex; - if (startIndex > endIndex) return CurveTransitionMark.Invalid(startThreshold, endThreshold); + int startIndex = FindDepartureStartIndex(filtered, curve.SampleRateHz, triggerIndex, preliminaryEndIndex, rising: false, vMax); + int endIndex = FirstReachIndex(curve.Voltage, endThreshold, rising: false, startIndex: startIndex); + if (endIndex < 0 || startIndex > endIndex) return CurveTransitionMark.Invalid(startThreshold, endThreshold); - return BuildTransitionMark(curve, startIndex, endIndex, startThreshold, endThreshold); + return BuildTransitionMark(curve, startIndex, endIndex, curve.Voltage[startIndex], endThreshold); } private static int TriggerIndex(CurveData curve) @@ -116,6 +124,169 @@ public static class CurveMetrics return -1; } + private static bool CanAnalyze(CurveData curve, double vMax, double startThreshold, double endThreshold) => + curve.Voltage.Length > 1 + && curve.SampleRateHz > 0 + && double.IsFinite(vMax) + && vMax > 0 + && double.IsFinite(startThreshold) + && double.IsFinite(endThreshold); + + private static double[] Smooth(double[] values) + { + double[] median = MedianFilter(values, window: 5); + return MovingAverage(median, window: 5); + } + + private static double[] MedianFilter(double[] values, int window) + { + if (values.Length == 0 || window <= 1) + return (double[])values.Clone(); + + int radius = window / 2; + var result = new double[values.Length]; + var scratch = new double[window]; + + for (int i = 0; i < values.Length; i++) + { + int from = Math.Max(0, i - radius); + int to = Math.Min(values.Length - 1, i + radius); + int count = 0; + for (int j = from; j <= to; j++) + scratch[count++] = values[j]; + + Array.Sort(scratch, 0, count); + result[i] = scratch[count / 2]; + } + + return result; + } + + private static double[] MovingAverage(double[] values, int window) + { + if (values.Length == 0 || window <= 1) + return (double[])values.Clone(); + + int radius = window / 2; + var result = new double[values.Length]; + + for (int i = 0; i < values.Length; i++) + { + int from = Math.Max(0, i - radius); + int to = Math.Min(values.Length - 1, i + radius); + double sum = 0; + int count = 0; + for (int j = from; j <= to; j++) + { + sum += values[j]; + count++; + } + result[i] = count > 0 ? sum / count : values[i]; + } + + return result; + } + + private static int FindDepartureStartIndex(double[] values, int sampleRateHz, int triggerIndex, int endIndex, bool rising, double vMax) + { + if (values.Length == 0) return -1; + + triggerIndex = Math.Clamp(triggerIndex, 0, values.Length - 1); + endIndex = Math.Clamp(endIndex, triggerIndex, values.Length - 1); + + double baseline = EstimateBaseline(values, triggerIndex); + double noise = EstimateAmplitudeNoise(values, triggerIndex); + double deadband = Math.Max(vMax * 0.005, noise * 6.0); + int stableCount = StableCount(sampleRateHz); + int lastStableBaselineEnd = triggerIndex; + + for (int i = triggerIndex; i <= endIndex - stableCount + 1; i++) + { + bool isBaseline = true; + for (int j = i; j < i + stableCount; j++) + { + bool nearBaseline = rising + ? values[j] <= baseline + deadband + : values[j] >= baseline - deadband; + if (!nearBaseline) + { + isBaseline = false; + break; + } + } + + if (isBaseline) + lastStableBaselineEnd = i + stableCount - 1; + } + + int start = Math.Min(lastStableBaselineEnd + 1, endIndex); + return Math.Clamp(start, triggerIndex, values.Length - 1); + } + + private static int FindStableReachIndex(double[] values, double target, bool rising, int startIndex, int endIndex) + { + if (values.Length == 0) return -1; + + const int stableCount = 3; + startIndex = Math.Clamp(startIndex, 0, values.Length - 1); + endIndex = Math.Clamp(endIndex, 0, values.Length - 1); + + for (int i = startIndex; i <= endIndex; i++) + { + bool ok = true; + int to = Math.Min(endIndex, i + stableCount - 1); + for (int j = i; j <= to; j++) + { + if (!(rising ? values[j] >= target : values[j] <= target)) + { + ok = false; + break; + } + } + + if (ok && to - i + 1 == stableCount) + return i; + } + + return -1; + } + + private static int StableCount(int sampleRateHz) + { + if (sampleRateHz <= 0) return 3; + int count = (int)Math.Round(sampleRateHz * 0.00002); + return Math.Clamp(count, 3, 25); + } + + private static double EstimateBaseline(double[] values, int triggerIndex) + { + int end = Math.Clamp(triggerIndex, 0, values.Length - 1); + int count = Math.Min(Math.Max(end + 1, 1), 100); + int start = Math.Max(0, end - count + 1); + var samples = new double[end - start + 1]; + for (int i = start; i <= end; i++) + samples[i - start] = values[i]; + + Array.Sort(samples); + return samples[samples.Length / 2]; + } + + private static double EstimateAmplitudeNoise(double[] values, int triggerIndex) + { + int end = Math.Clamp(triggerIndex, 0, values.Length - 1); + int count = Math.Min(Math.Max(end + 1, 1), 100); + int start = Math.Max(0, end - count + 1); + if (end <= start) return 0; + + double baseline = EstimateBaseline(values, triggerIndex); + var deviations = new double[end - start + 1]; + for (int i = start; i <= end; i++) + deviations[i - start] = Math.Abs(values[i] - baseline); + + Array.Sort(deviations); + return deviations[deviations.Length / 2]; + } + private static CurveTransitionMark BuildTransitionMark( CurveData curve, int startIndex, diff --git a/SSPCTester.Logic/Testing/BasicTest.cs b/SSPCTester.Logic/Testing/BasicTest.cs index 6602ee8..f1af5ba 100644 --- a/SSPCTester.Logic/Testing/BasicTest.cs +++ b/SSPCTester.Logic/Testing/BasicTest.cs @@ -39,7 +39,8 @@ public sealed class BasicTest : ITestModule row.CurrentOff = Math.Abs(offResult.Measurement.Current); row.DisplayVoltage = row.VoltageOff; row.DisplayCurrent = row.CurrentOff; - row.Status = onOk && offResult.IsStableOff + bool offOk = offResult.IsStableOff; + row.Status = onOk && offOk ? TestStatus.Pass : TestStatus.Fail; } catch (Exception ex) diff --git a/SSPCTester.Logic/Testing/BasicTestCriteria.cs b/SSPCTester.Logic/Testing/BasicTestCriteria.cs index 95ebf4f..dc5ed69 100644 --- a/SSPCTester.Logic/Testing/BasicTestCriteria.cs +++ b/SSPCTester.Logic/Testing/BasicTestCriteria.cs @@ -3,7 +3,7 @@ using SSPCTester.Devices.Interfaces; namespace SSPCTester.Logic.Testing; -/// 基础通断测试的统一判定规则。 +/// Shared pass/fail rules for the basic on/off test. public static class BasicTestCriteria { public const double OnVoltageThreshold = 1.0; @@ -27,15 +27,17 @@ public static class BasicTestCriteria HasVoltageData(voltage) && HasCurrentData(current); public static bool IsOffState(double voltage, double current) => - HasVoltageData(voltage) && !HasCurrentData(current); + IsOff(voltage) && !HasCurrentData(current); /// - /// 等待关状态稳定。连续多次满足“电压有数据、电流无数据”才确认关断,避免旧帧和瞬时噪声造成误判。 + /// Waits until the channel is stably off: no voltage and no current for consecutive samples. /// public static async Task WaitForStableOffAsync( ISspc sspc, int channel, - CancellationToken ct = default) + CancellationToken ct = default, + Action? onSample = null, + Func>? readSampleAsync = null) { await Task.Delay(OffInitialDelayMs, ct).ConfigureAwait(false); @@ -45,7 +47,8 @@ public static class BasicTestCriteria while (stopwatch.ElapsedMilliseconds < OffSettleTimeoutMs) { - latest = await sspc.ReadMeasurementAsync(channel, ct).ConfigureAwait(false); + latest = await ReadSampleAsync(sspc, channel, ct, readSampleAsync).ConfigureAwait(false); + onSample?.Invoke(latest); stableSamples = IsOffState(latest.Voltage, latest.Current) ? stableSamples + 1 : 0; if (stableSamples >= RequiredStableOffSamples) @@ -54,9 +57,17 @@ public static class BasicTestCriteria await Task.Delay(OffSampleIntervalMs, ct).ConfigureAwait(false); } - latest ??= await sspc.ReadMeasurementAsync(channel, ct).ConfigureAwait(false); + latest ??= await ReadSampleAsync(sspc, channel, ct, readSampleAsync).ConfigureAwait(false); + onSample?.Invoke(latest); return new OffStateResult(false, latest, stopwatch.Elapsed); } + + private static Task ReadSampleAsync( + ISspc sspc, + int channel, + CancellationToken ct, + Func>? readSampleAsync) => + readSampleAsync?.Invoke(channel, ct) ?? sspc.ReadMeasurementAsync(channel, ct); } public sealed record OffStateResult( diff --git a/SSPCTester.Tests/BasicTestCriteriaTests.cs b/SSPCTester.Tests/BasicTestCriteriaTests.cs index feb5707..1c555c6 100644 --- a/SSPCTester.Tests/BasicTestCriteriaTests.cs +++ b/SSPCTester.Tests/BasicTestCriteriaTests.cs @@ -1,4 +1,5 @@ using SSPCTester.Logic.Testing; +using SSPCTester.Devices.Interfaces; namespace SSPCTester.Tests; @@ -33,12 +34,74 @@ public sealed class BasicTestCriteriaTests } [Theory] - [InlineData(28.0, 0.0, true)] - [InlineData(28.0, 0.0005, true)] - [InlineData(28.0, 0.01, false)] - [InlineData(0.0, 0.0, false)] - public void IsOffState_RequiresVoltageDataAndNoCurrentData(double voltage, double current, bool expected) + [InlineData(0.0, 0.0, true)] + [InlineData(0.49, 0.0, true)] + [InlineData(-0.49, 0.0, true)] + [InlineData(0.0, 0.0005, true)] + [InlineData(0.50, 0.0, false)] + [InlineData(0.75, 0.0, false)] + [InlineData(0.0, 0.01, false)] + [InlineData(28.0, 0.0, false)] + public void IsOffState_RequiresNoVoltageAndNoCurrent(double voltage, double current, bool expected) { Assert.Equal(expected, BasicTestCriteria.IsOffState(voltage, current)); } + + [Fact] + public async Task WaitForStableOffAsync_ReportsEachSample() + { + var readings = new[] + { + new ChannelMeasurement { PhysicalSlot = 3, Voltage = 0.1, Current = 0.0 }, + new ChannelMeasurement { PhysicalSlot = 3, Voltage = 0.0, Current = 0.0 } + }; + var sspc = new SequenceSspc(readings); + var samples = new List(); + + var result = await BasicTestCriteria.WaitForStableOffAsync(sspc, 3, onSample: samples.Add); + + Assert.True(result.IsStableOff); + Assert.Equal(2, samples.Count); + Assert.Equal(0.1, samples[0].Voltage); + Assert.Equal(0.0, samples[0].Current); + Assert.Equal(0.0, result.Measurement.Voltage); + } + + private sealed class SequenceSspc : ISspc + { + private readonly Queue _readings; + + public SequenceSspc(IEnumerable readings) + { + _readings = new Queue(readings); + } + + public int ChannelCount => 24; + public bool IsConnected => true; + public string DisplayName => "Sequence SSPC"; + public event EventHandler? ConnectionChanged; + + public Task ConnectAsync(CancellationToken ct = default) + { + ConnectionChanged?.Invoke(this, true); + return Task.FromResult(true); + } + + public Task DisconnectAsync() => Task.CompletedTask; + public Task TurnOnAsync(int physicalSlot, CancellationToken ct = default) => Task.CompletedTask; + public Task TurnOffAsync(int physicalSlot, CancellationToken ct = default) => Task.CompletedTask; + public Task FlashAsync(int physicalSlot, int milliseconds, CancellationToken ct = default) => Task.CompletedTask; + public Task GetChannelStateAsync(int physicalSlot, CancellationToken ct = default) => Task.FromResult(false); + + public Task ReadMeasurementAsync(int physicalSlot, CancellationToken ct = default) + { + if (_readings.Count > 1) + return Task.FromResult(_readings.Dequeue()); + + return Task.FromResult(_readings.Peek()); + } + + public Task> ReadMeasurementsAsync(CancellationToken ct = default) => + Task.FromResult>(_readings.ToArray()); + } } diff --git a/SSPCTester.Tests/CalculationTests.cs b/SSPCTester.Tests/CalculationTests.cs index 89c4dab..b007802 100644 --- a/SSPCTester.Tests/CalculationTests.cs +++ b/SSPCTester.Tests/CalculationTests.cs @@ -56,12 +56,12 @@ public class CurveMetricsTests var mark = CurveMetrics.AnalyzeRising(c, 28); int triggerIndex = (int)Math.Round(c.TriggerTimeSec * c.SampleRateHz); int expectedEnd = CurveMetrics.FirstReachIndex(c.Voltage, 28 * 0.9, rising: true, triggerIndex); - int expectedStart = triggerIndex; + int expectedStart = triggerIndex + 1; Assert.True(mark.IsValid); Assert.Equal(expectedStart, mark.StartIndex); Assert.Equal(expectedEnd, mark.EndIndex); - Assert.Equal(0, mark.StartThresholdV, 6); + Assert.Equal(c.Voltage[expectedStart], mark.StartThresholdV, 6); Assert.Equal((c.TimeAt(expectedEnd) - c.TimeAt(expectedStart)) * 1000.0, mark.DurationMs, 6); Assert.Equal(mark.DurationMs, CurveMetrics.RiseTimeMs(c, 28), 6); } @@ -81,21 +81,72 @@ public class CurveMetricsTests var mark = CurveMetrics.AnalyzeFalling(c, 28); int triggerIndex = (int)Math.Round(c.TriggerTimeSec * c.SampleRateHz); int expectedEnd = CurveMetrics.FirstReachIndex(c.Voltage, 28 * 0.1, rising: false, triggerIndex); - int expectedStart = triggerIndex; - for (int i = triggerIndex; i <= expectedEnd; i++) - { - if (c.Voltage[i] >= 28 * 0.98) - expectedStart = i; - } + int expectedStart = triggerIndex + 1; Assert.True(mark.IsValid); Assert.Equal(expectedStart, mark.StartIndex); Assert.Equal(expectedEnd, mark.EndIndex); - Assert.Equal(28, mark.StartThresholdV, 6); + Assert.Equal(c.Voltage[expectedStart], mark.StartThresholdV, 6); Assert.Equal((c.TimeAt(expectedEnd) - c.TimeAt(expectedStart)) * 1000.0, mark.DurationMs, 6); Assert.Equal(mark.DurationMs, CurveMetrics.FallTimeMs(c, 28), 6); } + [Fact] + public void AnalyzeRising_StartsAtSustainedRiseAndEndsAtFirstRawNinetyPercent() + { + const int rate = 1_000_000; + const double trigger = 0.0002; + int triggerIndex = (int)Math.Round(trigger * rate); + var v = Enumerable.Repeat(0.0, 2_000).ToArray(); + + v[triggerIndex + 100] = -4.0; + v[triggerIndex + 101] = 2.0; + v[triggerIndex + 102] = 0.0; + + int riseStart = triggerIndex + 500; + for (int i = riseStart; i < v.Length; i++) + { + double progress = Math.Clamp((i - riseStart) / 60.0, 0.0, 1.0); + v[i] = 5.0 * progress; + } + + var curve = new CurveData { SampleRateHz = rate, TriggerTimeSec = trigger, Voltage = v }; + var mark = CurveMetrics.AnalyzeRising(curve, 5.0); + int expectedEnd = CurveMetrics.FirstReachIndex(v, 4.5, rising: true, riseStart); + + Assert.True(mark.IsValid); + Assert.InRange(mark.StartIndex, riseStart - 3, riseStart + 5); + Assert.Equal(expectedEnd, mark.EndIndex); + } + + [Fact] + public void AnalyzeFalling_StartsAtSlowDropAndEndsAtFirstRawTenPercent() + { + const int rate = 1_000_000; + const double trigger = 0.0002; + int triggerIndex = (int)Math.Round(trigger * rate); + var v = Enumerable.Repeat(3.0, 2_000).ToArray(); + + int fallStart = triggerIndex + 300; + for (int i = fallStart; i < triggerIndex + 850; i++) + v[i] = 3.0 - 0.55 * (i - fallStart) / 550.0; + + v[triggerIndex + 850] = 5.0; + for (int i = triggerIndex + 851; i < v.Length; i++) + { + double progress = Math.Clamp((i - (triggerIndex + 851)) / 45.0, 0.0, 1.0); + v[i] = Math.Max(-0.2, 2.45 * (1.0 - progress)); + } + + var curve = new CurveData { SampleRateHz = rate, TriggerTimeSec = trigger, Voltage = v }; + var mark = CurveMetrics.AnalyzeFalling(curve, 3.0); + int expectedEnd = CurveMetrics.FirstReachIndex(v, 0.3, rising: false, fallStart); + + Assert.True(mark.IsValid); + Assert.InRange(mark.StartIndex, fallStart - 5, fallStart + 30); + Assert.Equal(expectedEnd, mark.EndIndex); + } + [Fact] public void AnalyzeTransition_ReturnsInvalidWhenThresholdsAreNotReached() { diff --git a/SSPCTester.UI/Services/SafetyMonitorService.cs b/SSPCTester.UI/Services/SafetyMonitorService.cs index a2355c2..e6afb0c 100644 --- a/SSPCTester.UI/Services/SafetyMonitorService.cs +++ b/SSPCTester.UI/Services/SafetyMonitorService.cs @@ -10,6 +10,7 @@ public sealed class SafetyMonitorService : IDisposable private readonly ISafetyDio _safetyDio; private readonly IPowerSupply _power; private readonly ILoad _load; + private readonly ISspc _sspc; private readonly DeviceOptions _options; private readonly UiLogSink _log; private readonly ILogger _logger; @@ -23,6 +24,7 @@ public sealed class SafetyMonitorService : IDisposable ISafetyDio safetyDio, IPowerSupply power, ILoad load, + ISspc sspc, IOptions options, UiLogSink log, ILogger logger) @@ -30,6 +32,7 @@ public sealed class SafetyMonitorService : IDisposable _safetyDio = safetyDio; _power = power; _load = load; + _sspc = sspc; _options = options.Value; _log = log; _logger = logger; @@ -69,9 +72,10 @@ public sealed class SafetyMonitorService : IDisposable await EnsureSafetyDioConnectedAsync(ct).ConfigureAwait(false); await SetAlarmOutputAsync(true, ct).ConfigureAwait(false); + await TryShutdownSspcAsync().ConfigureAwait(false); await TryShutdownPowerAsync().ConfigureAwait(false); await TryShutdownLoadAsync().ConfigureAwait(false); - SetStatus(true, true, "软件急停已触发", "报警灯已接通,电源输出和负载输入已发送关闭指令"); + SetStatus(true, true, "软件急停已触发", "报警灯已接通,SSPC 通道已全部关断,电源输出和负载输入已发送关闭指令"); } catch (Exception ex) { @@ -175,9 +179,10 @@ public sealed class SafetyMonitorService : IDisposable try { await SetAlarmOutputAsync(true, CancellationToken.None).ConfigureAwait(false); + await TryShutdownSspcAsync().ConfigureAwait(false); await TryShutdownPowerAsync().ConfigureAwait(false); await TryShutdownLoadAsync().ConfigureAwait(false); - SetStatus(true, true, "急停已触发", "报警灯已接通,电源输出和负载输入已发送关闭指令"); + SetStatus(true, true, "急停已触发", "报警灯已接通,SSPC 通道已全部关断,电源输出和负载输入已发送关闭指令"); } catch (Exception ex) { @@ -188,6 +193,33 @@ public sealed class SafetyMonitorService : IDisposable } } + private async Task TryShutdownSspcAsync() + { + try + { + if (!_sspc.IsConnected && _options.Sspc.AutoConnect) + await _sspc.ConnectAsync().ConfigureAwait(false); + if (_sspc.IsConnected) + { + for (int ch = 1; ch <= _sspc.ChannelCount; ch++) + { + try { await _sspc.TurnOffAsync(ch).ConfigureAwait(false); } + catch (Exception ex) { _logger.LogWarning(ex, "急停关断 SSPC CH{Ch} 失败。", ch); } + } + _log.Add(UiLogLevel.Warning, $"SSPC 全部 {_sspc.ChannelCount} 个通道已发送关断指令。"); + } + else + { + _log.Add(UiLogLevel.Warning, "SSPC 未连接,跳过通道关断。"); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "SSPC 批量关断失败。"); + _log.Add(UiLogLevel.Error, $"SSPC 通道批量关断失败:{ex.Message}"); + } + } + private async Task TryShutdownPowerAsync() { try diff --git a/SSPCTester.UI/ViewModels/BasicTestViewModel.cs b/SSPCTester.UI/ViewModels/BasicTestViewModel.cs index dd840aa..06252d5 100644 --- a/SSPCTester.UI/ViewModels/BasicTestViewModel.cs +++ b/SSPCTester.UI/ViewModels/BasicTestViewModel.cs @@ -84,11 +84,8 @@ public partial class BasicTestViewModel : ObservableObject row.Status = TestStatus.Running; await _sspc.TurnOnAsync(row.Channel); await Task.Delay(SwitchSettleDelayMs); - var measurement = await _sspc.ReadMeasurementAsync(row.Channel); - row.VoltageOn = measurement.Voltage; - row.CurrentOn = Math.Abs(measurement.Current); - row.DisplayVoltage = row.VoltageOn; - row.DisplayCurrent = row.CurrentOn; + var measurement = await ReadDisplayedMeasurementAsync(row.Channel, CancellationToken.None); + ApplyOnMeasurement(row, measurement); bool onOk = BasicTestCriteria.IsOnState(row.VoltageOn, row.CurrentOn); row.Status = onOk ? TestStatus.Running : TestStatus.Fail; StartPolling(row.Channel); @@ -112,18 +109,21 @@ public partial class BasicTestViewModel : ObservableObject Busy = true; try { + if (!_sspc.IsConnected) await _sspc.ConnectAsync(); await StopPollingAsync(row.Channel); await _sspc.TurnOffAsync(row.Channel); await Task.Delay(SwitchSettleDelayMs); - var offResult = await BasicTestCriteria.WaitForStableOffAsync(_sspc, row.Channel); - row.VoltageOff = offResult.Measurement.Voltage; - row.CurrentOff = Math.Abs(offResult.Measurement.Current); - row.DisplayVoltage = row.VoltageOff; - row.DisplayCurrent = row.CurrentOff; + var offResult = await BasicTestCriteria.WaitForStableOffAsync( + _sspc, + row.Channel, + onSample: measurement => ApplyOffMeasurement(row, measurement), + readSampleAsync: ReadDisplayedMeasurementAsync); + ApplyOffMeasurement(row, offResult.Measurement); bool onOk = BasicTestCriteria.IsOnState(row.VoltageOn, row.CurrentOn); - row.Status = (onOk && offResult.IsStableOff) ? TestStatus.Pass : TestStatus.Fail; + bool offOk = offResult.IsStableOff; + row.Status = (onOk && offOk) ? TestStatus.Pass : TestStatus.Fail; string verdict = row.Status == TestStatus.Pass ? "合格" : "不合格"; - string offState = offResult.IsStableOff ? "已稳定关断" : "关断超时"; + string offState = offOk ? "已稳定关断" : "关断超时"; _log.Add(row.Status == TestStatus.Pass ? UiLogLevel.Success : UiLogLevel.Error, $"CH{row.Channel} 关 -> V={row.VoltageOff:F2}V I={row.CurrentOff:F3}A {offState} {verdict}"); } @@ -203,11 +203,8 @@ public partial class BasicTestViewModel : ObservableObject await _sspc.TurnOnAsync(row.Channel, ct); await Task.Delay(SwitchSettleDelayMs, ct); - var measurement = await _sspc.ReadMeasurementAsync(row.Channel, ct); - row.VoltageOn = measurement.Voltage; - row.CurrentOn = Math.Abs(measurement.Current); - row.DisplayVoltage = row.VoltageOn; - row.DisplayCurrent = row.CurrentOn; + var measurement = await ReadDisplayedMeasurementAsync(row.Channel, ct); + ApplyOnMeasurement(row, measurement); bool onOk = BasicTestCriteria.IsOnState(row.VoltageOn, row.CurrentOn); _log.Add(onOk ? UiLogLevel.Info : UiLogLevel.Error, $"CH{row.Channel} 开 -> V={row.VoltageOn:F2}V I={row.CurrentOn:F3}A {(onOk ? "开状态合格" : "开状态不合格")}"); @@ -220,14 +217,17 @@ public partial class BasicTestViewModel : ObservableObject await _sspc.TurnOffAsync(row.Channel, ct); await Task.Delay(SwitchSettleDelayMs, ct); - var offResult = await BasicTestCriteria.WaitForStableOffAsync(_sspc, row.Channel, ct); - row.VoltageOff = offResult.Measurement.Voltage; - row.CurrentOff = Math.Abs(offResult.Measurement.Current); - row.DisplayVoltage = row.VoltageOff; - row.DisplayCurrent = row.CurrentOff; - row.Status = (onOk && offResult.IsStableOff) ? TestStatus.Pass : TestStatus.Fail; + var offResult = await BasicTestCriteria.WaitForStableOffAsync( + _sspc, + row.Channel, + ct, + measurement => ApplyOffMeasurement(row, measurement), + ReadDisplayedMeasurementAsync); + ApplyOffMeasurement(row, offResult.Measurement); + bool offOk = offResult.IsStableOff; + row.Status = (onOk && offOk) ? TestStatus.Pass : TestStatus.Fail; string verdict = row.Status == TestStatus.Pass ? "合格" : "不合格"; - string offState = offResult.IsStableOff ? "关状态合格" : "关状态不合格"; + string offState = offOk ? "已稳定关断" : "关断超时"; _log.Add(row.Status == TestStatus.Pass ? UiLogLevel.Success : UiLogLevel.Error, $"CH{row.Channel} 关 -> V={row.VoltageOff:F2}V I={row.CurrentOff:F3}A {offState} {verdict}"); } @@ -290,6 +290,54 @@ public partial class BasicTestViewModel : ObservableObject } } + private async Task ReadDisplayedMeasurementAsync(int channel, CancellationToken ct) + { + var measurements = await _sspc.ReadMeasurementsAsync(ct); + var measurement = measurements.FirstOrDefault(x => x.PhysicalSlot == channel); + if (measurement != null) + return measurement; + + return await _sspc.ReadMeasurementAsync(channel, ct); + } + + private static void ApplyOnMeasurement(ChannelResult row, ChannelMeasurement measurement) + { + ApplyMeasurement(row, measurement, isOnMeasurement: true); + } + + private static void ApplyOffMeasurement(ChannelResult row, ChannelMeasurement measurement) + { + ApplyMeasurement(row, measurement, isOnMeasurement: false); + } + + private static void ApplyMeasurement(ChannelResult row, ChannelMeasurement measurement, bool isOnMeasurement) + { + void Apply() + { + double voltage = measurement.Voltage; + double current = Math.Abs(measurement.Current); + if (isOnMeasurement) + { + row.VoltageOn = voltage; + row.CurrentOn = current; + } + else + { + row.VoltageOff = voltage; + row.CurrentOff = current; + } + + row.DisplayVoltage = voltage; + row.DisplayCurrent = current; + } + + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher == null || dispatcher.CheckAccess()) + Apply(); + else + dispatcher.Invoke(Apply); + } + private async Task PollMeasurementsAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) @@ -322,10 +370,7 @@ public partial class BasicTestViewModel : ObservableObject var row = Rows.FirstOrDefault(x => x.Channel == measurement.PhysicalSlot); if (row == null) continue; - row.VoltageOn = measurement.Voltage; - row.CurrentOn = Math.Abs(measurement.Current); - row.DisplayVoltage = row.VoltageOn; - row.DisplayCurrent = row.CurrentOn; + ApplyOnMeasurement(row, measurement); } } }