添加曲线标记功能

This commit is contained in:
zhaotielin 2026-07-05 13:33:55 +08:00
parent 041bf693d1
commit 5d56137ce5
7 changed files with 460 additions and 20 deletions

View File

@ -0,0 +1,108 @@
# 算法说明文档
## 1. ThresholdV1 阈值算法
上升曲线和下降曲线都使用同一类阈值判断算法:根据名义高电平 `Vhigh` 计算 90% 阈值和 10% 阈值,再在曲线中查找对应的时间点。
核心阈值:
```text
90% 阈值 = Vhigh * R90
10% 阈值 = Vhigh * Rstart
```
在参数模板中:
```text
90% 阈值 = 3.0 * 0.9 = 2.7 V
10% 阈值 = 3.0 * 0.1 = 0.3 V
```
曲线图中显示的两条水平虚线分别对应 90% 阈值线和 10% 阈值线。
## 2. 曲线内标记时间线
### 开启曲线:开启 - 上升
开启曲线用于分析信号从低电平上升到高电平的过程。
```text
低电平 -> 开启起点 T0 -> 上升 -> T90 -> 高电平
```
判断逻辑:
1. 查找信号第一次达到 10% 阈值的位置,作为开启起点 `T0`
2. 从 `T0` 之后继续查找信号第一次达到 90% 阈值的位置,作为 `T90`
3. 计算开启上升时间:
```text
RiseTime = T90 - T0
```
| 标记 | 含义 |
| --- | --- |
| `T0` | 开启起点,信号达到 10% 阈值 |
| `T90` | 信号达到 90% 阈值 |
| `RiseTime` | 开启上升时间 |
### 关断曲线:关断 - 下降
关断曲线用于分析信号从高电平下降到低电平的过程。
```text
高电平 -> 关断起点 Toff -> 下降 -> T10 -> 低电平
```
判断逻辑:
1. 确认曲线中存在高电平区域。
2. 查找信号下降到 90% 阈值的位置,作为关断起点 `Toff`
3. 从 `Toff` 之后继续查找信号下降到 10% 阈值的位置,作为 `T10`
4. 计算关断下降时间:
```text
FallTime = T10 - Toff
```
| 标记 | 含义 |
| --- | --- |
| `Toff` | 关断起点,信号下降到 90% 阈值 |
| `T10` | 信号下降到 10% 阈值 |
| `FallTime` | 关断下降时间 |
## 3. 参数模板
参数固定在代码变量中,模板如下:
```text
Vhigh: 3.0
R90: 0.9
Rstart: 0.1
StableN: 1
Win: 5
Sigma: 300
MedianFilterCheckBox: 5
```
### 参数含义
| 参数 | 模板值 | 含义 |
| --- | --- | --- |
| `Vhigh` | `3.0` | 名义高电平电压 |
| `R90` | `0.9` | 90% 阈值比例 |
| `Rstart` | `0.1` | 10% 阈值比例 |
| `StableN` | `1` | 连续满足阈值的采样点数量 |
| `Win` | `5` | 搜索/判断窗口点数 |
| `Sigma` | `300` | 算法阈值或窗口相关参数,按代码变量固定 |
| `MedianFilterCheckBox` | `5` | 中值滤波窗口点数 |
## 4. 输出字段
| 字段 | 开启曲线含义 | 关断曲线含义 |
| --- | --- | --- |
| `StartIndex` | `T0` 对应采样点索引 | `Toff` 对应采样点索引 |
| `EndIndex` | `T90` 对应采样点索引 | `T10` 对应采样点索引 |
| `T0` | 开启起点时间 | 关断起点时间 |
| `T90` | 上升到 90% 的时间 | 下降到 10% 的时间 |
| `RiseTime` | `T90 - T0` | `T10 - Toff` |

View File

