曲线标记、安全联动功能已验证
This commit is contained in:
parent
5d56137ce5
commit
95a859ec85
@ -25,10 +25,8 @@ public static class CurveMetrics
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -36,10 +34,8 @@ public static class CurveMetrics
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Falling curve 90% - 10% transition mark.</summary>
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -3,7 +3,7 @@ using SSPCTester.Devices.Interfaces;
|
||||
|
||||
namespace SSPCTester.Logic.Testing;
|
||||
|
||||
/// <summary>基础通断测试的统一判定规则。</summary>
|
||||
/// <summary>Shared pass/fail rules for the basic on/off test.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 等待关状态稳定。连续多次满足“电压有数据、电流无数据”才确认关断,避免旧帧和瞬时噪声造成误判。
|
||||
/// Waits until the channel is stably off: no voltage and no current for consecutive samples.
|
||||
/// </summary>
|
||||
public static async Task<OffStateResult> WaitForStableOffAsync(
|
||||
ISspc sspc,
|
||||
int channel,
|
||||
CancellationToken ct = default)
|
||||
CancellationToken ct = default,
|
||||
Action<ChannelMeasurement>? onSample = null,
|
||||
Func<int, CancellationToken, Task<ChannelMeasurement>>? 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<ChannelMeasurement> ReadSampleAsync(
|
||||
ISspc sspc,
|
||||
int channel,
|
||||
CancellationToken ct,
|
||||
Func<int, CancellationToken, Task<ChannelMeasurement>>? readSampleAsync) =>
|
||||
readSampleAsync?.Invoke(channel, ct) ?? sspc.ReadMeasurementAsync(channel, ct);
|
||||
}
|
||||
|
||||
public sealed record OffStateResult(
|
||||
|
||||
@ -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<ChannelMeasurement>();
|
||||
|
||||
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<ChannelMeasurement> _readings;
|
||||
|
||||
public SequenceSspc(IEnumerable<ChannelMeasurement> readings)
|
||||
{
|
||||
_readings = new Queue<ChannelMeasurement>(readings);
|
||||
}
|
||||
|
||||
public int ChannelCount => 24;
|
||||
public bool IsConnected => true;
|
||||
public string DisplayName => "Sequence SSPC";
|
||||
public event EventHandler<bool>? ConnectionChanged;
|
||||
|
||||
public Task<bool> 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<bool> GetChannelStateAsync(int physicalSlot, CancellationToken ct = default) => Task.FromResult(false);
|
||||
|
||||
public Task<ChannelMeasurement> ReadMeasurementAsync(int physicalSlot, CancellationToken ct = default)
|
||||
{
|
||||
if (_readings.Count > 1)
|
||||
return Task.FromResult(_readings.Dequeue());
|
||||
|
||||
return Task.FromResult(_readings.Peek());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ChannelMeasurement>> ReadMeasurementsAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<ChannelMeasurement>>(_readings.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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<SafetyMonitorService> _logger;
|
||||
@ -23,6 +24,7 @@ public sealed class SafetyMonitorService : IDisposable
|
||||
ISafetyDio safetyDio,
|
||||
IPowerSupply power,
|
||||
ILoad load,
|
||||
ISspc sspc,
|
||||
IOptions<DeviceOptions> options,
|
||||
UiLogSink log,
|
||||
ILogger<SafetyMonitorService> 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
|
||||
|
||||
@ -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<ChannelMeasurement> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user