@ -47,11 +47,8 @@ public static class CurveMetrics
/// </summary>
public static double RiseTimeMs(CurveData curve, double vMax, double lowRatio = 0.1, double highRatio = 0.9)
{
int iLow = FirstReachIndex(curve.Voltage, vMax * lowRatio, rising: true);
if (iLow < 0) return double.NaN;
int iHigh = FirstReachIndex(curve.Voltage, vMax * highRatio, rising: true, startIndex: iLow);
if (iHigh < 0) return double.NaN;
return (curve.TimeAt(iHigh) - curve.TimeAt(iLow)) * 1000.0;
var mark = AnalyzeRising(curve, vMax, lowRatio, highRatio);
return mark.IsValid ? mark.DurationMs : double.NaN;
}
/// <summary>
@ -59,11 +56,84 @@ public static class CurveMetrics
/// </summary>
public static double FallTimeMs(CurveData curve, double vMax, double highRatio = 0.9, double lowRatio = 0.1)
{
int iHigh = FirstReachIndex(curve.Voltage, vMax * highRatio, rising: false);
if (iHigh < 0) return double.NaN;
int iLow = FirstReachIndex(curve.Voltage, vMax * lowRatio, rising: false, startIndex: iHigh);
if (iLow < 0) return double.NaN;
return (curve.TimeAt(iLow) - curve.TimeAt(iHigh)) * 1000.0;
var mark = AnalyzeFalling(curve, vMax, highRatio, lowRatio);
return mark.IsValid ? mark.DurationMs : double.NaN;
}
/// <summary>Rising curve 10% - 90% transition mark.</summary>
public static CurveTransitionMark AnalyzeRising(CurveData curve, double vMax, double lowRatio = 0.1, double highRatio = 0.9)
{
double startThreshold = 0;
double endThreshold = vMax * highRatio;
int triggerIndex = TriggerIndex(curve);
int endIndex = FirstReachIndex(curve.Voltage, endThreshold, rising: true, startIndex: triggerIndex);
if (endIndex < 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);
return BuildTransitionMark(curve, startIndex, endIndex, startThreshold, endThreshold);
}
/// <summary>Falling curve 90% - 10% transition mark.</summary>
public static CurveTransitionMark AnalyzeFalling(CurveData curve, double vMax, double highRatio = 0.9, double lowRatio = 0.1)
{
double startThreshold = vMax;
double endThreshold = vMax * lowRatio;
int triggerIndex = TriggerIndex(curve);
int endIndex = FirstReachIndex(curve.Voltage, endThreshold, rising: false, startIndex: triggerIndex);
if (endIndex < 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);
return BuildTransitionMark(curve, startIndex, endIndex, startThreshold, endThreshold);
}
private static int TriggerIndex(CurveData curve)
{
if (curve.TriggerSampleIndex > 0)
return Math.Min(curve.TriggerSampleIndex, Math.Max(curve.Voltage.Length - 1, 0));
if (curve.SampleRateHz <= 0)
return 0;
int index = (int)Math.Round(curve.TriggerTimeSec * curve.SampleRateHz);
return Math.Clamp(index, 0, Math.Max(curve.Voltage.Length - 1, 0));
}
private static int LastReachIndex(double[] v, double vTarget, bool rising, int startIndex, int endIndex)
{
if (v == null || v.Length == 0) return -1;
startIndex = Math.Clamp(startIndex, 0, v.Length - 1);
endIndex = Math.Clamp(endIndex, 0, v.Length - 1);
for (int i = endIndex; i >= startIndex; i--)
{
if (rising ? v[i] >= vTarget : v[i] <= vTarget) return i;
}
return -1;
}
private static CurveTransitionMark BuildTransitionMark(
CurveData curve,
int startIndex,
int endIndex,
double startThreshold,
double endThreshold)
{
double startTimeMs = (curve.TimeAt(startIndex) - curve.TriggerTimeSec) * 1000.0;
double endTimeMs = (curve.TimeAt(endIndex) - curve.TriggerTimeSec) * 1000.0;
return new CurveTransitionMark(
startIndex,
endIndex,
startTimeMs,
endTimeMs,
endTimeMs - startTimeMs,
startThreshold,
endThreshold,
true);
}
/// <summary>从波形最大值估算 Vmax去掉前后噪声取 95 百分位以上的均值)。</summary>
@ -78,3 +148,25 @@ public static class CurveMetrics
return n > 0 ? sum / n : sorted[^1];
}
}
public readonly record struct CurveTransitionMark(
int StartIndex,
int EndIndex,
double StartTimeMs,
double EndTimeMs,
double DurationMs,
double StartThresholdV,
double EndThresholdV,
bool IsValid)
{
public static CurveTransitionMark Invalid(double startThresholdV, double endThresholdV) =>
new(
-1,
-1,
double.NaN,
double.NaN,
double.NaN,
startThresholdV,
endThresholdV,
false);
}

View File

@ -18,6 +18,7 @@ public static class CurveImageRenderer
curve,
"ON",
Color.FromHex("#1A73C4"),
true,
viewStartMs,
viewEndMs,
width,
@ -35,6 +36,7 @@ public static class CurveImageRenderer
curve,
"OFF",
Color.FromHex("#D23B3B"),
false,
viewStartMs,
viewEndMs,
width,
@ -68,6 +70,8 @@ public static class CurveImageRenderer
double vMax = CurveMetrics.EstimateVMax(rising.Voltage);
AddThreshold(plot, vMax * 0.9, "90%");
AddThreshold(plot, vMax * 0.1, "10%");
AddTransitionMark(plot, rising, vMax, true, viewStartMs, viewEndMs);
AddTransitionMark(plot, falling, vMax, false, viewStartMs, viewEndMs);
if (!hasWindow || (start <= 0 && end >= 0))
{
@ -87,6 +91,7 @@ public static class CurveImageRenderer
CurveData curve,
string phaseLabel,
Color color,
bool isRising,
double? viewStartMs,
double? viewEndMs,
int width,
@ -110,6 +115,7 @@ public static class CurveImageRenderer
double vMax = CurveMetrics.EstimateVMax(curve.Voltage);
AddThreshold(plot, vMax * 0.9, "90%");
AddThreshold(plot, vMax * 0.1, "10%");
AddTransitionMark(plot, curve, vMax, isRising, viewStartMs, viewEndMs);
if (!hasWindow || (start <= 0 && end >= 0))
{
@ -176,4 +182,61 @@ public static class CurveImageRenderer
line.LineWidth = 1;
line.LegendText = label;
}
private static void AddTransitionMark(
Plot plot,
CurveData curve,
double vMax,
bool isRising,
double? viewStartMs,
double? viewEndMs)
{
if (!double.IsFinite(vMax) || vMax <= 0) return;
var mark = isRising
? CurveMetrics.AnalyzeRising(curve, vMax)
: CurveMetrics.AnalyzeFalling(curve, vMax);
if (!mark.IsValid) return;
bool hasWindow = IsValidWindow(viewStartMs, viewEndMs);
double start = viewStartMs ?? 0;
double end = viewEndMs ?? 0;
string startLabel = isRising ? "TON" : "TOFF";
string endLabel = isRising ? "T90" : "T10";
if (IsVisibleInView(mark.StartTimeMs, hasWindow, start, end))
AddMarkerLine(plot, mark.StartTimeMs, mark.StartThresholdV, startLabel, Color.FromHex("#1C8A2E"));
if (IsVisibleInView(mark.EndTimeMs, hasWindow, start, end))
AddMarkerLine(plot, mark.EndTimeMs, mark.EndThresholdV, endLabel, Color.FromHex("#D93025"));
double middleTime = (mark.StartTimeMs + mark.EndTimeMs) / 2.0;
if (IsVisibleInView(middleTime, hasWindow, start, end))
{
var points = BuildViewPoints(curve, viewStartMs, viewEndMs);
if (points.Count == 0) return;
double minY = points.Min(x => x.Voltage);
double maxY = points.Max(x => x.Voltage);
double labelY = minY + (maxY - minY) * 0.86;
var duration = plot.Add.Text($"{startLabel}-{endLabel} {mark.DurationMs:F3} ms", middleTime, labelY);
duration.LabelFontColor = Color.FromHex("#2F3A45");
duration.LabelFontSize = 11;
}
}
private static void AddMarkerLine(Plot plot, double timeMs, double thresholdV, string text, Color color)
{
var line = plot.Add.VerticalLine(timeMs);
line.Color = color;
line.LineWidth = 2;
var label = plot.Add.Text(text, timeMs, thresholdV);
label.LabelFontColor = color;
label.LabelFontSize = 11;
}
private static bool IsVisibleInView(double timeMs, bool hasWindow, double viewStart, double viewEnd) =>
double.IsFinite(timeMs) && (!hasWindow || (timeMs >= viewStart && timeMs <= viewEnd));
}

View File

@ -41,12 +41,29 @@ public class CurveMetricsTests
}
[Fact]
public void RiseTimeMs_TenToNinety_AboutTwoPointTwo()
public void RiseTimeMs_TonToNinety_AboutNinetyPercentTime()
{
var c = MakeRising();
double rt = CurveMetrics.RiseTimeMs(c, 28);
// ln(0.9/0.1) = ln 9 ≈ 2.197rt = tau * 2.197 ≈ 1.099 ms
Assert.InRange(rt, 1.05, 1.15);
Assert.InRange(rt, 1.1, 1.2);
}
[Fact]
public void AnalyzeRising_ReturnsTonToNinetyIndexesAndDuration()
{
var c = MakeRising();
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;
Assert.True(mark.IsValid);
Assert.Equal(expectedStart, mark.StartIndex);
Assert.Equal(expectedEnd, mark.EndIndex);
Assert.Equal(0, 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);
}
[Fact]
@ -57,6 +74,55 @@ public class CurveMetricsTests
Assert.InRange(off, 1.1, 1.2);
}
[Fact]
public void AnalyzeFalling_ReturnsStableHighToTenIndexesAndDuration()
{
var c = MakeFalling();
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;
}
Assert.True(mark.IsValid);
Assert.Equal(expectedStart, mark.StartIndex);
Assert.Equal(expectedEnd, mark.EndIndex);
Assert.Equal(28, 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 AnalyzeTransition_ReturnsInvalidWhenThresholdsAreNotReached()
{
var risingCurve = new CurveData
{
SampleRateHz = 100_000,
TriggerTimeSec = 0,
Voltage = Enumerable.Repeat(0.0, 100).ToArray()
};
var fallingCurve = new CurveData
{
SampleRateHz = 100_000,
TriggerTimeSec = 0,
Voltage = Enumerable.Repeat(28.0, 100).ToArray()
};
var rising = CurveMetrics.AnalyzeRising(risingCurve, 28);
var falling = CurveMetrics.AnalyzeFalling(fallingCurve, 28);
Assert.False(rising.IsValid);
Assert.False(falling.IsValid);
Assert.Equal(-1, rising.StartIndex);
Assert.Equal(-1, falling.EndIndex);
Assert.True(double.IsNaN(CurveMetrics.RiseTimeMs(risingCurve, 28)));
Assert.True(double.IsNaN(CurveMetrics.FallTimeMs(fallingCurve, 28)));
}
[Fact]
public void EstimateVMax_NearActualMax()
{

View File

@ -5,6 +5,7 @@ using System.Windows.Media;
using ScottPlot;
using ScottPlot.WPF;
using SSPCTester.Devices.Interfaces;
using SSPCTester.Logic.Calculation;
namespace SSPCTester.UI.Controls;
@ -103,14 +104,9 @@ public partial class CurvePlotControl : UserControl
sig.MarkerSize = 0;
// 阈值线
double threshold = IsRising ? VMax * 0.9 : VMax * 0.1;
var hLine = _plot.Plot.Add.HorizontalLine(threshold);
hLine.Color = ScottPlot.Color.FromHex("#98A1AD");
hLine.LinePattern = ScottPlot.LinePattern.Dashed;
hLine.LineWidth = 1;
var label = _plot.Plot.Add.Text($"{(IsRising ? 90 : 10)}%", xs[0], threshold);
label.LabelFontColor = ScottPlot.Color.FromHex("#98A1AD");
label.LabelFontSize = 10;
AddThresholdLine(VMax * 0.9, "90%", xs[0]);
AddThresholdLine(VMax * 0.1, "10%", xs[0]);
AddTransitionMark(c, xs, ys);
// 触发线 t=0
if (!HasViewWindow(out double start, out double end) || (start <= 0 && end >= 0))
@ -125,6 +121,66 @@ public partial class CurvePlotControl : UserControl
_plot.Refresh();
}
private void AddThresholdLine(double volts, string text, double labelX)
{
if (!double.IsFinite(volts)) return;
var hLine = _plot!.Plot.Add.HorizontalLine(volts);
hLine.Color = ScottPlot.Color.FromHex("#98A1AD");
hLine.LinePattern = ScottPlot.LinePattern.Dashed;
hLine.LineWidth = 1;
var label = _plot.Plot.Add.Text(text, labelX, volts);
label.LabelFontColor = ScottPlot.Color.FromHex("#98A1AD");
label.LabelFontSize = 10;
}
private void AddTransitionMark(CurveData curve, double[] xs, double[] ys)
{
if (!double.IsFinite(VMax) || VMax <= 0) return;
var mark = IsRising
? CurveMetrics.AnalyzeRising(curve, VMax)
: CurveMetrics.AnalyzeFalling(curve, VMax);
if (!mark.IsValid) return;
bool hasWindow = HasViewWindow(out double viewStart, out double viewEnd);
string startLabel = IsRising ? "TON" : "TOFF";
string endLabel = IsRising ? "T90" : "T10";
if (IsVisibleInView(mark.StartTimeMs, hasWindow, viewStart, viewEnd))
AddMarkerLine(mark.StartTimeMs, mark.StartThresholdV, startLabel, "#1C8A2E");
if (IsVisibleInView(mark.EndTimeMs, hasWindow, viewStart, viewEnd))
AddMarkerLine(mark.EndTimeMs, mark.EndThresholdV, endLabel, "#D93025");
double middleTime = (mark.StartTimeMs + mark.EndTimeMs) / 2.0;
if (IsVisibleInView(middleTime, hasWindow, viewStart, viewEnd))
{
double minY = ys.Min();
double maxY = ys.Max();
double labelY = minY + (maxY - minY) * 0.86;
var duration = _plot!.Plot.Add.Text($"{startLabel}-{endLabel} {mark.DurationMs:F3} ms", middleTime, labelY);
duration.LabelFontColor = ScottPlot.Color.FromHex("#2F3A45");
duration.LabelFontSize = 11;
}
}
private void AddMarkerLine(double timeMs, double thresholdV, string text, string colorHex)
{
var color = ScottPlot.Color.FromHex(colorHex);
var line = _plot!.Plot.Add.VerticalLine(timeMs);
line.Color = color;
line.LineWidth = 2;
var label = _plot.Plot.Add.Text(text, timeMs, thresholdV);
label.LabelFontColor = color;
label.LabelFontSize = 11;
}
private static bool IsVisibleInView(double timeMs, bool hasWindow, double viewStart, double viewEnd) =>
double.IsFinite(timeMs) && (!hasWindow || (timeMs >= viewStart && timeMs <= viewEnd));
private List<(double TimeMs, double Voltage)> BuildViewPoints(CurveData curve)
{
var points = new List<(double TimeMs, double Voltage)>(curve.Voltage.Length);

View File

@ -146,6 +146,22 @@ public partial class CurveTestViewModel : ObservableObject
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()
{
@ -157,6 +173,41 @@ public partial class CurveTestViewModel : ObservableObject
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;

View File

@ -89,6 +89,8 @@
FontFamily="{StaticResource FontMono}" />
<Button Content="默认" Width="54" Height="26" Padding="0" Margin="0,0,8,0"
Command="{Binding ResetRisingCurveViewCommand}" />
<Button Content="缩放到点位" Width="86" Height="26" Padding="0" Margin="0,0,8,0"
Command="{Binding ZoomRisingCurveToMarksCommand}" />
<Button Content="放大" Width="64" Height="26" Padding="0"
Command="{Binding OpenPopupCommand}"
CommandParameter="Rising" />
@ -118,6 +120,8 @@
FontFamily="{StaticResource FontMono}" />
<Button Content="默认" Width="54" Height="26" Padding="0" Margin="0,0,8,0"
Command="{Binding ResetFallingCurveViewCommand}" />
<Button Content="缩放到点位" Width="86" Height="26" Padding="0" Margin="0,0,8,0"
Command="{Binding ZoomFallingCurveToMarksCommand}" />
<Button Content="放大" Width="64" Height="26" Padding="0"
Command="{Binding OpenPopupCommand}"
CommandParameter="Falling" />