diff --git a/HANDOFF_ARSAS_SMV_TAB_ACCEPTED_CHECKPOINT_2026-10-24.md b/HANDOFF_ARSAS_SMV_TAB_ACCEPTED_CHECKPOINT_2026-10-24.md
new file mode 100644
index 000000000..58ac5ae0f
--- /dev/null
+++ b/HANDOFF_ARSAS_SMV_TAB_ACCEPTED_CHECKPOINT_2026-10-24.md
@@ -0,0 +1,44 @@
+# ARSAS SMV Tab — accepted FAT checkpoint, 2026-10-24
+
+Status: **operator-accepted SMV visual/engineering workflow, consolidated integration checkpoint**
+Scope: IEC 61850-9-2 Sampled Values passive monitor and its necessary shared WPF Inter typography.
+Decision: merge accepted SMV-only stacked work onto an isolated integration checkpoint; **do not imply that `main` or the independent SG7/SSOT/Write/Control workstreams are released**.
+
+## Canonical provenance (single source of truth)
+
+- Approved final code HEAD: `4b2b62f882a81988a39a2f82d1ac8ff67592be02` ([source PR #722](https://github.com/masarray/arsas/pull/722)).
+- **Consolidated merge**: [PR #725](https://github.com/masarray/arsas/pull/725), merge commit `bcf0ceb92a4dc6081d725ce0bd0c8f60bc102cc4` on isolated integration checkpoint; merge comparison to accepted source changed **only this handoff file**.
+- Integration PR exact-source-SHA CI: [Windows build #40052239441](https://github.com/masarray/arsas/actions/runs/40052239441), [SV validation #40052239421](https://github.com/masarray/arsas/actions/runs/40052239421), [P1 advisory #40052239471](https://github.com/masarray/arsas/actions/runs/40052239471), all SUCCESS.
+- FAT candidate workflow: [Windows Build ARSAS 40050735060](https://github.com/masarray/arsas/actions/runs/40050735060): ARSAS managed regressions **2784/2784**, engine **24/24**, native/managed bridge **6/6**, portable EXE smoke success.
+- [SV validation 40050735053](https://github.com/masarray/arsas/actions/runs/40050735053) passed; [CI P1 advisory 40050735051](https://github.com/masarray/arsas/actions/runs/40050735051) passed.
+- Operator-tested FAT EXE: [artifact 12543442068](https://github.com/masarray/arsas/actions/runs/40050735060/artifacts/12543442068), `ARSAS-1.6.40-win-x64-portable.exe`.
+- EXE SHA-256: `1bd10822cf9523ed42c30f7824e8a2e4f5e0eeb8089b9591ae72c2f0e53b44d8`.
+- Operator acceptance: visual presentation declared **good and finished** in the GitHub handoff chat, requesting checkpoint/closure on 2026-10-24.
+- Completed stacked SMV sequence: [#708](https://github.com/masarray/arsas/pull/708) → [#711](https://github.com/masarray/arsas/pull/711) → [#714](https://github.com/masarray/arsas/pull/714) → [#715](https://github.com/masarray/arsas/pull/715) → [#717](https://github.com/masarray/arsas/pull/717) → [#719](https://github.com/masarray/arsas/pull/719) → [#722](https://github.com/masarray/arsas/pull/722).
+- SSOT parent anchor: `00afef5be5a5a4a84c071eb1a82435b8b3c262df` (head of draft #705). The checkpoint starts at precisely this SHA, **without advancing #705**.
+
+## Locked behavior and regression invariants
+
+1. Passive acquisition, bounded coherent stream snapshots, independent SV publisher identity, live stream discovery and synchronization provenance all remain truthful. `smpSynch` is *publisher-reported*, never verified PTP/GPS/GM lock.
+2. Selecting SV1/SV2/SV3 must not blank the window or show spurious Sync Unknown between valid snapshots. Last coherent data are retained under explicit HOLD and **never attributed to the newly selected publisher**. Stream list and eight measurement rows retain presentation identity with change-only notifications.
+3. For verified mapped 9-2LE, Voltage waveform first, Current below; voltage left and current right in Measurements; RMS, phasor magnitude+phase, Power P/Q₁/S/PF consistent with the selected coherent publisher, no naive cross-publisher pairing.
+4. RAW counts are uncalibrated. RAW phasor callouts display channel+angle only and Power remains fully collapsed. Do not claim U/I calibration or fabricated signal frequency/timebase.
+5. UI phase angle displayed 0–360° without altering signed mathematical phase/DFT calculations. Correct shared axis zero reference, per-phase stacked axes, ms timeline, cursor intersections and callouts.
+6. SMV renders compact electrical information by default. Diagnostics (APPID, MAC, VLAN, gap, restart, scales, measurement methods, rate provenance) remain accessible by hover/Diagnostics; safely short, wrapped sync HOLD tooltip. Embedded Inter is the shared desktop typography.
+7. Do not reintroduce panel recreation, all-row replacement, per-packet WPF invalidations or synchronous GUI work. No default animation timers and no unnecessary user dialogs.
+8. Changes in this stack beyond SMV WPF components are intentional *shared font consistency* or strict source-test guards, not a license to modify Command/Write behavior.
+
+## Cross-thread integration contract
+
+- **SG7 performance / SSOT (#679, #695, #698, #703, #705):** no overwrite of reactive viewport/indexing, model journal, live signal priorities or performance counters. This checkpoint is based on SSOT #705; promote only through an independently reviewed integration that accounts for in-flight branch changes.
+- **MMS Control / Command Dock / Write (#721 and active FAT branches):** none of those command/safety paths is changed by this checkpoint; keep their separate PR approvals and tests.
+- **Future SMV changes:** branch from this integration checkpoint or a proven descendant, record previous HEAD, require exact-head Windows app+engine+native+SV validation and physical signal FAT. No post-acceptance formatting churn without a new issue.
+- **Do not force merge into `main`.** As of audit, `main` and the SMV final source had diverged significantly (>1000 commits ahead and independent commits on main). The dedicated checkpoint is an **integration milestone, not a production release or main merge**.
+
+## Reproduction / recovery
+
+1. Fetch branch `checkpoint/smv-tab-v1640-fat-accepted-20261024`; canonical GitHub consolidated [PR #725](https://github.com/masarray/arsas/pull/725) merged at `bcf0ceb92a4dc6081d725ce0bd0c8f60bc102cc4`. This documentation-only follow-up does not change compiled code.
+2. The reviewed FAT source tree is `4b2b62f882a81988a39a2f82d1ac8ff67592be02` and the above Windows artifact is the independently identified build; never relabel an older #719 or #717 artifact as this checkpoint.
+3. Restore using the integration merge commit as a starting SHA, not a force-push to shared workstreams; revert through normal PR if regressions appear.
+4. Component PRs #708/#711/#714/#715/#717/#719/#722 are **closed as consolidated into integration PR #725** (not individually merged into `main`). Original M2.14 SMV navigation/safe-capture ancestors #589/#592 are also closed as superseded (their heads are proven ancestors of the accepted FAT source). Historic divergent SMV PRs #667/#671/#675/#680 are **closed as superseded proposals**; their unique historic commits were NOT implicitly cherry-picked and source branches are retained. Any independently useful old change needs a fresh reviewed proposal.
+5. No SMV-titled open PRs or SMV-specific titled issues were found at final closure. Shared cross-thread checkpoint issue #651 and SG7 issues #706/#707 stay open by design; closing those would incorrectly interfere with unrelated work.
diff --git a/MainWindow.PersistentWorkbench.cs b/MainWindow.PersistentWorkbench.cs
index 9d2c8d824..21139a39a 100644
--- a/MainWindow.PersistentWorkbench.cs
+++ b/MainWindow.PersistentWorkbench.cs
@@ -40,6 +40,9 @@ private sealed class PersistentWorkbenchState
// Preserve its user-chosen expansion state across navigation. Replaying
// different per-tab defaults repeatedly unloaded/reloaded command rows.
public bool DockExpanded { get; set; } = true;
+ // SV inspection is independent of MMS command admission. Default
+ // collapsed, but retain the operator's explicit override per workspace.
+ public bool SmvDockExpanded { get; set; } = false;
}
[ModuleInitializer]
@@ -381,20 +384,26 @@ private void CaptureP0CommandDockState(bool expanded)
if (state == null || state.ApplyingDockState || state.SuppressDockStateCapture)
return;
- state.DockExpanded = expanded;
+ if (MainTabs.SelectedIndex == EngineeringNavigationRoute.SmvWorkspaceIndex)
+ state.SmvDockExpanded = expanded;
+ else
+ state.DockExpanded = expanded;
}
private void ApplyP0CommandDockState(PersistentWorkbenchState state, int workspaceIndex)
{
- // Navigation is read-only to dock state. No WPF unload/reload or
- // implicit expanded-event MMS inspection on tab change.
- if (CommandPanelExpander.IsExpanded == state.DockExpanded)
+ // Restore the operator choice for each purpose, while initially hiding
+ // MMS commands on passive SV inspection. Do not recreate the panel or
+ // invoke any control/refresh operation as a navigation side effect.
+ var desiredExpanded = workspaceIndex == EngineeringNavigationRoute.SmvWorkspaceIndex
+ ? state.SmvDockExpanded : state.DockExpanded;
+ if (CommandPanelExpander.IsExpanded == desiredExpanded)
return;
state.ApplyingDockState = true;
try
{
- CommandPanelExpander.IsExpanded = state.DockExpanded;
+ CommandPanelExpander.IsExpanded = desiredExpanded;
}
finally
{
diff --git a/Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs b/Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs
index 829ae8c2c..4b4a56391 100644
--- a/Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs
+++ b/Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs
@@ -4,6 +4,7 @@
using System.Windows;
using System.Windows.Media;
using ProcessBus.Core.Models;
+using ArIED61850Tester.Views;
using ProcessBus.Core.Presentation;
namespace ProcessBus.App.Wpf.Controls;
@@ -139,10 +140,38 @@ protected override void OnRender(DrawingContext dc)
if (width < 130 || height < 130)
return;
- // Reserve side rails for collision-free labels, rather than letting text
- // overlap the arrows or escape beyond the narrow analyzer workspace.
- var center = new Point(width / 2.0, height / 2.0 - 2);
- var radius = Math.Max(0, Math.Min(width / 2.0 - 69, height / 2.0 - 35));
+ // P1: fit the polar plot to the ACTUAL annotated text, not a fixed
+ // 69-DIP side rail. This prevents value labels from crossing the ring
+ // at narrower window widths or at different Windows text scaling.
+ // At most six FormattedText objects are measured once per repaint.
+ // At very narrow host widths the value remains available in the
+ // measurements table. Prefer a legible vector + phase callout over
+ // an unreadable ring pushed out of view by six magnitude strings.
+ var fitNarrow = width < 420;
+ var labelCache = _visualItems.Where(IsRenderable)
+ .ToDictionary(item => item, item =>
+ CreateText(FormatPhasorLabel(item, fitNarrow), 11.3,
+ PhaseBrush(PhaseIndex(item.Name)), LabelTypeface));
+ // Mode-wide minimum text lanes keep the ring/grid fixed while
+ // numbers update (e.g. 9.9V -> 100V). Widths beyond the reserve
+ // still grow safely; the common case never makes the plot breathe.
+ var engineeringLabels = !fitNarrow && labelCache.Keys.Any(item => item.Unit != "counts");
+ var leftRail = engineeringLabels ? 142.0 : 92.0;
+ var rightRail = engineeringLabels ? 142.0 : 92.0;
+ foreach (var pair in labelCache)
+ {
+ var rad = pair.Key.AngleDegrees!.Value * Math.PI / 180.0;
+ if (Math.Cos(rad) < -1e-8)
+ leftRail = Math.Max(leftRail, pair.Value.Width + 10.0);
+ else
+ rightRail = Math.Max(rightRail, pair.Value.Width + 10.0);
+ }
+ // Leave a safety corridor between ring and horizontal callout text;
+ // scale the ring, never reduce font size or clip individual labels.
+ var center = new Point((leftRail + width - rightRail) / 2.0, height / 2.0 - 2);
+ var radius = Math.Max(0, Math.Min(
+ Math.Min(center.X - leftRail, width - rightRail - center.X) - 6,
+ height / 2.0 - 35));
if (radius < 12)
return;
@@ -196,8 +225,7 @@ protected override void OnRender(DrawingContext dc)
dc.DrawLine(pen, center, tip);
DrawArrow(dc, center, tip, arrowPen, item.Family == PhasorFamily.Voltage ? 9.5 : 8.0);
dc.Pop();
- vectors.Add(new RenderedVector(item, tip, brush,
- CreateText(ToPhaseLabel(item.Name), 11.0, brush, LabelTypeface)));
+ vectors.Add(new RenderedVector(item, tip, brush, labelCache[item]));
}
dc.DrawEllipse(OriginInnerBrush, null, center, 4.2, 4.2);
@@ -245,21 +273,14 @@ protected override void OnRender(DrawingContext dc)
}
dc.Pop();
- // Numeric scale evidence comes from the SAME six vectors as arrows.
- // This footer is a visual reference (per-family maximum), not a
- // calibration claim or one shared physical voltage/current scale.
- var voltageUnit = _visualItems.FirstOrDefault(item =>
- item.Family == PhasorFamily.Voltage && IsRenderable(item))?.Unit ?? "counts";
- var currentUnit = _visualItems.FirstOrDefault(item =>
- item.Family == PhasorFamily.Current && IsRenderable(item))?.Unit ?? "counts";
- var voltageScaleText = PhasorScaleCaption.Format(voltageUnit == "V" ? "U" : "A", voltageMax, voltageUnit);
- var currentScaleText = PhasorScaleCaption.Format(currentUnit == "A" ? "I" : "B", currentMax, currentUnit);
- DrawText(dc, $"{voltageScaleText} · {currentScaleText}",
- 10.0, new Point(6, height - 31), TextSecondaryBrush, BodyTypeface);
- DrawText(dc, voltageUnit == "V" && currentUnit == "A"
- ? "9-2LE · U solid / I dashed · independent scales"
- : "RAW A solid · RAW B dashed · independent count scales",
- 9.5, new Point(6, height - 15), TextMutedBrush, BodyTypeface);
+ // The six callouts already display magnitudes. Avoid two further
+ // lines repeating normalization/calibration explanations below them.
+ // Still distinguish the independently normalized vector families.
+ var mapped = _visualItems.Any(item => item.Unit is "V" or "A");
+ DrawText(dc, mapped
+ ? "U solid · I dashed · separate scales"
+ : "RAW A solid · B dashed · separate scales",
+ 9.8, new Point(6, height - 17), TextMutedBrush, BodyTypeface);
if (!IsStreamLive)
DrawText(dc, "STALE · last valid samples", 11, new Point(7, 5), StaleBrush, LabelTypeface);
}
@@ -284,6 +305,38 @@ private static bool IsRenderable(PhasorDisplayItem item) =>
_ => PhaseRBrush
};
+ public static string FormatPhasorLabel(PhasorDisplayItem item, bool fitNarrow = false)
+ {
+ if (item.AngleDegrees is not double angle || !double.IsFinite(angle))
+ return item.Name;
+
+ // RAW has no proven U/I mapping or engineering scale. The grid below
+ // already shows exact counts: repeating eight-digit counts on the
+ // diagram consumes side rails and produces collisions. Keep identity
+ // + angle on the polar diagram; retain all data in Measurements.
+ var degrees = SmvAnglePresentation.Normalize360(angle);
+ var angleText = "∠" + degrees.ToString("0.#", CultureInfo.InvariantCulture) + "°";
+ if (item.Unit == "counts" || fitNarrow)
+ return item.Name + " " + angleText;
+
+ if (item.Magnitude is not double magnitude || !double.IsFinite(magnitude))
+ return item.Name + " " + angleText;
+
+ // Engineering callouts are compact approximate readouts only. Full
+ // precision remains in the Measurements table; magnitude here is
+ // fundamental RMS, not a silently invented waveform peak.
+ var value = CompactMagnitude(magnitude, item.Unit);
+ return item.Name + " " + value + " " + angleText;
+ }
+
+ private static string CompactMagnitude(double magnitude, string unit)
+ {
+ if ((unit == "V" || unit == "A") && Math.Abs(magnitude) >= 1000)
+ return (magnitude / 1000.0).ToString("0.##", CultureInfo.InvariantCulture) +
+ (unit == "V" ? "kV" : "kA");
+ return magnitude.ToString(unit == "A" ? "0.###" : "0.##", CultureInfo.InvariantCulture) + unit;
+ }
+
private static string ToPhaseLabel(string name) => name switch
{
"Ua" => "Ua (R)",
diff --git a/Ports/DigSubAnalyzer/Controls/SmvWaveformTimeAxis.cs b/Ports/DigSubAnalyzer/Controls/SmvWaveformTimeAxis.cs
new file mode 100644
index 000000000..2213a1df5
--- /dev/null
+++ b/Ports/DigSubAnalyzer/Controls/SmvWaveformTimeAxis.cs
@@ -0,0 +1,52 @@
+using ProcessBus.Core.Models;
+
+namespace ProcessBus.App.Wpf.Controls;
+
+///
+/// Presentation time axis only. In mapped IEC 9-2LE the first rising Ua
+/// zero crossing within the first cycle is t=0; sample order and sample
+/// counter evidence are NOT re-timed or modified. In RAW/nonmapped input,
+/// t=0 is the first visible sample, NOT a fabricated voltage phase.
+///
+public static class SmvWaveformTimeAxis
+{
+ public static int AvailableSamples(WaveformSnapshot snapshot)
+ {
+ var count = 0;
+ foreach (var row in snapshot.VoltageSeries)
+ count = Math.Max(count, row.Samples.Count);
+ foreach (var row in snapshot.CurrentSeries)
+ count = Math.Max(count, row.Samples.Count);
+ return count;
+ }
+
+ public static double ReferenceSample(WaveformSnapshot snapshot, int visibleSamples)
+ {
+ if (visibleSamples < 2)
+ return 0;
+ // CurrentSeries in this legacy visualization contract contains wire
+ // Ch5-8 (voltage in 9-2LE). Prefer semantic name, not container name.
+ WaveformSeriesModel? ua = null;
+ foreach (var row in snapshot.CurrentSeries)
+ if (row.Name == "Ua") { ua = row; break; }
+ if (ua is null)
+ foreach (var row in snapshot.VoltageSeries)
+ if (row.Name == "Ua") { ua = row; break; }
+ if (ua is null || ua.Samples.Count < 2)
+ return 0;
+
+ var count = Math.Min(visibleSamples, ua.Samples.Count);
+ var offset = ua.Samples.Count - count;
+ var span = Math.Min(count, snapshot.SamplesPerCycle > 1 ? snapshot.SamplesPerCycle : count);
+ for (var i = 1; i < span; i++)
+ {
+ var before = ua.Samples[offset + i - 1];
+ var after = ua.Samples[offset + i];
+ if (!double.IsFinite(before) || !double.IsFinite(after))
+ continue;
+ if (before <= 0 && after > 0)
+ return i - 1 + (-before / (after - before));
+ }
+ return 0; // No visible Ua rising crossing: frame-relative axis, no claim.
+ }
+}
diff --git a/Ports/DigSubAnalyzer/Controls/WaveformCursorCalloutLayout.cs b/Ports/DigSubAnalyzer/Controls/WaveformCursorCalloutLayout.cs
new file mode 100644
index 000000000..11ae37687
--- /dev/null
+++ b/Ports/DigSubAnalyzer/Controls/WaveformCursorCalloutLayout.cs
@@ -0,0 +1,74 @@
+using System.Windows;
+
+namespace ProcessBus.App.Wpf.Controls;
+
+///
+/// Deterministic geometry for live SV cursor callouts. The input is limited to
+/// one lane (at most four phase/neutral curves); no scheduling, animation,
+/// packet work or canvas measurements are performed here.
+///
+public static class WaveformCursorCalloutLayout
+{
+ public readonly record struct Anchor(int Index, double Y, double Width);
+ public readonly record struct Placement(int Index, Rect Box, bool LabelRight);
+
+ public static Placement[] Arrange(Rect plot, double cursorX, IReadOnlyList anchors)
+ {
+ if (anchors.Count == 0 || plot.Width <= 0 || plot.Height <= 0)
+ return [];
+
+ const double height = 22;
+ const double gutter = 24;
+ const double inset = 5;
+ var sorted = new Anchor[anchors.Count];
+ for (var i = 0; i < sorted.Length; i++)
+ sorted[i] = anchors[i];
+ Array.Sort(sorted, static (a, b) =>
+ {
+ var order = a.Y.CompareTo(b.Y);
+ return order != 0 ? order : a.Index.CompareTo(b.Index);
+ });
+
+ var rightSpace = plot.Right - cursorX - gutter - inset;
+ var leftSpace = cursorX - plot.Left - gutter - inset;
+ var maxWidth = 0.0;
+ for (var i = 0; i < sorted.Length; i++)
+ maxWidth = Math.Max(maxWidth, sorted[i].Width);
+ var labelRight = rightSpace >= maxWidth ||
+ (rightSpace >= leftSpace && leftSpace < maxWidth);
+ var widthLimit = Math.Max(35, Math.Min(plot.Width - inset * 2,
+ labelRight ? rightSpace : leftSpace));
+
+ var topMin = plot.Top + inset;
+ var bottomMax = plot.Bottom - inset;
+ var gap = Math.Max(0, Math.Min(5.0, (bottomMax - topMin - sorted.Length * height) /
+ Math.Max(1, sorted.Length - 1)));
+ var positions = new double[sorted.Length];
+ for (var i = 0; i < sorted.Length; i++)
+ {
+ var proposed = Math.Clamp(sorted[i].Y - height / 2, topMin,
+ Math.Max(topMin, bottomMax - height));
+ positions[i] = i == 0 ? proposed : Math.Max(proposed, positions[i - 1] + height + gap);
+ }
+ // Move any overflowing group upwards, preserving order and separation.
+ if (positions[^1] + height > bottomMax)
+ {
+ positions[^1] = bottomMax - height;
+ for (var i = positions.Length - 2; i >= 0; i--)
+ positions[i] = Math.Min(positions[i], positions[i + 1] - height - gap);
+ }
+
+ var output = new Placement[sorted.Length];
+ for (var i = 0; i < sorted.Length; i++)
+ {
+ var width = Math.Min(sorted[i].Width, widthLimit);
+ // Keep labels in the plot even when the cursor approaches a boundary.
+ var x = labelRight
+ ? Math.Min(cursorX + gutter, plot.Right - inset - width)
+ : Math.Max(plot.Left + inset, cursorX - gutter - width);
+ output[i] = new Placement(sorted[i].Index,
+ new Rect(x, Math.Max(topMin, positions[i]), width, height), labelRight);
+ }
+ return output;
+ }
+}
diff --git a/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs b/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs
index 1b67aff11..363b14cd4 100644
--- a/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs
+++ b/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs
@@ -32,15 +32,24 @@ public sealed class WaveformPanelControl : FrameworkElement
private static readonly Pen ZeroPen = CreatePen("#A1B4CA", 1.35);
private static readonly Pen HighlightPen = CreatePen("#EDF3FA", 1.0);
private static readonly Brush AxisTextBrush = CreateBrush("#7C8DA4");
- private static readonly Pen CursorPen = CreatePen("#2563EB", 1.4);
- private static readonly Brush CursorDotBrush = CreateBrush("#2865C7");
- private static readonly Brush CursorFillBrush = CreateBrush("#E8F1FF");
- private static readonly Pen CursorBorderPen = CreatePen("#2865C7", 1.0);
+ // Soft vertical guide; phase-specific leader arrows carry the information.
+ // All drawing resources are frozen and shared between render frames.
+ private static readonly Pen CursorPen = CreatePen("#8AA0BA", 1.05, [3.0, 4.0]);
+ private static readonly Brush CursorLabelSurface = CreateBrush("#FFFFFF");
+ private static readonly Pen CursorLabelFrame = CreatePen("#9EB5D4", 1.15);
+ private static readonly Pen CursorRedLeader = CreateFrozenSeriesPen(UaBrush, 1.4);
+ private static readonly Pen CursorGoldLeader = CreateFrozenSeriesPen(UbBrush, 1.4);
+ private static readonly Pen CursorBlueLeader = CreateFrozenSeriesPen(UcBrush, 1.4);
+ private static readonly Pen CursorNeutralLeader = CreateFrozenSeriesPen(NeutralBrush, 1.4);
private static readonly Dictionary SeriesPenCache = new(StringComparer.OrdinalIgnoreCase);
private static readonly Typeface TitleTypeface = new((FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
- private static readonly Typeface LabelTypeface = new((FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal);
+ private static readonly Typeface LabelTypeface = new((FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily"), FontStyles.Normal, FontWeights.Medium, FontStretches.Normal);
+ private static readonly Typeface CalloutTypeface = new((FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
private double _cursorFraction = 0.5;
private bool _isDraggingCursor;
+ private bool _cursorVisible;
+ private int _cursorLane = -1;
+ private int _cursorSampleIndex = -1;
private double _voltageAutoPeak;
private double _currentAutoPeak;
private DateTime _voltagePeakUpdatedUtc = DateTime.MinValue;
@@ -234,11 +243,27 @@ protected override void OnRender(DrawingContext dc)
var showVoltage = !string.Equals(ShowMode, "Current", StringComparison.OrdinalIgnoreCase);
var showCurrent = !string.Equals(ShowMode, "Voltage", StringComparison.OrdinalIgnoreCase);
+ // Legacy DigSub groups are named after Ch1–4 and Ch5–8, not 9-2LE
+ // semantics. In the explicit 9-2LE profile, Ch5–8 is Ua/Ub/Uc/Un
+ // and MUST occupy the top pane; Ch1–4 (Ia/Ib/Ic/In) is below it.
+ // RAW preserves its original unverified group A/B order.
+ var nineTwoLe = snapshot.CurrentSeries.FirstOrDefault()?.Unit == "V" &&
+ snapshot.VoltageSeries.FirstOrDefault()?.Unit == "A";
var visibleLanes = new List(2);
- if (showVoltage)
- visibleLanes.Add(new LaneDefinition(snapshot.VoltageSeries.FirstOrDefault()?.Unit == "A" ? "CURRENT · Ch1–4" : "RAW Ch1–4", snapshot.VoltageSeries.FirstOrDefault()?.Unit ?? "counts", snapshot.VoltageSeries));
- if (showCurrent)
- visibleLanes.Add(new LaneDefinition(snapshot.CurrentSeries.FirstOrDefault()?.Unit == "V" ? "VOLTAGE · Ch5–8" : "RAW Ch5–8", snapshot.CurrentSeries.FirstOrDefault()?.Unit ?? "counts", snapshot.CurrentSeries));
+ if (nineTwoLe)
+ {
+ if (showCurrent)
+ visibleLanes.Add(new LaneDefinition("VOLTAGE", "V", snapshot.CurrentSeries));
+ if (showVoltage)
+ visibleLanes.Add(new LaneDefinition("CURRENT", "A", snapshot.VoltageSeries));
+ }
+ else
+ {
+ if (showVoltage)
+ visibleLanes.Add(new LaneDefinition("RAW Ch1–4", "counts", snapshot.VoltageSeries));
+ if (showCurrent)
+ visibleLanes.Add(new LaneDefinition("RAW Ch5–8", "counts", snapshot.CurrentSeries));
+ }
if (visibleLanes.Count == 0)
{
@@ -252,15 +277,15 @@ protected override void OnRender(DrawingContext dc)
var laneWidth = Math.Max(120, bounds.Width - (outerPadding * 2));
var top = outerPadding;
- foreach (var lane in visibleLanes)
+ for (var laneIndex = 0; laneIndex < visibleLanes.Count; laneIndex++)
{
var laneRect = new Rect(outerPadding, top, laneWidth, laneHeight);
- DrawLane(dc, laneRect, lane, snapshot);
+ DrawLane(dc, laneRect, visibleLanes[laneIndex], snapshot, laneIndex);
top += laneHeight + laneGap;
}
}
- private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, WaveformSnapshot snapshot)
+ private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, WaveformSnapshot snapshot, int laneIndex)
{
dc.DrawRoundedRectangle(PlotBackgroundBrush, BorderPen, rect, 10, 10);
@@ -268,7 +293,8 @@ private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, Wavefor
dc.DrawRoundedRectangle(LaneHeaderBrush, null, headerRect, 10, 10);
dc.DrawRectangle(LaneHeaderBrush, null, new Rect(rect.Left, rect.Top + 15, rect.Width, 15));
- var innerRect = new Rect(rect.Left + 12, rect.Top + 38, rect.Width - 24, rect.Height - 50);
+ // Dedicated time-axis footer prevents tick labels overlapping live traces.
+ var innerRect = new Rect(rect.Left + 12, rect.Top + 38, rect.Width - 24, rect.Height - 74);
if (innerRect.Width <= 20 || innerRect.Height <= 20)
return;
@@ -310,8 +336,15 @@ private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, Wavefor
dc.DrawText(held, new Point(rect.Right - held.Width - 12, rect.Top + 8));
}
- DrawGrid(dc, innerRect);
- DrawYAxisScale(dc, innerRect, amplitudeMax, lane.Unit);
+ var stacked = string.Equals(LayoutMode, "Stacked", StringComparison.OrdinalIgnoreCase);
+ // Neutral at exactly zero is not a fourth *phase waveform*; retain its
+ // legend and measurements, but do not waste a Stacked band on a flat line.
+ var plotSeries = laneSeries
+ .Where(item => item.Samples.Count >= 2 && !IsSilentZeroTrace(item.Samples))
+ .ToArray();
+ DrawGrid(dc, innerRect, stacked, plotSeries.Length);
+ DrawMillisecondsAxis(dc, innerRect, snapshot);
+ DrawYAxisScale(dc, innerRect, amplitudeMax, lane.Unit, stacked, plotSeries.Length);
if (laneSeries.Count == 0 || laneSeries.All(item => item.Samples.Count < 2))
{
@@ -319,21 +352,19 @@ private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, Wavefor
return;
}
- var stacked = string.Equals(LayoutMode, "Stacked", StringComparison.OrdinalIgnoreCase);
- for (var index = 0; index < laneSeries.Count; index++)
+ for (var index = 0; index < plotSeries.Length; index++)
{
- var trace = laneSeries[index];
+ var trace = plotSeries[index];
dc.PushOpacity(TraceOpacity(IsStreamLive, trace.Freshness));
- DrawSeries(dc, innerRect, trace, amplitudeMax, stacked, index, laneSeries.Count);
+ DrawSeries(dc, innerRect, trace, amplitudeMax, stacked, index, plotSeries.Length);
dc.Pop();
}
- // Inspection overlay is interactive only: never obstruct live curves
- // with a permanent cursor/tooltip when the operator is not inspecting.
- if (MarkersEnabled && _isDraggingCursor)
- DrawCursor(dc, innerRect, laneSeries, amplitudeMax, stacked, snapshot);
+ if (MarkersEnabled && _cursorVisible && _cursorLane == laneIndex)
+ DrawCursor(dc, innerRect, plotSeries, amplitudeMax, stacked, snapshot);
- DrawTimebaseLabel(dc, innerRect, snapshot);
+ if (laneIndex == 0)
+ DrawTimebaseLabel(dc, innerRect, snapshot);
}
private IReadOnlyList BuildVisibleSeries(
@@ -722,51 +753,58 @@ private int CalculateVisibleSamples(WaveformSnapshot snapshot)
return samplesPerCycle * cycles;
}
- private void DrawGrid(DrawingContext dc, Rect rect)
+ private static void DrawGrid(DrawingContext dc, Rect rect, bool stacked, int traces)
{
- // vertical minor grid
- for (var i = 1; i < 10; i++)
+ // Frozen pens and lightweight subdivisions; never remove the actual 0 axis.
+ // X-grid lines are the real 5ms time ticks, drawn separately from
+ // the sample-rate evidence; no arbitrary 10-column pseudo-time grid.
+
+ // Stacked gets one mathematically correct baseline per *visible phase*.
+ // Overlay gets exactly one shared baseline at Y=0.
+ if (stacked && traces > 0)
{
- var x = rect.Left + (rect.Width * i / 10.0);
- var pen = i == 5 ? MajorGridPen : GridPen;
- dc.DrawLine(pen, new Point(x, rect.Top), new Point(x, rect.Bottom));
+ var band = rect.Height / traces;
+ var zeroLabel = CreateText("0", 9.6, AxisTextBrush, LabelTypeface);
+ for (var i = 0; i < traces; i++)
+ {
+ var y = rect.Top + (i + 0.5) * band;
+ dc.DrawLine(ZeroPen, new Point(rect.Left, y), new Point(rect.Right, y));
+ dc.DrawText(zeroLabel, new Point(rect.Left + 3, y - 13));
+ }
+ for (var i = 1; i < traces; i++)
+ {
+ var y = rect.Top + i * band;
+ dc.DrawLine(GridPen, new Point(rect.Left, y), new Point(rect.Right, y));
+ }
}
-
- // horizontal grid
- for (var i = 1; i < 4; i++)
+ else
{
- var y = rect.Top + (rect.Height * i / 4.0);
- var pen = i == 2 ? ZeroPen : GridPen;
- dc.DrawLine(pen, new Point(rect.Left, y), new Point(rect.Right, y));
+ for (var i = 1; i < 4; i += 2)
+ {
+ var y = rect.Top + rect.Height * i / 4.0;
+ dc.DrawLine(GridPen, new Point(rect.Left, y), new Point(rect.Right, y));
+ }
+ var zeroY = rect.Top + rect.Height / 2.0;
+ dc.DrawLine(ZeroPen, new Point(rect.Left, zeroY), new Point(rect.Right, zeroY));
+ dc.DrawText(CreateText("0", 9.6, AxisTextBrush, LabelTypeface),
+ new Point(rect.Left + 3, zeroY - 13));
}
- var zeroY = rect.Top + (rect.Height / 2.0);
-
- // top/bottom subtle frame
dc.DrawLine(HighlightPen, new Point(rect.Left, rect.Top + 1), new Point(rect.Right, rect.Top + 1));
dc.DrawLine(HighlightPen, new Point(rect.Left, rect.Bottom - 1), new Point(rect.Right, rect.Bottom - 1));
-
- // zero axis stronger
- dc.DrawLine(ZeroPen, new Point(rect.Left, zeroY), new Point(rect.Right, zeroY));
}
- private static void DrawYAxisScale(DrawingContext dc, Rect rect, double peak, string unit)
+ private static void DrawYAxisScale(DrawingContext dc, Rect rect, double peak, string unit, bool stacked, int traces)
{
- if (peak <= 0)
- return;
+ if (peak <= 0 || stacked)
+ return; // stacked uses per-phase zero axes; global ±peak labels are misleading.
var topText = FormatAxisLabelSigned(peak, unit, positive: true);
- var zeroText = "0";
var bottomText = FormatAxisLabelSigned(peak, unit, positive: false);
-
var top = CreateText(topText, 10.2, AxisTextBrush, LabelTypeface);
- var zero = CreateText(zeroText, 10.2, AxisTextBrush, LabelTypeface);
var bottom = CreateText(bottomText, 10.2, AxisTextBrush, LabelTypeface);
-
var x = rect.Left + 4;
-
dc.DrawText(top, new Point(x, rect.Top + 4));
- dc.DrawText(zero, new Point(x, rect.Top + (rect.Height / 2.0) - (zero.Height / 2.0)));
dc.DrawText(bottom, new Point(x, rect.Bottom - bottom.Height - 4));
}
@@ -788,6 +826,12 @@ private void DrawSeries(DrawingContext dc, Rect rect, WaveformSeriesModel series
if (series.Samples.Count < 2)
return;
+ // Zero neutral/disabled inputs should not impersonate a permanently
+ // enabled 0-axis when phase traces are overlaid. Keep the channel in
+ // measurements/cursor evidence; only the redundant flat ink is omitted.
+ if (!stacked && IsSilentZeroTrace(series.Samples))
+ return;
+
var geometry = new StreamGeometry();
using var ctx = geometry.Open();
@@ -818,6 +862,16 @@ private void DrawSeries(DrawingContext dc, Rect rect, WaveformSeriesModel series
}
+ private static bool IsSilentZeroTrace(IReadOnlyList samples)
+ {
+ // Bounded one-pass numeric check. No allocations and no tolerance that
+ // could erase a physically small but nonzero engineering signal.
+ for (var i = 0; i < samples.Count; i++)
+ if (samples[i] != 0.0)
+ return false;
+ return true;
+ }
+
private static void DrawLaneShapeBadge(DrawingContext dc, Rect rect, IReadOnlyList series)
{
var quality = ResolveLaneShape(series);
@@ -933,6 +987,14 @@ private static double GetLastY(Rect rect, IReadOnlyList samples, double
return GetPoint(rect, samples, amplitudeMax, stacked, index, totalSeries, samples.Count - 1).Y;
}
+ private static Pen GetCursorLeaderPen(string name) => name switch
+ {
+ "Ua" or "Ia" or "Ch1" or "Ch5" => CursorRedLeader,
+ "Ub" or "Ib" or "Ch2" or "Ch6" => CursorGoldLeader,
+ "Uc" or "Ic" or "Ch3" or "Ch7" => CursorBlueLeader,
+ _ => CursorNeutralLeader
+ };
+
private void DrawCursor(
DrawingContext dc,
Rect rect,
@@ -941,104 +1003,177 @@ private void DrawCursor(
bool stacked,
WaveformSnapshot snapshot)
{
- var maxSamples = series.Select(item => item.Samples.Count).DefaultIfEmpty(0).Max();
- if (maxSamples < 2)
+ if (series.Count == 0)
return;
- var sampleIndex = (int)Math.Round(_cursorFraction * (maxSamples - 1));
- sampleIndex = Math.Clamp(sampleIndex, 0, maxSamples - 1);
+ // The cursor is snapped to the visible plot position, not to a
+ // separately sampled or fabricated set of amplitudes.
+ var count = 0;
+ foreach (var item in series)
+ count = Math.Max(count, item.Samples.Count);
+ if (count < 2)
+ return;
- var x = rect.Left + (rect.Width * sampleIndex / (maxSamples - 1.0));
+ var sampleIndex = (int)Math.Round(_cursorFraction * (count - 1));
+ var x = rect.Left + rect.Width * sampleIndex / (count - 1.0);
dc.DrawLine(CursorPen, new Point(x, rect.Top), new Point(x, rect.Bottom));
- dc.DrawEllipse(CursorDotBrush, null, new Point(x, rect.Top + 7), 3.8, 3.8);
-
- var sampleRate = snapshot.SampleRateHz > 0 ? snapshot.SampleRateHz : 4000.0;
- var timeMs = sampleIndex / sampleRate * 1000.0;
- var freqText = snapshot.MeasuredFrequencyHz > 0
- ? $"{snapshot.MeasuredFrequencyHz:0.###} Hz"
- : "freq N/A";
-
- var chips = new List<(string Text, Brush Brush)>
- {
- ($"t={timeMs:0.###} ms", LabelBrush),
- ($"n={sampleIndex}", LabelBrush),
- (freqText, LabelBrush)
- };
-
- foreach (var item in series)
+ // Compute the *exact line-segment intersection* for each plotted phase.
+ // The same 0.37 vertical transform is used by DrawSeries. This is not
+ // a new DSP sample; the displayed value is explicitly a linear
+ // interpolation between adjacent plotted vertices at the cursor.
+ var anchors = new List(series.Count);
+ var texts = new List(series.Count);
+ var colors = new List(series.Count);
+ for (var index = 0; index < series.Count; index++)
{
- if (item.Samples.Count == 0)
+ var item = series[index];
+ if (item.Samples.Count < 2)
continue;
- var localIndex = Math.Clamp(sampleIndex, 0, item.Samples.Count - 1);
- chips.Add(($"{item.Name}={FormatEngineeringValue(item.Samples[localIndex], item.Unit)}", GetSeriesBrush(item.Name)));
- }
-
- var paddingX = 8.0;
- var paddingY = 5.0;
- var gap = 6.0;
-
- var chipLayouts = chips
- .Select(chip =>
- {
- var text = CreateText(chip.Text, 10.8, chip.Brush, LabelTypeface);
- var width = text.Width + (paddingX * 2);
- var height = text.Height + (paddingY * 2);
- return (chip.Text, chip.Brush, Text: text, Width: width, Height: height);
- })
- .ToArray();
-
- var totalWidth = chipLayouts.Sum(c => c.Width) + (gap * Math.Max(0, chipLayouts.Length - 1)) + 12;
- var maxHeight = chipLayouts.Select(c => c.Height).DefaultIfEmpty(24).Max() + 8;
-
- var boxWidth = Math.Min(rect.Width - 8, totalWidth);
- var boxX = Math.Clamp(x + 10, rect.Left + 4, rect.Right - boxWidth - 4);
- var boxY = rect.Top - (maxHeight + 6);
-
- // kalau terlalu atas, fallback ke dalam
- if (boxY < 4)
- boxY = rect.Top + 6;
-
- var box = new Rect(boxX, boxY, boxWidth, maxHeight);
-
- dc.DrawRoundedRectangle(CloneBrushOpacity(CursorFillBrush, 0.92), CursorBorderPen, box, 8, 8);
+ var samplePosition = sampleIndex / (count - 1.0) * (item.Samples.Count - 1);
+ var a = (int)Math.Floor(samplePosition);
+ var b = Math.Min(a + 1, item.Samples.Count - 1);
+ var t = samplePosition - a;
+ var value = item.Samples[a] + (item.Samples[b] - item.Samples[a]) * t;
+ if (!double.IsFinite(value))
+ continue;
- var cx = box.Left + 6;
- var cy = box.Top + 4;
+ var bandHeight = stacked ? rect.Height / series.Count : rect.Height;
+ var bandCenter = rect.Top + (stacked ? (index + 0.5) * bandHeight : rect.Height / 2.0);
+ var y = bandCenter - (amplitudeMax > 0 ? value / amplitudeMax : 0.0)
+ * bandHeight * 0.37;
+ y = Math.Clamp(y, rect.Top, rect.Bottom);
+
+ var color = GetSeriesBrush(item.Name);
+ var formatted = CreateText(
+ item.Name + " " + FormatEngineeringValue(value, item.Unit),
+ 12.0, color, CalloutTypeface);
+ anchors.Add(new WaveformCursorCalloutLayout.Anchor(texts.Count, y, formatted.Width + 17));
+ texts.Add(formatted);
+ colors.Add(GetCursorLeaderPen(item.Name));
+ }
- foreach (var chip in chipLayouts)
+ var placements = WaveformCursorCalloutLayout.Arrange(rect, x, anchors);
+ // First paint leader lines, then the compact labels on top. The arrowhead
+ // tip sits precisely on the vertical cursor / phase-wave intersection.
+ foreach (var placement in placements)
{
- if (cx + chip.Width > box.Right - 4)
- break;
-
- var chipRect = new Rect(cx, cy, chip.Width, chip.Height);
- dc.DrawRoundedRectangle(CloneBrushOpacity(chip.Brush, 0.16), null, chipRect, 7, 7);
+ var tip = new Point(x, anchors[placement.Index].Y);
+ var start = new Point(placement.LabelRight ? placement.Box.Left : placement.Box.Right,
+ placement.Box.Top + placement.Box.Height / 2.0);
+ DrawCalloutArrow(dc, colors[placement.Index], start, tip);
+ }
- dc.DrawEllipse(chip.Brush, null, new Point(chipRect.Left + 8, chipRect.Top + chipRect.Height / 2), 3.2, 3.2);
- dc.DrawText(chip.Text, new Point(chipRect.Left + 14, chipRect.Top + paddingY));
+ foreach (var placement in placements)
+ {
+ var box = placement.Box;
+ dc.DrawRoundedRectangle(CursorLabelSurface, CursorLabelFrame, box, 5, 5);
+ dc.DrawText(texts[placement.Index],
+ new Point(box.Left + 8, box.Top + (box.Height - texts[placement.Index].Height) / 2.0));
+ }
- cx += chip.Width + gap;
+ // One small time caption, never a row of chips over the phase legend.
+ if (snapshot.SampleRateHz > 0 && double.IsFinite(snapshot.SampleRateHz))
+ {
+ var maxSourceSamples = 2;
+ foreach (var item in snapshot.VoltageSeries)
+ maxSourceSamples = Math.Max(maxSourceSamples, item.Samples.Count);
+ foreach (var item in snapshot.CurrentSeries)
+ maxSourceSamples = Math.Max(maxSourceSamples, item.Samples.Count);
+ var windowSamples = Math.Min(CalculateVisibleSamples(snapshot), maxSourceSamples);
+ var referenceSample = SmvWaveformTimeAxis.ReferenceSample(snapshot, windowSamples);
+ var timeMs = (_cursorFraction * Math.Max(0, windowSamples - 1) - referenceSample) *
+ 1000.0 / snapshot.SampleRateHz;
+ var caption = CreateText("t " + timeMs.ToString("0.00", CultureInfo.InvariantCulture) + " ms",
+ 10.1, LabelBrush, LabelTypeface);
+ var width = caption.Width + 12;
+ var left = Math.Clamp(x - width / 2, rect.Left + 4,
+ Math.Max(rect.Left + 4, rect.Right - width - 4));
+ var badge = new Rect(left, rect.Bottom - 23, width, 20);
+ dc.DrawRoundedRectangle(CursorLabelSurface, CursorLabelFrame, badge, 4, 4);
+ dc.DrawText(caption, new Point(badge.Left + 6, badge.Top + 2));
}
}
+ private static void DrawCalloutArrow(DrawingContext dc, Pen pen, Point start, Point tip)
+ {
+ var dx = tip.X - start.X;
+ var dy = tip.Y - start.Y;
+ var length = Math.Sqrt(dx * dx + dy * dy);
+ if (length < 1.0)
+ return;
+ var ux = dx / length;
+ var uy = dy / length;
+ dc.DrawLine(pen, start, tip);
+ // Directional open-arrow tips (no marker dot) terminate on the signal.
+ const double arrowLength = 6.5;
+ const double arrowHalfWidth = 3.0;
+ var back = new Point(tip.X - ux * arrowLength, tip.Y - uy * arrowLength);
+ dc.DrawLine(pen, tip, new Point(back.X - uy * arrowHalfWidth, back.Y + ux * arrowHalfWidth));
+ dc.DrawLine(pen, tip, new Point(back.X + uy * arrowHalfWidth, back.Y - ux * arrowHalfWidth));
+ }
+
private static string FormatEngineeringValue(double value, string unit)
{
+ // Cursor precision is diagnostic, but concise: small engineering
+ // signals must not disappear when rounded to one decimal place.
if (string.Equals(unit, "V", StringComparison.OrdinalIgnoreCase) && Math.Abs(value) >= 1000.0)
- return $"{value / 1000.0:0.##} kV";
+ return $"{value / 1000.0:0.###} kV";
if (string.Equals(unit, "A", StringComparison.OrdinalIgnoreCase) && Math.Abs(value) >= 1000.0)
- return $"{value / 1000.0:0.##} kA";
+ return $"{value / 1000.0:0.###} kA";
- return $"{value:0.#} {unit}";
+ return $"{value:0.###} {unit}";
+ }
+
+ private void DrawMillisecondsAxis(DrawingContext dc, Rect plot, WaveformSnapshot snapshot)
+ {
+ if (!(snapshot.SampleRateHz > 0) || !double.IsFinite(snapshot.SampleRateHz))
+ return; // Do not invent milliseconds from an untrusted sampling rate.
+ var visible = Math.Min(CalculateVisibleSamples(snapshot),
+ SmvWaveformTimeAxis.AvailableSamples(snapshot));
+ if (visible < 2)
+ return;
+
+ var reference = SmvWaveformTimeAxis.ReferenceSample(snapshot, visible);
+ var duration = (visible - 1.0) * 1000.0 / snapshot.SampleRateHz;
+ if (duration <= 0 || !double.IsFinite(duration))
+ return;
+ var startMs = -reference * 1000.0 / snapshot.SampleRateHz;
+ var major = plot.Width * 5.0 / duration >= 48.0 ? 5.0 : 10.0;
+ var firstTick = (int)Math.Ceiling(startMs / major);
+ var lastTick = (int)Math.Floor((startMs + duration) / major);
+ for (var index = firstTick; index <= lastTick && index - firstTick < 80; index++)
+ {
+ var ms = index * major;
+ var x = plot.Left + (ms - startMs) / duration * plot.Width;
+ dc.DrawLine(MajorGridPen, new Point(x, plot.Top), new Point(x, plot.Bottom));
+ dc.DrawLine(ZeroPen, new Point(x, plot.Bottom), new Point(x, plot.Bottom + 5));
+ var label = CreateText(ms.ToString("+0.##;-0.##;0", CultureInfo.InvariantCulture) + " ms",
+ 10.1, AxisTextBrush, LabelTypeface);
+ if (x + label.Width / 2 < plot.Right - 3 && x - label.Width / 2 > plot.Left + 3)
+ dc.DrawText(label, new Point(x - label.Width / 2, plot.Bottom + 7));
+ }
}
private static void DrawTimebaseLabel(DrawingContext dc, Rect rect, WaveformSnapshot snapshot)
{
- var sampleRateText = snapshot.SampleRateHz > 0 ? $"{snapshot.SampleRateHz:0.#} Hz sample" : "Sample rate pending";
- var frequencyText = snapshot.MeasuredFrequencyHz > 0 ? $"{snapshot.MeasuredFrequencyHz:0.###} Hz signal" : "Signal frequency pending";
- dc.DrawText(CreateText(sampleRateText, 10.5, MutedBrush, LabelTypeface), new Point(rect.Left, rect.Bottom + 3));
- dc.DrawText(CreateText(frequencyText, 10.5, MutedBrush, LabelTypeface), new Point(rect.Right - 106, rect.Bottom + 3));
+ // Share this technical caption once, never duplicate pending text.
+ if (snapshot.SampleRateHz > 0 && double.IsFinite(snapshot.SampleRateHz))
+ {
+ var rate = snapshot.SampleRateHz >= 1000
+ ? (snapshot.SampleRateHz / 1000.0).ToString("0.###", CultureInfo.InvariantCulture) + " kS/s"
+ : snapshot.SampleRateHz.ToString("0.#", CultureInfo.InvariantCulture) + " S/s";
+ dc.DrawText(CreateText(rate, 10.3, MutedBrush, LabelTypeface),
+ new Point(rect.Left, rect.Bottom + 23));
+ }
+ if (snapshot.MeasuredFrequencyHz > 0 && double.IsFinite(snapshot.MeasuredFrequencyHz))
+ {
+ var label = CreateText(snapshot.MeasuredFrequencyHz.ToString("0.##",
+ CultureInfo.InvariantCulture) + " Hz", 10.3, MutedBrush, LabelTypeface);
+ dc.DrawText(label, new Point(rect.Right - label.Width, rect.Bottom + 23));
+ }
}
// Presentation only: no fade animations/timers or per-sample state.
@@ -1121,24 +1256,29 @@ private static Pen CreatePen(string hex, double thickness, double[]? dash = null
protected override void OnMouseDown(MouseButtonEventArgs e)
{
base.OnMouseDown(e);
- if (e.ChangedButton != MouseButton.Left)
+ if (e.ChangedButton != MouseButton.Left || !MarkersEnabled)
return;
-
Focus();
- CaptureMouse();
_isDraggingCursor = true;
- UpdateCursorPosition(e.GetPosition(this).X);
+ CaptureMouse();
+ UpdateCursorPosition(e.GetPosition(this));
e.Handled = true;
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
- if (!_isDraggingCursor)
- return;
+ // Passive hover costs nothing until the nearest *source sample* changes.
+ // The control has at most two lanes and already owns this drawing surface.
+ if (MarkersEnabled)
+ UpdateCursorPosition(e.GetPosition(this));
+ }
- UpdateCursorPosition(e.GetPosition(this).X);
- e.Handled = true;
+ protected override void OnMouseLeave(MouseEventArgs e)
+ {
+ base.OnMouseLeave(e);
+ if (!_isDraggingCursor)
+ HideCursor();
}
protected override void OnMouseUp(MouseButtonEventArgs e)
@@ -1146,18 +1286,85 @@ protected override void OnMouseUp(MouseButtonEventArgs e)
base.OnMouseUp(e);
if (e.ChangedButton != MouseButton.Left)
return;
+ _isDraggingCursor = false;
+ if (IsMouseCaptured)
+ ReleaseMouseCapture();
+ UpdateCursorPosition(e.GetPosition(this));
+ e.Handled = true;
+ }
+ protected override void OnLostMouseCapture(MouseEventArgs e)
+ {
+ base.OnLostMouseCapture(e);
_isDraggingCursor = false;
- ReleaseMouseCapture();
+ if (!IsMouseOver)
+ HideCursor();
+ }
+
+ private void HideCursor()
+ {
+ if (!_cursorVisible) return;
+ _cursorVisible = false;
+ _cursorLane = -1;
+ _cursorSampleIndex = -1;
InvalidateVisual();
- e.Handled = true;
}
- private void UpdateCursorPosition(double x)
+ private void UpdateCursorPosition(Point mouse)
{
- const double horizontalPadding = 22.0;
- var width = Math.Max(1.0, ActualWidth - (horizontalPadding * 2.0));
- _cursorFraction = Math.Clamp((x - horizontalPadding) / width, 0.0, 1.0);
+ var snapshot = Snapshot;
+ if (snapshot is null)
+ {
+ HideCursor();
+ return;
+ }
+
+ const double outerPadding = 10;
+ const double laneGap = 12;
+ const double plotInsetX = 12;
+ var availableWidth = ActualWidth - 2 * (outerPadding + plotInsetX);
+ var showBoth = string.Equals(ShowMode, "Both", StringComparison.OrdinalIgnoreCase);
+ var lanes = showBoth ? 2 : 1;
+ var laneHeight = Math.Max(110, (ActualHeight - 2 * outerPadding - laneGap * (lanes - 1)) / lanes);
+
+ if (availableWidth <= 20 || mouse.X < outerPadding + plotInsetX ||
+ mouse.X > ActualWidth - outerPadding - plotInsetX)
+ {
+ HideCursor();
+ return;
+ }
+ var lane = -1;
+ for (var i = 0; i < lanes; i++)
+ {
+ var laneTop = outerPadding + i * (laneHeight + laneGap);
+ if (mouse.Y >= laneTop + 38 && mouse.Y <= laneTop + laneHeight - 12)
+ {
+ lane = i;
+ break;
+ }
+ }
+ if (lane < 0)
+ {
+ HideCursor();
+ return;
+ }
+
+ var fraction = Math.Clamp((mouse.X - outerPadding - plotInsetX) / availableWidth, 0.0, 1.0);
+ // The pointer hot path must not create LINQ enumerators or temporary
+ // arrays; received data is already bounded and read-only.
+ var count = 2;
+ foreach (var series in snapshot.VoltageSeries)
+ count = Math.Max(count, series.Samples.Count);
+ foreach (var series in snapshot.CurrentSeries)
+ count = Math.Max(count, series.Samples.Count);
+ var sampleIndex = (int)Math.Round(fraction * (count - 1));
+ if (_cursorVisible && _cursorLane == lane && _cursorSampleIndex == sampleIndex)
+ return; // sample quantization avoids per-pixel redraw on high-rate mouse moves.
+
+ _cursorFraction = fraction;
+ _cursorVisible = true;
+ _cursorLane = lane;
+ _cursorSampleIndex = sampleIndex;
InvalidateVisual();
}
diff --git a/Resources/ArvrelMiniIedFascia.xaml b/Resources/ArvrelMiniIedFascia.xaml
index 5e47e4a64..b491abd76 100644
--- a/Resources/ArvrelMiniIedFascia.xaml
+++ b/Resources/ArvrelMiniIedFascia.xaml
@@ -141,9 +141,9 @@
+ Foreground="White" FontFamily="{StaticResource AppFontFamily}" FontSize="11.5" FontWeight="Bold"/>
diff --git a/Views/SmvAnglePresentation.cs b/Views/SmvAnglePresentation.cs
new file mode 100644
index 000000000..6cb5acdf1
--- /dev/null
+++ b/Views/SmvAnglePresentation.cs
@@ -0,0 +1,28 @@
+using System.Globalization;
+
+namespace ArIED61850Tester.Views;
+
+///
+/// Pure presentation policy. DFT phase, measured displacement, and sample
+/// polarity are never changed: only human-readable angle formatting wraps
+/// values into [0°, 360°), with zero/360 rounding normalized to exactly 0°.
+///
+public static class SmvAnglePresentation
+{
+ public static double Normalize360(double degrees)
+ {
+ if (!double.IsFinite(degrees))
+ return double.NaN;
+ var angle = (degrees % 360.0 + 360.0) % 360.0;
+ var rounded = Math.Round(angle, 1, MidpointRounding.AwayFromZero);
+ return rounded >= 360.0 ? 0.0 : rounded;
+ }
+
+ public static string Format(double degrees)
+ {
+ var normalized = Normalize360(degrees);
+ return double.IsFinite(normalized)
+ ? "∠" + normalized.ToString("0.0", CultureInfo.InvariantCulture) + "°"
+ : "—";
+ }
+}
diff --git a/Views/SmvDigSubPresentationAdapter.cs b/Views/SmvDigSubPresentationAdapter.cs
index 6f9f487bf..e703ee560 100644
--- a/Views/SmvDigSubPresentationAdapter.cs
+++ b/Views/SmvDigSubPresentationAdapter.cs
@@ -27,7 +27,11 @@ public static WaveformSnapshot BuildWaveform(SmvSnapshotResult snapshot, SmvDisp
var channelUnits = SmvEngineeringDisplay.Describe(channel.ChannelIndex, profile);
var series = new WaveformSeriesModel
{
- Name = $"Ch{channel.ChannelIndex}", // fixed color identity shared with RAW
+ // The 9-2LE operator profile already supplies Ia/Ib/Ic/In and
+ // Ua/Ub/Uc/Un. Do not force beginners to cross-map "Ch1" in
+ // the plot against "Ia" in the measurement table.
+ Name = profile == SmvDisplayUnits.NineTwoLe
+ ? channelUnits.Name : $"Ch{channel.ChannelIndex}",
Unit = channelUnits.Unit,
Freshness = ChannelFreshness.Live,
Samples = channelUnits.Scale == 1 ? aligned : aligned.Select(v => v * channelUnits.Scale).ToArray(),
@@ -80,7 +84,10 @@ public static IReadOnlyList BuildPhasors(
IReadOnlyList vectors,
SmvDisplayUnits profile = SmvDisplayUnits.RawCounts)
{
- var reference = vectors.FirstOrDefault(v => v.ChannelIndex == 1 && v.HasFundamental)
+ // 9-2LE uses Ua/Ch5 as the voltage phase reference, matching standard
+ // three-phase instrument conventions. RAW retains Ch1 as reference.
+ var referenceChannel = profile == SmvDisplayUnits.NineTwoLe ? 5 : 1;
+ var reference = vectors.FirstOrDefault(v => v.ChannelIndex == referenceChannel && v.HasFundamental)
?? vectors.FirstOrDefault(v => v.HasFundamental);
if (reference is null)
return [];
@@ -108,9 +115,11 @@ public static IReadOnlyList BuildPhasors(
}
public static IReadOnlyList RelativeMeasurements(
- IReadOnlyList vectors)
+ IReadOnlyList vectors,
+ SmvDisplayUnits profile = SmvDisplayUnits.RawCounts)
{
- var anchor = vectors.FirstOrDefault(v => v.ChannelIndex == 1 && v.HasFundamental)
+ var referenceChannel = profile == SmvDisplayUnits.NineTwoLe ? 5 : 1;
+ var anchor = vectors.FirstOrDefault(v => v.ChannelIndex == referenceChannel && v.HasFundamental)
?? vectors.FirstOrDefault(v => v.HasFundamental);
if (anchor is null)
return vectors;
diff --git a/Views/SmvEngineeringDisplay.cs b/Views/SmvEngineeringDisplay.cs
index 5b4a3f982..7fe1fef8b 100644
--- a/Views/SmvEngineeringDisplay.cs
+++ b/Views/SmvEngineeringDisplay.cs
@@ -51,9 +51,23 @@ public sealed record SmvDisplayMeasurement(
double FundamentalRms, double PhaseDegrees, bool HasFundamental)
{
public string Label => $"Wire channel {ChannelIndex} · {DisplayName}";
- public string RmsText => Rms.ToString("0.###", CultureInfo.InvariantCulture);
- public string FundamentalRmsText => FundamentalRms.ToString("0.###", CultureInfo.InvariantCulture);
+ // The wire RMS remains the unmodified total RMS (including harmonics/DC).
+ // Auto-prefix affects presentation only, never engineering calculations.
+ public string RmsText => FormatMagnitude(Rms, Unit);
+ public string FundamentalRmsText => FormatMagnitude(FundamentalRms, Unit);
+ public string RmsDetail => HasFundamental
+ ? $"Total RMS: {RmsText} · fundamental (selected coherent timebase DFT): {FundamentalRmsText} · waveform distortion/DC may make these differ"
+ : $"Total RMS: {RmsText} · no stable fundamental phase";
public string AngleText => HasFundamental
- ? PhaseDegrees.ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture) + "°"
- : "—";
+ ? SmvAnglePresentation.Format(PhaseDegrees) : "—";
+
+ public static string FormatMagnitude(double value, string unit)
+ {
+ if (!double.IsFinite(value)) return "—";
+ if ((unit == "V" || unit == "A") && Math.Abs(value) >= 1000.0)
+ return (value / 1000.0).ToString("0.###", CultureInfo.InvariantCulture) +
+ (unit == "V" ? " kV" : " kA");
+ return value.ToString("0.###", CultureInfo.InvariantCulture) +
+ (unit is "V" or "A" ? " " + unit : string.Empty);
+ }
}
diff --git a/Views/SmvInlineViewer.xaml b/Views/SmvInlineViewer.xaml
index 7deb737cb..13559d76f 100644
--- a/Views/SmvInlineViewer.xaml
+++ b/Views/SmvInlineViewer.xaml
@@ -106,10 +106,13 @@
-
+
+
@@ -292,12 +295,12 @@
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="1"
CornerRadius="8">
-
+ ToolTip="{Binding StreamTechnicalDetail}" MaxHeight="95"/>
@@ -320,10 +323,24 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
+
-
+
+
@@ -389,7 +410,8 @@
+ IsStreamLive="{Binding IsLiveSnapshot}"
+ ToolTip="Voltage/current (or RAW A/B) arrows use separate magnitude scales. Full measurement precision is shown below."/>
@@ -397,10 +419,12 @@
-
-
+
+
+
+
@@ -409,51 +433,182 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -465,7 +620,7 @@
-
diff --git a/Views/SmvInlineViewer.xaml.cs b/Views/SmvInlineViewer.xaml.cs
index 848d45625..f2381c42c 100644
--- a/Views/SmvInlineViewer.xaml.cs
+++ b/Views/SmvInlineViewer.xaml.cs
@@ -2,12 +2,14 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Data;
using System.Windows.Media;
using System.Windows.Shapes;
using System.Windows.Threading;
using ArIED61850Tester.Services;
using ArIED61850Tester.Models;
using ProcessBus.Core.Models;
+using ProcessBus.App.Wpf.Controls;
namespace ArIED61850Tester.Views;
@@ -29,6 +31,11 @@ public partial class SmvInlineViewer : UserControl, INotifyPropertyChanged
private CancellationTokenSource? _captureCancellation;
private SmvLiveUpdate? _pendingUpdate;
private SmvSnapshotResult? _visibleSnapshot;
+ // Single in-flight analyzer, single pending latest snapshot. No task per
+ // packet or unbounded background queue, even under a saturated UI.
+ private PendingAnalysis? _pendingAnalysis;
+ private bool _analysisRunning;
+ private sealed record PendingAnalysis(SmvSnapshotResult Snapshot, string? SelectedKey, int Generation);
private IReadOnlyList _visibleVectors = [];
private string? _selectedKey;
private string _captureStatus = "Select an Ethernet adapter and press Start. No IED or SCL is required.";
@@ -41,12 +48,59 @@ public partial class SmvInlineViewer : UserControl, INotifyPropertyChanged
private bool _capturing;
private bool _loadingAdapters;
private bool _selectingProgrammatically;
+ private bool _awaitingPublisherSwitch;
+ private SmvDisplayUnits _lastViewProfile = SmvDisplayUnits.RawCounts;
+ private string? _lastUnitCaption;
+ private string? _lastVoltageHeading;
+ private string? _lastCurrentHeading;
+ private string? _lastReferenceHeading;
+ private string? _lastTimeAxisCaption;
+ private string? _lastSyncLabel;
+ private string? _lastSyncDetail;
+ private Visibility _lastPowerVisibility = Visibility.Collapsed;
+ private string _lastDisplayedPublisherKey = string.Empty;
private long _frameCount;
public event PropertyChangedEventHandler? PropertyChanged;
public ObservableCollection Adapters { get; } = [];
public ObservableCollection Streams { get; } = [];
- public ObservableCollection Measurements { get; } = [];
+ public ObservableCollection Measurements { get; } = [];
+ public ObservableCollection PowerPhases { get; } = [];
+ private SmvPowerMeasurements.Phase? _powerTotal;
+ public string PowerP => _powerTotal?.PText ?? "—";
+ public string PowerQ => _powerTotal?.QText ?? "—";
+ public string PowerS => _powerTotal?.SText ?? "—";
+ public string PowerPf => _powerTotal?.PfText ?? "—";
+ public Visibility PowerVisibility => ActiveUnits == SmvDisplayUnits.NineTwoLe && _powerTotal is not null
+ ? Visibility.Visible : Visibility.Collapsed;
+ public string PowerStatus => _powerTotal is null
+ ? "Power unavailable · requires clean 9-2LE U/I sample pairing"
+ : "P true · Q₁ fundamental · S true RMS · PF true (operator-selected U/I)";
+ public string PowerStatusShort => _powerTotal is null
+ ? "9-2LE required" : "True P · Q₁ fundamental";
+ public string PowerDetails => _powerTotal is null
+ ? "No power calculation for unverified RAW channels or incomplete snapshots."
+ : PowerStatus + "\n" + string.Join("\n", PowerPhases.Select(phase =>
+ $"{phase.Name}: P {phase.PText} Q₁ {phase.QText} S {phase.SText} PF {phase.PfText}"));
+ public string SyncLabel => SmvSyncPresentation.Label(_visibleSnapshot);
+ public string SyncDetail => _awaitingPublisherSwitch && _visibleSnapshot is not null
+ ? "HOLD · Previous publisher sync shown.\nNew publisher sync not yet known.\n" +
+ SmvSyncPresentation.Detail(_visibleSnapshot)
+ : SmvSyncPresentation.Detail(_visibleSnapshot);
+ public Brush SyncBackground => SmvSyncPresentation.Background(_visibleSnapshot);
+ public Brush SyncForeground => SmvSyncPresentation.Foreground(_visibleSnapshot);
+
+ // Two lightweight live views of the original bounded eight-row collection;
+ // no duplicate samples, DSP calculations or packet observations.
+ public ICollectionView VoltageMeasurements { get; }
+ public ICollectionView CurrentMeasurements { get; }
+
+ // The data grid's column header already supplies RMS and Angle;
+ // mapping and scale remain accessible by hovering either title.
+ public string VoltageGroupHeading => ActiveUnits == SmvDisplayUnits.NineTwoLe
+ ? "Voltage (V)" : "RAW · Ch1–4";
+ public string CurrentGroupHeading => ActiveUnits == SmvDisplayUnits.NineTwoLe
+ ? "Current (A)" : "RAW · Ch5–8";
// The original DigSub WPF controls repaint immutable models, not packet work.
public WaveformSnapshot? CurrentWaveform { get; private set; }
@@ -61,25 +115,78 @@ private SmvDisplayUnits RequestedUnits
private SmvDisplayUnits ActiveUnits
=> SmvEngineeringDisplay.Effective(_visibleSnapshot, RequestedUnits);
+ public string TimeAxisCaption
+ {
+ get
+ {
+ if (CurrentWaveform is not { } wave || wave.SampleRateHz <= 0)
+ return "Timebase —";
+ var count = Math.Min(2 * Math.Max(1, wave.SamplesPerCycle),
+ SmvWaveformTimeAxis.AvailableSamples(wave));
+ return ActiveUnits == SmvDisplayUnits.NineTwoLe &&
+ SmvWaveformTimeAxis.ReferenceSample(wave, count) > 0.01
+ ? "Ua = 0 ms"
+ : "Start = 0 ms";
+ }
+ }
+
public string PhaseReferenceText => ActiveUnits == SmvDisplayUnits.NineTwoLe
- ? "Ia (Ch1) · 0°" : "Ch1 · 0° reference";
+ ? "Ref: Ua" : "Ref: Ch1";
public string UnitStatus => RequestedUnits switch
{
- SmvDisplayUnits.RawCounts => "RAW counts · no verified U/I channel scale",
+ SmvDisplayUnits.RawCounts => "RAW counts · Voltage/Current grouping is unverified; 9-2LE profile: Ch1–4 ×0.001 A, Ch5–8 ×0.01 V (operator-selected)",
_ when ActiveUnits == SmvDisplayUnits.NineTwoLe
=> "9-2LE user profile · 1 mA / 10 mV per count · verify publisher DataSet",
_ => "9-2LE selected · need eight clean channels; displaying RAW counts"
};
public string StreamCountText => $"{Streams.Count:N0} detected";
- public string CaptureStatus { get => _captureStatus; private set => Change(ref _captureStatus, value, nameof(CaptureStatus)); }
+
+ // Instrument status stays concise; original diagnostics remain in tooltip
+ // and in the dedicated Diagnostics tab. Never conceal an actual HOLD/error.
+ public string CaptureBrief => _captureStatus switch
+ {
+ var status when status.StartsWith("HOLD", StringComparison.OrdinalIgnoreCase) =>
+ "HOLD · stream interrupted",
+ var status when status.Contains("failed", StringComparison.OrdinalIgnoreCase) ||
+ status.Contains("error", StringComparison.OrdinalIgnoreCase) =>
+ "SV capture error",
+ _ when _capturing && Streams.Count > 0 => $"LIVE · {Streams.Count} SV stream(s)",
+ _ when _capturing => "Searching for SV streams",
+ _ => "SMV idle"
+ };
+ private string? _lastCaptureBrief;
+ public string CaptureStatus
+ {
+ get => _captureStatus;
+ private set
+ {
+ if (_captureStatus == value) return;
+ Change(ref _captureStatus, value, nameof(CaptureStatus));
+ if (_lastCaptureBrief == CaptureBrief) return;
+ _lastCaptureBrief = CaptureBrief;
+ Notify(nameof(CaptureBrief));
+ }
+ }
public string WaveformStatus { get => _waveformStatus; private set => Change(ref _waveformStatus, value, nameof(WaveformStatus)); }
public string StreamDetail { get => _streamDetail; private set => Change(ref _streamDetail, value, nameof(StreamDetail)); }
+ private string _streamTechnicalDetail = "Choose an SV stream to inspect its publisher metadata.";
+ public string StreamTechnicalDetail { get => _streamTechnicalDetail; private set => Change(ref _streamTechnicalDetail, value, nameof(StreamTechnicalDetail)); }
public SmvInlineViewer()
{
InitializeComponent();
+ VoltageMeasurements = new ListCollectionView(Measurements)
+ {
+ Filter = item => item is SmvMeasurementLiveRow row &&
+ SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage
+ };
+ CurrentMeasurements = new ListCollectionView(Measurements)
+ {
+ Filter = item => item is SmvMeasurementLiveRow row &&
+ !SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage
+ };
DataContext = this;
_freshness = new DispatcherTimer(DispatcherPriority.Background, Dispatcher)
{
@@ -248,10 +355,28 @@ private void Stream_SelectionChanged(object sender, SelectionChangedEventArgs e)
if (_selectingProgrammatically)
return;
var stream = StreamList.SelectedItem as SmvLiveStreamSummary;
- Volatile.Write(ref _selectedKey, stream?.Key);
+ var key = stream?.Key;
+ if (key == Volatile.Read(ref _selectedKey))
+ return;
+
+ Volatile.Write(ref _selectedKey, key);
+ _pendingAnalysis = null;
Interlocked.Increment(ref _analysisGeneration);
- ClearPresentation(stream is null ? "Select a publisher to display samples."
- : $"Acquiring coherent samples from {stream.SvId}.");
+ if (stream is null)
+ {
+ ClearPresentation("Select a publisher to display samples.");
+ }
+ else
+ {
+ // Keep the last real waveform, phasors, measurements and sync badge
+ // intact until a coherent window from the NEW publisher is ready.
+ // Never attribute those held values to the newly selected publisher.
+ _awaitingPublisherSwitch = _visibleSnapshot is not null;
+ WaveformStatus = _awaitingPublisherSwitch
+ ? $"Switching to {stream.SvId} · HOLD last displayed publisher until coherent samples arrive."
+ : $"Acquiring coherent samples from {stream.SvId}.";
+ Notify(nameof(SyncDetail));
+ }
UpdateStreamDetail(stream);
}
@@ -280,23 +405,33 @@ private void ApplyUpdate(SmvLiveUpdate update, int generation)
{
var currentKey = Volatile.Read(ref _selectedKey);
_selectingProgrammatically = true;
- Streams.Clear();
- foreach (var discovered in update.Streams)
- Streams.Add(discovered);
- _selectingProgrammatically = false;
- Notify(nameof(StreamCountText));
-
- var next = Streams.FirstOrDefault(s => s.Key == currentKey) ??
- (currentKey is null ? Streams.FirstOrDefault() : null);
- if (next is not null)
+ try
{
+ // Reconcile by stable stream Key. Clearing the ListBox on every
+ // discovery change discards selection containers and flashes the UI.
+ for (var i = 0; i < update.Streams.Count; i++)
+ {
+ var wanted = update.Streams[i];
+ if (i < Streams.Count && Streams[i].Key == wanted.Key)
+ continue;
+ var existing = -1;
+ for (var j = i + 1; j < Streams.Count; j++)
+ if (Streams[j].Key == wanted.Key) { existing = j; break; }
+ if (existing >= 0) Streams.Move(existing, i);
+ else Streams.Insert(i, wanted);
+ }
+ while (Streams.Count > update.Streams.Count)
+ Streams.RemoveAt(Streams.Count - 1);
+ Notify(nameof(StreamCountText));
+
+ var next = Streams.FirstOrDefault(s => s.Key == currentKey) ??
+ (currentKey is null ? Streams.FirstOrDefault() : null);
StreamList.SelectedItem = next;
- Volatile.Write(ref _selectedKey, next.Key);
+ Volatile.Write(ref _selectedKey, next?.Key);
}
- else
+ finally
{
- StreamList.SelectedItem = null;
- Volatile.Write(ref _selectedKey, null);
+ _selectingProgrammatically = false;
}
}
@@ -328,38 +463,92 @@ private void ApplyUpdate(SmvLiveUpdate update, int generation)
// no mixing old results after stream selection or captured-source changes.
if (update.SelectedKey != selectedKey || update.SelectedSnapshot is null)
{
- ClearPresentation(summary.Diagnostic);
+ // A buffer in its initial warm-up window must not repeatedly blank
+ // every pane 5 times/second. Stream changes clear only once in the
+ // SelectionChanged handler. Hold the last coherent state otherwise.
+ if (_visibleSnapshot is null && Measurements.Count == 0)
+ WaveformStatus = summary.Diagnostic;
return;
}
- var snapshot = update.SelectedSnapshot;
- _visibleSnapshot = snapshot;
- IsLiveSnapshot = true;
- Notify(nameof(IsLiveSnapshot));
- UpdateUnitPresentation(redrawWaveform: true);
- BeginAnalysis(snapshot, selectedKey, generation);
+ BeginAnalysis(update.SelectedSnapshot, selectedKey, generation);
}
- private async void BeginAnalysis(SmvSnapshotResult snapshot, string? selectedKey, int generation)
+ private void BeginAnalysis(SmvSnapshotResult snapshot, string? selectedKey, int generation)
+ {
+ // Latest-value backpressure: retain at most one waiting coherent window
+ // while the prior one is processed. Never accumulate Task.Run jobs.
+ _pendingAnalysis = new PendingAnalysis(snapshot, selectedKey, generation);
+ if (_analysisRunning)
+ return;
+ _analysisRunning = true;
+ _ = ProcessAnalysisQueueAsync();
+ }
+
+ private async Task ProcessAnalysisQueueAsync()
{
- var analysis = Interlocked.Increment(ref _analysisGeneration);
try
{
- var vectors = await Task.Run(() => SmvRawVectorMath.Analyze(snapshot));
- if (generation != _generation || analysis != _analysisGeneration ||
- !ReferenceEquals(_visibleSnapshot, snapshot) || !IsLoaded ||
- selectedKey != Volatile.Read(ref _selectedKey))
- return;
- _visibleVectors = SmvDigSubPresentationAdapter.RelativeMeasurements(vectors);
- UpdateUnitPresentation(redrawWaveform: false);
+ while (_pendingAnalysis is { } pending)
+ {
+ _pendingAnalysis = null;
+ var snapshot = pending.Snapshot;
+ var selectedKey = pending.SelectedKey;
+ var generation = pending.Generation;
+ var analysis = Interlocked.Increment(ref _analysisGeneration);
+ var profile = RequestedUnits;
+ try
+ {
+ // One bounded immutable publication: DSP and waveform are
+ // calculated on the same selected snapshot off the UI thread.
+ var rendered = await Task.Run(() =>
+ {
+ var vectors = SmvRawVectorMath.Analyze(snapshot);
+ var waveform = SmvDigSubPresentationAdapter.BuildWaveform(snapshot, profile);
+ return (vectors, waveform);
+ });
+ if (generation != _generation || analysis != _analysisGeneration ||
+ !IsLoaded || !_capturing ||
+ selectedKey != Volatile.Read(ref _selectedKey))
+ continue;
+
+ _visibleSnapshot = snapshot;
+ _visibleVectors = SmvDigSubPresentationAdapter.RelativeMeasurements(rendered.vectors);
+ _lastDisplayedPublisherKey = selectedKey ?? string.Empty;
+ _awaitingPublisherSwitch = false;
+ // Keep the StreamKey consistent with the published sample
+ // identity; the old key stays attached during HOLD.
+ if (CurrentStreamKey != _lastDisplayedPublisherKey)
+ {
+ CurrentStreamKey = _lastDisplayedPublisherKey;
+ Notify(nameof(CurrentStreamKey));
+ }
+ CurrentWaveform = rendered.waveform;
+ Notify(nameof(CurrentWaveform));
+ IsLiveSnapshot = true;
+ Notify(nameof(IsLiveSnapshot));
+ UpdateUnitPresentation(redrawWaveform: profile != RequestedUnits);
+ if (profile == RequestedUnits)
+ WaveformStatus =
+ (snapshot.TimebaseReason.StartsWith("ASSUMED", StringComparison.OrdinalIgnoreCase)
+ ? "ASSUMED timebase · rate/PTP unverified · " : string.Empty) +
+ $"Received {snapshot.CapturedSamples:N0} coherent samples • {snapshot.SamplesPerCycle} samples/cycle • " +
+ (ActiveUnits == SmvDisplayUnits.NineTwoLe
+ ? "9-2LE operator-selected engineering display"
+ : "raw INT32 counts");
+ }
+ catch (Exception)
+ {
+ if (generation != _generation || analysis != _analysisGeneration ||
+ selectedKey != Volatile.Read(ref _selectedKey))
+ continue;
+ WaveformStatus = "New SV analysis unavailable; displaying last coherent snapshot.";
+ }
+ }
}
- catch (Exception)
+ finally
{
- if (generation != _generation || analysis != _analysisGeneration)
- return;
- _visibleVectors = [];
- UpdateUnitPresentation(redrawWaveform: false);
- WaveformStatus = "Raw phasor calculation unavailable; no substitute values shown.";
+ _analysisRunning = false;
}
}
@@ -376,17 +565,27 @@ private void Freshness_Tick(object? sender, EventArgs e)
private void UpdateStreamDetail(SmvLiveStreamSummary? summary)
{
StreamDetail = summary is null
- ? "Passive discovery • no selected publisher. No MMS connection needed."
- : $"{summary.SvId} • APPID 0x{summary.AppId:X4}\n" +
+ ? "Select an SV publisher"
+ : $"APPID 0x{summary.AppId:X4} · VLAN {summary.Vlan}";
+ StreamTechnicalDetail = summary is null
+ ? "Passive discovery; no MMS connection required."
+ : $"{summary.SvId} · APPID 0x{summary.AppId:X4}\n" +
$"{summary.SourceMac} → {summary.DestinationMac}\n" +
- $"VLAN {summary.Vlan} • gap {summary.Gaps} • restart {summary.Restarts}";
+ $"VLAN {summary.Vlan} · gap {summary.Gaps} · restart {summary.Restarts}";
}
private void ClearPresentation(string message)
{
+ _pendingAnalysis = null;
+ Interlocked.Increment(ref _analysisGeneration);
+ _awaitingPublisherSwitch = false;
+ _lastDisplayedPublisherKey = string.Empty;
_visibleSnapshot = null;
_visibleVectors = [];
Measurements.Clear();
+ PowerPhases.Clear();
+ _powerTotal = null;
+ NotifyPowerStatus();
WaveformStatus = message;
CurrentWaveform = null;
Notify(nameof(CurrentWaveform));
@@ -428,12 +627,112 @@ private void UpdateUnitPresentation(bool redrawWaveform)
// The immutable wire/DSP vectors remain raw. Scaling is presentation-only.
// WPF collections remain bounded to at most the 8 displayed channels.
- Measurements.Clear();
- foreach (var raw in _visibleVectors)
- Measurements.Add(SmvEngineeringDisplay.Present(raw, profile));
+ // Preserve the same bounded DataGrid collection/view and replace only
+ // changed rows; Clear/Add resets WPF row containers and visibly flickers.
+ // The phase reference is selected at PRESENTATION time; no wire value
+ // or DFT result is overwritten.
+ var presentationVectors = SmvDigSubPresentationAdapter.RelativeMeasurements(_visibleVectors, profile);
+ var rowIndex = 0;
+ foreach (var raw in presentationVectors)
+ {
+ var next = SmvEngineeringDisplay.Present(raw, profile);
+ if (rowIndex < Measurements.Count && Measurements[rowIndex].ChannelIndex == next.ChannelIndex)
+ Measurements[rowIndex].Apply(next);
+ else if (rowIndex < Measurements.Count)
+ Measurements[rowIndex] = new SmvMeasurementLiveRow(next);
+ else
+ Measurements.Add(new SmvMeasurementLiveRow(next));
+ rowIndex++;
+ }
+ while (Measurements.Count > rowIndex)
+ Measurements.RemoveAt(Measurements.Count - 1);
+
+ // Filters depend on the operator-selected profile, not each frame.
+ // Recreate the views only when the profile truly changes.
+ if (_lastViewProfile != profile)
+ {
+ _lastViewProfile = profile;
+ VoltageMeasurements.Refresh();
+ CurrentMeasurements.Refresh();
+ }
+
+ // A single two-cycle immutable snapshot is the only source of power
+ // calculations; never pair samples across different publishers/frames.
+ var power = SmvPowerMeasurements.Analyze(_visibleSnapshot, _visibleVectors, profile);
+ var phaseCount = 0;
+ if (power is { } actual)
+ foreach (var phase in actual.Phases)
+ {
+ if (phaseCount < PowerPhases.Count)
+ {
+ if (!Equals(PowerPhases[phaseCount], phase))
+ PowerPhases[phaseCount] = phase;
+ }
+ else
+ PowerPhases.Add(phase);
+ phaseCount++;
+ }
+ while (PowerPhases.Count > phaseCount)
+ PowerPhases.RemoveAt(PowerPhases.Count - 1);
+ _powerTotal = power?.Total;
+ NotifyPowerStatus();
UpdateDigSubPhasors();
- Notify(nameof(UnitStatus));
- Notify(nameof(PhaseReferenceText));
+
+ // Static panel text and sync brush are *not* frame-rate content.
+ // Invalidate only when identity/profile/status actually changes.
+ if (_lastUnitCaption != UnitStatus)
+ {
+ _lastUnitCaption = UnitStatus;
+ Notify(nameof(UnitStatus));
+ }
+ if (_lastVoltageHeading != VoltageGroupHeading)
+ {
+ _lastVoltageHeading = VoltageGroupHeading;
+ Notify(nameof(VoltageGroupHeading));
+ }
+ if (_lastCurrentHeading != CurrentGroupHeading)
+ {
+ _lastCurrentHeading = CurrentGroupHeading;
+ Notify(nameof(CurrentGroupHeading));
+ }
+ if (_lastReferenceHeading != PhaseReferenceText)
+ {
+ _lastReferenceHeading = PhaseReferenceText;
+ Notify(nameof(PhaseReferenceText));
+ }
+ if (_lastTimeAxisCaption != TimeAxisCaption)
+ {
+ _lastTimeAxisCaption = TimeAxisCaption;
+ Notify(nameof(TimeAxisCaption));
+ }
+ if (_lastSyncLabel != SyncLabel)
+ {
+ _lastSyncLabel = SyncLabel;
+ Notify(nameof(SyncLabel));
+ Notify(nameof(SyncBackground));
+ Notify(nameof(SyncForeground));
+ }
+ if (_lastSyncDetail != SyncDetail)
+ {
+ _lastSyncDetail = SyncDetail;
+ Notify(nameof(SyncDetail));
+ }
+ }
+
+ private void NotifyPowerStatus()
+ {
+ if (_lastPowerVisibility != PowerVisibility)
+ {
+ _lastPowerVisibility = PowerVisibility;
+ Notify(nameof(PowerVisibility));
+ }
+ Notify(nameof(PowerP));
+ Notify(nameof(PowerQ));
+ Notify(nameof(PowerS));
+ Notify(nameof(PowerPf));
+ Notify(nameof(PowerStatus));
+ Notify(nameof(PowerStatusShort));
+ Notify(nameof(PowerDetails));
}
private void WaveformOptionsChanged(object sender, SelectionChangedEventArgs e)
diff --git a/Views/SmvMeasurementLiveRow.cs b/Views/SmvMeasurementLiveRow.cs
new file mode 100644
index 000000000..5ae31efd8
--- /dev/null
+++ b/Views/SmvMeasurementLiveRow.cs
@@ -0,0 +1,49 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace ArIED61850Tester.Views;
+
+///
+/// Stable DataGrid row, updated from an immutable engineering measurement.
+/// Rows and row containers are not replaced while samples stream. The
+/// measurement values remain calculated in SmvEngineeringDisplay; this class
+/// changes only their presentation notification.
+///
+public sealed class SmvMeasurementLiveRow : INotifyPropertyChanged
+{
+ private SmvDisplayMeasurement _current;
+
+ public SmvMeasurementLiveRow(SmvDisplayMeasurement current) => _current = current;
+
+ public int ChannelIndex => _current.ChannelIndex;
+ public string DisplayName => _current.DisplayName;
+ public string Unit => _current.Unit;
+ public string Label => _current.Label;
+ public string RmsText => _current.RmsText;
+ public string FundamentalRmsText => _current.FundamentalRmsText;
+ public string RmsDetail => _current.RmsDetail;
+ public string AngleText => _current.AngleText;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public void Apply(SmvDisplayMeasurement next)
+ {
+ if (next.ChannelIndex != _current.ChannelIndex)
+ throw new InvalidOperationException("A live SMV row cannot change its wire channel identity.");
+ if (Equals(_current, next))
+ return;
+
+ var prev = _current;
+ _current = next;
+ if (prev.DisplayName != next.DisplayName) Notify(nameof(DisplayName));
+ if (prev.Unit != next.Unit) Notify(nameof(Unit));
+ if (prev.Label != next.Label) Notify(nameof(Label));
+ if (prev.RmsText != next.RmsText) Notify(nameof(RmsText));
+ if (prev.FundamentalRmsText != next.FundamentalRmsText) Notify(nameof(FundamentalRmsText));
+ if (prev.RmsDetail != next.RmsDetail) Notify(nameof(RmsDetail));
+ if (prev.AngleText != next.AngleText) Notify(nameof(AngleText));
+ }
+
+ private void Notify([CallerMemberName] string? propertyName = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+}
diff --git a/Views/SmvPowerMeasurements.cs b/Views/SmvPowerMeasurements.cs
new file mode 100644
index 000000000..a967301be
--- /dev/null
+++ b/Views/SmvPowerMeasurements.cs
@@ -0,0 +1,79 @@
+using System.Globalization;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester.Views;
+
+///
+/// One coherent same-ASDU eight-channel U/I window. Engineering units are
+/// operator-selected 9-2LE assumptions, never SCL/protection-calibration proof.
+/// P = arithmetic mean(u*i), S = true U/I RMS product, PF = P/S.
+/// Q1 uses *fundamental* DFT magnitude and phase displacement, and must not
+/// be mislabeled as total/non-sinusoidal reactive power.
+///
+public static class SmvPowerMeasurements
+{
+ public sealed record Phase(string Name, double P, double Q1, double S, double PF)
+ {
+ public string PText => Format(P, "W");
+ public string QText => Format(Q1, "var");
+ public string SText => Format(S, "VA");
+ public string PfText => PF.ToString("+0.000;-0.000;0.000", CultureInfo.InvariantCulture);
+ }
+
+ public sealed record Result(IReadOnlyList Phases, Phase Total);
+
+ public static Result? Analyze(SmvSnapshotResult? snapshot,
+ IReadOnlyList vectors, SmvDisplayUnits selected)
+ {
+ if (snapshot is null || !snapshot.IsCleanProof || snapshot.TargetSamples < 8 ||
+ SmvEngineeringDisplay.Effective(snapshot, selected) != SmvDisplayUnits.NineTwoLe)
+ return null;
+
+ var output = new List(3);
+ for (var phase = 0; phase < 3; phase++)
+ {
+ var iChannel = phase + 1;
+ var uChannel = phase + 5;
+ var i = snapshot.Channels.FirstOrDefault(x => x.ChannelIndex == iChannel);
+ var u = snapshot.Channels.FirstOrDefault(x => x.ChannelIndex == uChannel);
+ var iVector = vectors.FirstOrDefault(x => x.ChannelIndex == iChannel);
+ var uVector = vectors.FirstOrDefault(x => x.ChannelIndex == uChannel);
+ if (i is null || u is null || iVector is null || uVector is null ||
+ i.Samples.Count != snapshot.TargetSamples || u.Samples.Count != snapshot.TargetSamples)
+ return null;
+
+ var p = 0.0;
+ for (var sample = 0; sample < snapshot.TargetSamples; sample++)
+ p += (u.Samples[sample] * 0.01) * (i.Samples[sample] * 0.001);
+ p /= snapshot.TargetSamples;
+
+ var s = uVector.Rms * 0.01 * iVector.Rms * 0.001;
+ // A DFT with no fundamental cannot assert a signed displacement-Q.
+ if (!uVector.HasFundamental || !iVector.HasFundamental ||
+ !double.IsFinite(p) || !double.IsFinite(s))
+ return null;
+ var angle = (uVector.PhaseDegrees - iVector.PhaseDegrees) * Math.PI / 180.0;
+ var q = uVector.FundamentalRms * 0.01 *
+ iVector.FundamentalRms * 0.001 * Math.Sin(angle);
+ if (!double.IsFinite(q))
+ return null;
+ output.Add(new Phase(((char)('a' + phase)).ToString(),
+ p, q, s, s > 1e-12 ? Math.Clamp(p / s, -1, 1) : 0));
+ }
+
+ var totalP = output.Sum(x => x.P);
+ var totalQ = output.Sum(x => x.Q1);
+ var totalS = output.Sum(x => x.S);
+ var total = new Phase("Σ", totalP, totalQ, totalS,
+ totalS > 1e-12 ? Math.Clamp(totalP / totalS, -1, 1) : 0);
+ return new Result(output, total);
+ }
+
+ public static string Format(double value, string unit)
+ {
+ var scaled = Math.Abs(value) >= 1000;
+ return (scaled ? value / 1000 : value)
+ .ToString("0.###", CultureInfo.InvariantCulture) +
+ (scaled ? " k" : " ") + unit;
+ }
+}
diff --git a/Views/SmvSyncPresentation.cs b/Views/SmvSyncPresentation.cs
new file mode 100644
index 000000000..1ac15222f
--- /dev/null
+++ b/Views/SmvSyncPresentation.cs
@@ -0,0 +1,58 @@
+using System.Windows.Media;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester.Views;
+
+///
+/// IEC 61850-9-2 sample publisher's reported smpSynch state, NOT receiver-side
+/// PTP/GPS verification. An absent/stale/invalid snapshot is always Unknown.
+///
+public static class SmvSyncPresentation
+{
+ private static readonly Brush GlobalBg = Frozen("#E4F7ED");
+ private static readonly Brush GlobalInk = Frozen("#147D49");
+ private static readonly Brush LocalBg = Frozen("#E8F1FF");
+ private static readonly Brush LocalInk = Frozen("#295DB8");
+ private static readonly Brush NoneBg = Frozen("#FFF1E5");
+ private static readonly Brush NoneInk = Frozen("#995218");
+ private static readonly Brush UnknownBg = Frozen("#EEF2F7");
+ private static readonly Brush UnknownInk = Frozen("#59687E");
+
+ public static string Label(SmvSnapshotResult? snapshot) => snapshot switch
+ {
+ { IsCleanProof: true, SampleSynchronization: 2 } => "Global Sync",
+ { IsCleanProof: true, SampleSynchronization: 1 } => "Local Sync",
+ { IsCleanProof: true, SampleSynchronization: 0 } => "Sync None",
+ _ => "Sync Unknown"
+ };
+
+ public static string Detail(SmvSnapshotResult? snapshot) => snapshot switch
+ {
+ { IsCleanProof: true, SampleSynchronization: <= 2 } =>
+ $"Publisher-reported smpSynch={snapshot.SampleSynchronization} (IEC 61850 Ed.2 semantics). Does NOT verify PTP, GPS or grandmaster quality.",
+ null => "No selected coherent SV snapshot; publisher synchronization not known.",
+ _ => "smpSynch absent, outside 0–2 or sample continuity not qualified."
+ };
+
+ public static Brush Background(SmvSnapshotResult? snapshot) => Label(snapshot) switch
+ {
+ "Global Sync" => GlobalBg,
+ "Local Sync" => LocalBg,
+ "Sync None" => NoneBg,
+ _ => UnknownBg
+ };
+ public static Brush Foreground(SmvSnapshotResult? snapshot) => Label(snapshot) switch
+ {
+ "Global Sync" => GlobalInk,
+ "Local Sync" => LocalInk,
+ "Sync None" => NoneInk,
+ _ => UnknownInk
+ };
+
+ private static Brush Frozen(string hex)
+ {
+ var brush = (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
+ brush.Freeze();
+ return brush;
+ }
+}
diff --git a/docs/release/ARSAS_RC1_ACCEPTED_STACK_INTEGRATION_20261024.md b/docs/release/ARSAS_RC1_ACCEPTED_STACK_INTEGRATION_20261024.md
new file mode 100644
index 000000000..504c88501
--- /dev/null
+++ b/docs/release/ARSAS_RC1_ACCEPTED_STACK_INTEGRATION_20261024.md
@@ -0,0 +1,18 @@
+# ARSAS RC1 accepted stack integration — 2026-10-24
+
+**DRAFT ONLY: NOT PUBLIC STABLE, NOT MAIN.**
+
+- Parent RC0 `77c6cd2dccd27de155bbad6799b3d914be118c9a` includes accepted SSOT V2 responsive reporting, frozen MMS Write->readback Browser SSOT, and independent final RC0 handoff.
+- Final operator-accepted SMV checkpoint `84b6e15f0e7a94b419d172921b366c4cb7f28793`, PR #725, original source #722 `4b2b62f882a81988a39a2f82d1ac8ff67592be02`.
+- Import 27 SMV-only source/test/document blobs. Take entire final `Views/SmvInlineViewer.xaml` without old UI rollback. Combine SMV Dock and RC0 exact DPC Boolean safety test. Keep RC0 `App.xaml` (identical to SMV), four IoList embedded Inter paths (same intended font behavior), already equivalent SMV theme test.
+- Preserve SSOT v2 model/index/association/live-value journal and SG7 performance, MMS typed one-shot Write/Control admission, 77/77 SG7 value + 4/4 RCB previous field proof, ZIVAVR 8/8+1 RCB, GR_X 13/13+2 RCB, SCL/GOOSE, Smart IP, COMTRADE ArdIrec. These are source/field checkpoint proofs, NOT this combined binary's proof.
+- No automatic cyclic process polling, no invented q/t/values, no unqualified SBOw/Operate, no false SV PTP-lock claim. Retain RAW-vs-9-2LE calibration truth and SMV HOLD across stream changes.
+- Engine lock is inherited unchanged: `f75e323e9f8443aa20e0a3818a1d5d77255b2c0f` (ARIEC staging PR #192 still DRAFT, not qualified production).
+
+## Mandatory gates before public stable
+
+1. Exact-head Windows Build and application/engine regression, native bridge/CTest, R7, SV, P0/P1, IO List, COMTRADE, portable+installer smoke and SHA256 manifest. Do not substitute a historic green SHA.
+2. Diff and verify final SMV approved presentation, SG7 authority, Write readback, WPF Command Dock tap-first/BSC numeric, Browser navigation order, Smart IP, UI response, failure paths and reconnect.
+3. Combine with latest public main only through reviewed three-way merge preserving independent accepted main COMTRADE, release/update/security, installed version and engine lock. No force push or public auto-release.
+4. Retain each historic vendor/model/firmware user FAT acceptance and perform a bounded combined-binary cross-feature FAT on SG7_BCU, ZIVAVR, GR_X, and SV publishers. Investigate report continuity/BufOvfl and validate UI p95/p99; do not demand redoing entire weeks of accepted FAT unless exact feature changed.
+5. Only after all checks and operator acceptance: immutable release SHA/tag, Windows portable + installer, SHA256, source/engine manifest, signed release notes, known limits and rollback baseline.
diff --git a/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs b/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs
index 7f77b2c0d..e1b4c4684 100644
--- a/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs
+++ b/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs
@@ -76,9 +76,12 @@ public void NavigationIsNotAnMmsReadTrigger_AndDockKeepsOneState()
var shell = Source("MainWindow.PersistentWorkbench.cs");
Assert.Contains("public bool DockExpanded { get; set; }", shell, StringComparison.Ordinal);
- Assert.DoesNotContain("DockExpandedByWorkspace", shell, StringComparison.Ordinal);
- Assert.Contains("CommandPanelExpander.IsExpanded == state.DockExpanded", shell, StringComparison.Ordinal);
+ Assert.Contains("public bool SmvDockExpanded { get; set; } = false;", shell, StringComparison.Ordinal);
+ Assert.Contains("workspaceIndex == EngineeringNavigationRoute.SmvWorkspaceIndex", shell, StringComparison.Ordinal);
+ Assert.Contains("CommandPanelExpander.IsExpanded == desiredExpanded", shell, StringComparison.Ordinal);
Assert.Contains("state.DockExpanded = expanded;", shell, StringComparison.Ordinal);
+ Assert.Contains("state.SmvDockExpanded = expanded;", shell, StringComparison.Ordinal);
+ Assert.DoesNotContain("RefreshControlValuesAsync(SelectedDevice", shell, StringComparison.Ordinal);
}
[Fact]
diff --git a/tests/ARSAS.Tests/SmvEngineeringDisplayTests.cs b/tests/ARSAS.Tests/SmvEngineeringDisplayTests.cs
index 7ee4016c6..b3cbf9450 100644
--- a/tests/ARSAS.Tests/SmvEngineeringDisplayTests.cs
+++ b/tests/ARSAS.Tests/SmvEngineeringDisplayTests.cs
@@ -13,6 +13,8 @@ public void Default_IsRawAndCannotInventUnitsWithoutProfileSelection()
var plotted = SmvDigSubPresentationAdapter.BuildWaveform(snapshot);
Assert.All(plotted.VoltageSeries.Concat(plotted.CurrentSeries),
series => Assert.Equal("counts", series.Unit));
+ Assert.Equal("Ch1", plotted.VoltageSeries[0].Name);
+ Assert.Equal("Ch5", plotted.CurrentSeries[0].Name);
var raw = SmvEngineeringDisplay.Present(
new SmvRawVectorMeasurement(5, "Raw Ch5", 5000, 4700, 120, true),
SmvDisplayUnits.RawCounts);
@@ -29,6 +31,8 @@ public void Explicit9_2Le_UsesCurrentFirstVoltageSecondAndSameUnitsInAllPlots()
var plotted = SmvDigSubPresentationAdapter.BuildWaveform(snapshot, SmvDisplayUnits.NineTwoLe);
Assert.Equal("A", plotted.VoltageSeries[0].Unit);
Assert.Equal("V", plotted.CurrentSeries[0].Unit);
+ Assert.Equal("Ia", plotted.VoltageSeries[0].Name);
+ Assert.Equal("Ua", plotted.CurrentSeries[0].Name);
Assert.Equal(1, plotted.VoltageSeries[0].Samples[0], 8);
Assert.Equal(50, plotted.CurrentSeries[0].Samples[0], 8);
Assert.Equal("Ia", SmvEngineeringDisplay.Describe(1, SmvDisplayUnits.NineTwoLe).Name);
@@ -56,6 +60,14 @@ public void Explicit9_2Le_UsesCurrentFirstVoltageSecondAndSameUnitsInAllPlots()
Assert.Equal(47, phasors[1].Magnitude!.Value, 8);
}
+ [Fact]
+ public void AngleZero_NeverShowsNegativeZeroOrMisleadingSignedReference()
+ {
+ var value = new SmvDisplayMeasurement(5, "Ua", "V", 50, 50, -0.001, true);
+ Assert.Equal("∠0.0°", value.AngleText);
+ Assert.Equal(-0.001, value.PhaseDegrees);
+ }
+
[Fact]
public void EightChannelShapeRequired_NoSilentScalingOfUnverifiedCustomStream()
{
diff --git a/tests/ARSAS.Tests/SmvMeasurementCoolBlueCheckpointTests.cs b/tests/ARSAS.Tests/SmvMeasurementCoolBlueCheckpointTests.cs
index 60ef5d679..a4f6407a9 100644
--- a/tests/ARSAS.Tests/SmvMeasurementCoolBlueCheckpointTests.cs
+++ b/tests/ARSAS.Tests/SmvMeasurementCoolBlueCheckpointTests.cs
@@ -28,7 +28,11 @@ public void PaletteChanges_DoNotTouchRawSmvOrWaveformProcessing()
var x = Read("Views/SmvInlineViewer.xaml");
Assert.Contains("x:Name=\"WaveformView\"", x);
Assert.Contains("x:Name=\"PhasorView\"", x);
- Assert.Contains("ItemsSource=\"{Binding Measurements}\"", x);
+ Assert.Contains("ItemsSource=\"{Binding VoltageMeasurements}\"", x);
+ Assert.Contains("ItemsSource=\"{Binding CurrentMeasurements}\"", x);
+ var code = Read("Views/SmvInlineViewer.xaml.cs");
+ Assert.Contains("ObservableCollection Measurements", code);
+ Assert.Equal(2, code.Split("new ListCollectionView(Measurements)").Length - 1);
Assert.Contains("Content=\"Raw counts\" Tag=\"Raw\"", x);
}
diff --git a/tests/ARSAS.Tests/SmvMeasurementFidelityAndCursorRegressionTests.cs b/tests/ARSAS.Tests/SmvMeasurementFidelityAndCursorRegressionTests.cs
new file mode 100644
index 000000000..0577b7d47
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvMeasurementFidelityAndCursorRegressionTests.cs
@@ -0,0 +1,128 @@
+using System.IO;
+using ArIED61850Tester.Services;
+using ArIED61850Tester.Views;
+
+namespace ARSAS.Tests;
+
+///
+/// Physical FAT expectations: the SV receiver must measure wire samples, not
+/// rewrite results to match a publisher's configured fundamental setpoint.
+///
+public sealed class SmvMeasurementFidelityAndCursorRegressionTests
+{
+ [Theory]
+ [InlineData(0.493, "A", "0.493 A")]
+ [InlineData(999.999, "V", "999.999 V")]
+ [InlineData(1000, "V", "1 kV")]
+ [InlineData(5735.6, "V", "5.736 kV")]
+ [InlineData(1000, "A", "1 kA")]
+ [InlineData(1600, "A", "1.6 kA")]
+ [InlineData(5999.377, "counts", "5999.377")]
+ public void RmsFormatting_UsesExplicitVerifiedUnitsWithoutChangingNumericSamples(
+ double value, string unit, string expected)
+ {
+ var row = new SmvDisplayMeasurement(1, "Ia", unit, value, value, 0, true);
+ Assert.Equal(expected, row.RmsText);
+ Assert.Equal(value, row.Rms);
+ Assert.StartsWith("∠", row.AngleText);
+ Assert.Contains("Total RMS", row.RmsDetail);
+ Assert.Contains("fundamental", row.RmsDetail);
+ }
+
+ [Fact]
+ public void PhaseReference_UsesUaFor9_2LeAndCh1ForRaw()
+ {
+ var vectors = new[]
+ {
+ new SmvRawVectorMeasurement(1, "Ch1", 1000, 1000, 15, true),
+ new SmvRawVectorMeasurement(2, "Ch2", 1000, 1000, -140, true),
+ new SmvRawVectorMeasurement(3, "Ch3", 1000, 1000, 150, true),
+ new SmvRawVectorMeasurement(5, "Ch5", 1000, 1000, 0, true),
+ new SmvRawVectorMeasurement(6, "Ch6", 1000, 1000, -160, true),
+ new SmvRawVectorMeasurement(7, "Ch7", 1000, 1000, 100, true)
+ };
+ var raw = SmvDigSubPresentationAdapter.RelativeMeasurements(vectors);
+ Assert.Equal(0, raw[0].PhaseDegrees, 8);
+ Assert.Equal(-155, raw[1].PhaseDegrees, 8);
+ Assert.Equal(-15, raw[3].PhaseDegrees, 8);
+
+ var mapped = SmvDigSubPresentationAdapter.RelativeMeasurements(raw, SmvDisplayUnits.NineTwoLe);
+ Assert.Equal(15, mapped[0].PhaseDegrees, 8);
+ Assert.Equal(-140, mapped[1].PhaseDegrees, 8);
+ Assert.Equal(150, mapped[2].PhaseDegrees, 8);
+ Assert.Equal(0, mapped[3].PhaseDegrees, 8);
+ Assert.Equal(-160, mapped[4].PhaseDegrees, 8);
+ Assert.Equal(100, mapped[5].PhaseDegrees, 8);
+
+ var phasors = SmvDigSubPresentationAdapter.BuildPhasors(vectors, SmvDisplayUnits.NineTwoLe);
+ Assert.Equal(new[] { 15d, -140d, 150d, 0d, -160d, 100d },
+ phasors.Select(v => v.AngleDegrees!.Value).ToArray());
+ }
+
+ [Fact]
+ public void NonSinusoidalWaveform_HasDifferentTotalAndFundamentalRms_ByMathematicalDefinition()
+ {
+ const int count = 160;
+ const int spc = 80;
+ const double fundamentalRms = 40;
+ const double thirdHarmonicRms = 4;
+ var signal = Enumerable.Range(0, count)
+ .Select(i => Math.Sqrt(2) * (
+ fundamentalRms * Math.Cos(2 * Math.PI * i / spc) +
+ thirdHarmonicRms * Math.Cos(6 * Math.PI * i / spc)))
+ .ToArray();
+ var snapshot = new SmvSnapshotResult
+ {
+ SamplesPerCycle = spc,
+ CycleCount = 2,
+ TargetSamples = count,
+ CapturedSamples = count,
+ Channels = [new SmvSnapshotChannel { ChannelIndex = 5, Label = "Ch5", Samples = signal }]
+ };
+ var measured = Assert.Single(SmvRawVectorMath.Analyze(snapshot));
+ Assert.InRange(measured.Rms, Math.Sqrt(1616) - 0.000001, Math.Sqrt(1616) + 0.000001);
+ Assert.InRange(measured.FundamentalRms, 39.999999, 40.000001);
+ Assert.True(measured.Rms > measured.FundamentalRms);
+ var displayed = SmvEngineeringDisplay.Present(measured, SmvDisplayUnits.NineTwoLe);
+ Assert.Contains("fundamental", displayed.RmsDetail);
+ Assert.Equal(measured.Rms * .01, displayed.Rms, 9);
+ }
+
+ [Fact]
+ public void HoverCursorAndStackedAxes_AreBoundedToWaveRenderer_WithoutTimersOrProtocolChanges()
+ {
+ var wave = Source("Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs");
+ Assert.Contains("UpdateCursorPosition(e.GetPosition(this))", wave);
+ Assert.Contains("protected override void OnMouseLeave(", wave);
+ Assert.Contains("_cursorSampleIndex == sampleIndex", wave);
+ Assert.Contains("_cursorLane == laneIndex", wave);
+ Assert.Contains("if (stacked && traces > 0)", wave);
+ Assert.Contains("if (!stacked && IsSilentZeroTrace(series.Samples))", wave);
+ Assert.Contains("if (samples[i] != 0.0)", wave);
+ Assert.Contains("var y = rect.Top + (i + 0.5) * band", wave);
+ Assert.DoesNotContain("var pen = i == 2 ? ZeroPen", wave);
+ Assert.DoesNotContain("DispatcherTimer", wave);
+ }
+
+ [Fact]
+ public void SharedCommandDock_DefaultsClosedOnlyInPassiveSmvWorkspace_ButIsManuallyExpandable()
+ {
+ var code = Source("MainWindow.PersistentWorkbench.cs");
+ Assert.Contains("public bool SmvDockExpanded { get; set; } = false;", code);
+ Assert.Contains("workspaceIndex == EngineeringNavigationRoute.SmvWorkspaceIndex", code);
+ Assert.Contains("state.SmvDockExpanded = expanded;", code);
+ Assert.Contains("state.DockExpanded = expanded;", code);
+ Assert.Contains("state.ApplyingDockState = true;", code);
+ Assert.DoesNotContain("RefreshControlValuesAsync(SelectedDevice", code);
+ }
+
+ private static string Source(string path)
+ {
+ for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
+ {
+ var full = Path.Combine(dir.FullName, path);
+ if (File.Exists(full)) return File.ReadAllText(full);
+ }
+ throw new FileNotFoundException(path);
+ }
+}
diff --git a/tests/ARSAS.Tests/SmvMeasurementPaletteRegressionTests.cs b/tests/ARSAS.Tests/SmvMeasurementPaletteRegressionTests.cs
index 290545fbc..cecedacfa 100644
--- a/tests/ARSAS.Tests/SmvMeasurementPaletteRegressionTests.cs
+++ b/tests/ARSAS.Tests/SmvMeasurementPaletteRegressionTests.cs
@@ -17,15 +17,34 @@ public void Measurements_HasScopedCoolBlueGridRowsHeaderAndCells()
XNamespace wpf = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
- var grid = Assert.Single(document.Descendants(wpf + "DataGrid"));
- Assert.Equal("{StaticResource SmvMeasurementGrid}", (string?)grid.Attribute("Style"));
- Assert.Equal("{StaticResource SmvMeasurementHeader}", (string?)grid.Attribute("ColumnHeaderStyle"));
- Assert.Equal("{StaticResource SmvMeasurementCell}", (string?)grid.Attribute("CellStyle"));
- Assert.Equal("{StaticResource SmvMeasurementRow}", (string?)grid.Attribute("RowStyle"));
- Assert.Equal("#F8FBFF", (string?)grid.Attribute("Background"));
- Assert.Equal("#F8FBFF", (string?)grid.Attribute("RowBackground"));
- Assert.Equal("#EDF5FF", (string?)grid.Attribute("AlternatingRowBackground"));
- Assert.Equal("2", (string?)grid.Attribute("AlternationCount"));
+ // The final operator-accepted layout has TWO synchronized DataGrids:
+ // voltage and current. Do not force the obsolete single-grid layout.
+ var grids = document.Descendants(wpf + "DataGrid").ToArray();
+ Assert.Equal(2, grids.Length);
+ foreach (var (name, view) in new[]
+ {
+ ("SmvVoltageMeasurementsGrid", "VoltageMeasurements"),
+ ("SmvCurrentMeasurementsGrid", "CurrentMeasurements")
+ })
+ {
+ var grid = grids.Single(e => (string?)e.Attribute(x + "Name") == name);
+ Assert.Equal("{Binding " + view + "}", (string?)grid.Attribute("ItemsSource"));
+ Assert.Equal("True", (string?)grid.Attribute("IsReadOnly"));
+ Assert.Equal("True", (string?)grid.Attribute("EnableRowVirtualization"));
+ Assert.Equal("False", (string?)grid.Attribute("CanUserSortColumns"));
+ Assert.Equal("None", (string?)grid.Attribute("GridLinesVisibility"));
+ Assert.Equal("{StaticResource SmvMeasurementGrid}", (string?)grid.Attribute("Style"));
+ Assert.Equal("{StaticResource SmvMeasurementHeader}", (string?)grid.Attribute("ColumnHeaderStyle"));
+ Assert.Equal("{StaticResource SmvMeasurementCell}", (string?)grid.Attribute("CellStyle"));
+ Assert.Equal("{StaticResource SmvMeasurementRow}", (string?)grid.Attribute("RowStyle"));
+ Assert.Equal("#F8FBFF", (string?)grid.Attribute("Background"));
+ Assert.Equal("#F8FBFF", (string?)grid.Attribute("RowBackground"));
+ Assert.Equal("#EDF5FF", (string?)grid.Attribute("AlternatingRowBackground"));
+ Assert.Equal("2", (string?)grid.Attribute("AlternationCount"));
+ Assert.Equal(3, grid.Descendants(wpf + "DataGridColumn").Count() +
+ grid.Descendants(wpf + "DataGridTemplateColumn").Count() +
+ grid.Descendants(wpf + "DataGridTextColumn").Count());
+ }
AssertStyle(document, wpf, x, "SmvMeasurementGrid", "ModernDataGrid",
("Background", "#F8FBFF"), ("RowBackground", "#F8FBFF"),
@@ -50,11 +69,32 @@ public void Measurements_HasScopedCoolBlueGridRowsHeaderAndCells()
public void StylingOnly_LeavesEngineeringBindingsAndDigSubPlotsIntact()
{
var xml = Read("Views/SmvInlineViewer.xaml");
- Assert.Contains("ItemsSource=\"{Binding Measurements}\"", xml);
+ Assert.Contains("ItemsSource=\"{Binding VoltageMeasurements}\"", xml);
+ Assert.Contains("ItemsSource=\"{Binding CurrentMeasurements}\"", xml);
Assert.Contains(" (string?)e.Attribute("Binding") == "{Binding RmsText}"));
+ Assert.Equal(2, columns.Count(e => (string?)e.Attribute("Binding") == "{Binding AngleText}"));
+ Assert.Equal(2, document.Descendants(wpf + "TextBlock")
+ .Count(e => (string?)e.Attribute("Text") == "{Binding DisplayName}"));
+
+ // Both panels must project ONE authoritative, change-notified eight-row
+ // model. Preserve the engineering values and stable row lifetimes.
+ var viewer = Read("Views/SmvInlineViewer.xaml.cs");
+ var liveRow = Read("Views/SmvMeasurementLiveRow.cs");
+ Assert.Contains("ObservableCollection Measurements", viewer);
+ Assert.Equal(2, viewer.Split("new ListCollectionView(Measurements)").Length - 1);
+ Assert.Contains("SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage", viewer);
+ Assert.Contains("!SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage", viewer);
+ Assert.Contains("VoltageMeasurements.Refresh();", viewer);
+ Assert.Contains("CurrentMeasurements.Refresh();", viewer);
+ Assert.Contains("next.ChannelIndex != _current.ChannelIndex", liveRow);
+ foreach (var field in new[] { "DisplayName", "Unit", "RmsText", "FundamentalRmsText", "AngleText" })
+ Assert.Contains("nameof(" + field + ")", liveRow);
}
private static void AssertStyle(
diff --git a/tests/ARSAS.Tests/SmvMeasurementSplitGridRegressionTests.cs b/tests/ARSAS.Tests/SmvMeasurementSplitGridRegressionTests.cs
new file mode 100644
index 000000000..ac5ac2260
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvMeasurementSplitGridRegressionTests.cs
@@ -0,0 +1,74 @@
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+using ArIED61850Tester.Views;
+
+namespace ARSAS.Tests;
+
+public sealed class SmvMeasurementSplitGridRegressionTests
+{
+ [Fact]
+ public void MeasurementArea_UsesTwoSideBySideCoolBlueFourChannelGrids()
+ {
+ var xaml = Source("Views/SmvInlineViewer.xaml");
+ var xml = XDocument.Parse(xaml);
+ var grids = xml.Descendants().Where(x => x.Name.LocalName == "DataGrid").ToArray();
+ Assert.Equal(2, grids.Length);
+ Assert.Contains("x:Name=\"SmvVoltageMeasurementsGrid\"", xaml);
+ Assert.Contains("ItemsSource=\"{Binding VoltageMeasurements}\"", xaml);
+ Assert.Contains("x:Name=\"SmvCurrentMeasurementsGrid\"", xaml);
+ Assert.Contains("ItemsSource=\"{Binding CurrentMeasurements}\"", xaml);
+ Assert.Contains("Header=\"RMS\"", xaml);
+ Assert.Contains("Header=\"Angle\"", xaml);
+ Assert.DoesNotContain("Header=\"Fund.\"", xaml);
+ Assert.DoesNotContain("Header=\"Unit\"", xaml);
+ Assert.Contains("Value=\"#F8FBFF\"", xaml);
+ Assert.Contains("Value=\"#EDF5FF\"", xaml);
+ Assert.Contains("Value=\"#E8F2FF\"", xaml);
+ Assert.All(grids, grid =>
+ {
+ Assert.Equal("False", (string?)grid.Attribute("AutoGenerateColumns"));
+ Assert.Equal("False", (string?)grid.Attribute("CanUserSortColumns"));
+ Assert.Equal("36", (string?)grid.Attribute("RowHeight"));
+ Assert.Equal("{StaticResource SmvMeasurementGrid}", (string?)grid.Attribute("Style"));
+ var columnElement = grid.Elements().Single(x => x.Name.LocalName == "DataGrid.Columns");
+ Assert.Equal(new[] { "Channel", "RMS", "Angle" },
+ columnElement.Elements().Select(x => (string?)x.Attribute("Header")).ToArray());
+ });
+ }
+
+ [Fact]
+ public void GroupingKeepsOriginalEightRawValuesAndHonorsExplicit9_2LeOrder()
+ {
+ // RAW groups are visual-only; do not manufacture voltage or current scaling.
+ for (var ch = 1; ch <= 8; ch++)
+ {
+ var raw = SmvEngineeringDisplay.Describe(ch, SmvDisplayUnits.RawCounts);
+ Assert.Equal("counts", raw.Unit);
+ Assert.Equal(ch <= 4, raw.IsVoltage);
+ var configured = SmvEngineeringDisplay.Describe(ch, SmvDisplayUnits.NineTwoLe);
+ Assert.Equal(ch >= 5, configured.IsVoltage);
+ Assert.Equal(ch >= 5 ? "V" : "A", configured.Unit);
+ }
+
+ var code = Source("Views/SmvInlineViewer.xaml.cs");
+ Assert.Contains("new ListCollectionView(Measurements)", code);
+ Assert.Contains("SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage", code);
+ Assert.Contains("!SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage", code);
+ Assert.Contains("var next = SmvEngineeringDisplay.Present(raw, profile);", code);
+ Assert.Contains("Measurements[rowIndex].Apply(next);", code);
+ Assert.Contains("Notify(nameof(VoltageGroupHeading))", code);
+ Assert.Contains("Notify(nameof(CurrentGroupHeading))", code);
+ Assert.Contains("grouping is unverified", code);
+ }
+
+ private static string Source(string relative)
+ {
+ for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
+ {
+ var full = Path.Combine(dir.FullName, relative);
+ if (File.Exists(full)) return File.ReadAllText(full);
+ }
+ throw new FileNotFoundException(relative);
+ }
+}
diff --git a/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs b/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs
index a8706c10f..647e68ea8 100644
--- a/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs
+++ b/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs
@@ -21,7 +21,9 @@ public void MainSmvTab_HostsFullViewerWithoutIEDSelectionGate()
Assert.Contains("new SmvSnapshotCaptureService().ListAdapters()", code);
Assert.Contains("Volatile.Read(ref _selectedKey)", code);
Assert.Contains("SmvDigSubPresentationAdapter.BuildWaveform(snapshot, profile)", code);
- Assert.Contains("SmvDigSubPresentationAdapter.RelativeMeasurements(vectors)", code);
+ Assert.Contains("SmvDigSubPresentationAdapter.RelativeMeasurements(rendered.vectors)", code);
+ Assert.Contains("var vectors = SmvRawVectorMath.Analyze(snapshot);", code);
+ Assert.Contains("CurrentWaveform = rendered.waveform;", code);
Assert.DoesNotContain("private void DrawWaveform()", code);
Assert.DoesNotContain("private void DrawPhasors()", code);
Assert.Contains("MainTabs.SelectedIndex = EngineeringNavigationRoute.SmvWorkspaceIndex", quick);
diff --git a/tests/ARSAS.Tests/SmvP0RetainedPresentationTests.cs b/tests/ARSAS.Tests/SmvP0RetainedPresentationTests.cs
new file mode 100644
index 000000000..a6e27ebc8
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvP0RetainedPresentationTests.cs
@@ -0,0 +1,103 @@
+using System.IO;
+using ArIED61850Tester.Views;
+using ProcessBus.App.Wpf.Controls;
+using ProcessBus.Core.Models;
+
+namespace ARSAS.Tests;
+
+public sealed class SmvP0RetainedPresentationTests
+{
+ [Theory]
+ [InlineData(-120, 240)]
+ [InlineData(-160, 200)]
+ [InlineData(-140, 220)]
+ [InlineData(-0.001, 0)]
+ [InlineData(360, 0)]
+ [InlineData(720, 0)]
+ [InlineData(100, 100)]
+ [InlineData(375, 15)]
+ public void Normalize360_AffectsPresentationOnly(double input, double expected)
+ {
+ Assert.Equal(expected, SmvAnglePresentation.Normalize360(input), 5);
+ }
+
+ [Fact]
+ public void AnglesInGridAndPhasorLabel_ShareSameUnsignedConvention()
+ {
+ var voltage = new SmvDisplayMeasurement(6, "Ub", "V", 49.999,
+ 49.999, -160, true);
+ Assert.Equal("∠200.0°", voltage.AngleText);
+ Assert.Equal("Ub 50V ∠200°", PhasorDiagramControl.FormatPhasorLabel(
+ new PhasorDisplayItem
+ {
+ Name = "Ub", Unit = "V", Family = PhasorFamily.Voltage,
+ Magnitude = 49.999, AngleDegrees = -160
+ }));
+ Assert.Equal("Ia 1.002A ∠15°", PhasorDiagramControl.FormatPhasorLabel(
+ new PhasorDisplayItem
+ {
+ Name = "Ia", Unit = "A", Family = PhasorFamily.Current,
+ Magnitude = 1.002, AngleDegrees = 15
+ }));
+ Assert.Equal(-160, voltage.PhaseDegrees);
+ }
+
+ [Fact]
+ public void LiveMeasurementRow_PreservesIdentityWhileNotifyingOnlyChangedCells()
+ {
+ var old = new SmvDisplayMeasurement(1, "Ia", "A", 1.0, 1.0, -120, true);
+ var row = new SmvMeasurementLiveRow(old);
+ var changed = new List();
+ row.PropertyChanged += (_, args) => changed.Add(args.PropertyName);
+ row.Apply(new SmvDisplayMeasurement(1, "Ia", "A", 1.002, 1.002, 240, true));
+ Assert.Equal("1.002 A", row.RmsText);
+ Assert.Equal("∠240.0°", row.AngleText);
+ Assert.Contains(nameof(row.RmsText), changed);
+ Assert.DoesNotContain(nameof(row.DisplayName), changed);
+ // -120 and +240 are the SAME displayed phase; no text disturbance.
+ Assert.DoesNotContain(nameof(row.AngleText), changed);
+ changed.Clear();
+ row.Apply(new SmvDisplayMeasurement(1, "Ia", "A", 1.002, 1.002, 240, true));
+ Assert.Empty(changed);
+ Assert.Throws(() => row.Apply(
+ new SmvDisplayMeasurement(2, "Ib", "A", 1, 1, 0, true)));
+ }
+
+ [Fact]
+ public void StreamTransition_RetainsLastCoherentFrame_AndNeverShowsTemporaryUnknown()
+ {
+ var code = Read("Views/SmvInlineViewer.xaml.cs");
+ var selectionStart = code.IndexOf("private void Stream_SelectionChanged", StringComparison.Ordinal);
+ var selectionEnd = code.IndexOf("private void QueueUpdate", selectionStart, StringComparison.Ordinal);
+ var selection = code[selectionStart..selectionEnd];
+ Assert.DoesNotContain("ClearPresentation(stream is null ?", selection);
+ Assert.Contains("_pendingAnalysis = null;", selection);
+ Assert.Contains("_awaitingPublisherSwitch = _visibleSnapshot is not null;", selection);
+ Assert.Contains("Notify(nameof(SyncDetail));", selection);
+ Assert.Contains("_lastDisplayedPublisherKey", code);
+ Assert.Contains("CurrentStreamKey = _lastDisplayedPublisherKey;", code);
+ Assert.Contains("Streams.Move(existing, i);", code);
+ Assert.Contains("Measurements[rowIndex].Apply(next);", code);
+ Assert.DoesNotContain("Measurements[rowIndex] = next;", code);
+ }
+
+ [Fact]
+ public void RawProfile_CollapsesEntirePowerCard_InsteadOfPresentingDashes()
+ {
+ var xaml = Read("Views/SmvInlineViewer.xaml");
+ Assert.Contains("Grid.Row=\"4\" Visibility=\"{Binding PowerVisibility}\"", xaml);
+ var code = Read("Views/SmvInlineViewer.xaml.cs");
+ Assert.Contains("public Visibility PowerVisibility => ActiveUnits == SmvDisplayUnits.NineTwoLe && _powerTotal is not null", code);
+ Assert.Contains("Notify(nameof(PowerVisibility));", code);
+ }
+
+ private static string Read(string relativePath)
+ {
+ for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
+ {
+ var file = Path.Combine(dir.FullName, relativePath);
+ if (File.Exists(file)) return File.ReadAllText(file);
+ }
+ throw new FileNotFoundException(relativePath);
+ }
+}
diff --git a/tests/ARSAS.Tests/SmvP1InstrumentNoiseRegressionTests.cs b/tests/ARSAS.Tests/SmvP1InstrumentNoiseRegressionTests.cs
new file mode 100644
index 000000000..6c3074f38
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvP1InstrumentNoiseRegressionTests.cs
@@ -0,0 +1,96 @@
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+
+namespace ARSAS.Tests;
+
+///
+/// Windows FAT P1 lock: instrument-first surface remains quiet while protocol
+/// and provenance evidence stays reachable through bounded hover details.
+/// This is presentation-only; do not change sync, timebase or measured values.
+///
+public sealed class SmvP1InstrumentNoiseRegressionTests
+{
+ [Fact]
+ public void SyncHover_IsBoundedAndWrapped_WhileLiveBadgeRemains()
+ {
+ var xaml = XDocument.Parse(Read("Views/SmvInlineViewer.xaml"));
+ var tooltip = xaml.Descendants().Single(x => x.Name.LocalName == "ToolTip" &&
+ (string?)x.Attribute("MaxWidth") == "330");
+ Assert.Contains(tooltip.Descendants(), x => x.Name.LocalName == "TextBlock" &&
+ (string?)x.Attribute("TextWrapping") == "Wrap" &&
+ ((string?)x.Attribute("Text"))!.Contains("SyncDetail", StringComparison.Ordinal));
+ Assert.Contains("Text=\"{Binding SyncLabel}\"", Read("Views/SmvInlineViewer.xaml"));
+ var vm = Read("Views/SmvInlineViewer.xaml.cs");
+ Assert.Contains("Previous publisher sync shown.", vm);
+ Assert.Contains("New publisher sync not yet known.", vm);
+ Assert.Contains("SmvSyncPresentation.Detail(_visibleSnapshot)", vm);
+ }
+
+ [Fact]
+ public void MeasurementSurface_FavorsLiveElectricalValues_AndRetainsOnDemandMetadata()
+ {
+ var xaml = Read("Views/SmvInlineViewer.xaml");
+ var vm = Read("Views/SmvInlineViewer.xaml.cs");
+ Assert.DoesNotContain("Text=\"RMS · phase\"", xaml);
+ Assert.DoesNotContain("ACTIVE · P", xaml);
+ Assert.DoesNotContain("REACTIVE · Q₁", xaml);
+ Assert.DoesNotContain("APPARENT · S", xaml);
+ Assert.DoesNotContain("POWER FACTOR", xaml);
+ Assert.Contains("Text=\"3φ Power\"", xaml);
+ Assert.Contains("ToolTip=\"{Binding PowerDetails}\"", xaml);
+ Assert.Contains("Text=\"{Binding UnitStatus}\" Visibility=\"Collapsed\"", xaml);
+ Assert.Contains("ToolTip=\"{Binding UnitStatus}\"", xaml);
+ Assert.Contains("\"Voltage (V)\"", vm);
+ Assert.Contains("\"Current (A)\"", vm);
+ Assert.Contains("\"Ref: Ua\"", vm);
+ Assert.Contains("\"Ua = 0 ms\"", vm);
+ Assert.Contains("SmvEngineeringDisplay.Effective(_visibleSnapshot, RequestedUnits)", vm);
+ }
+
+ [Fact]
+ public void PublisherAndCapture_StayConciseButFullEvidenceIsStillAvailable()
+ {
+ var xaml = Read("Views/SmvInlineViewer.xaml");
+ var vm = Read("Views/SmvInlineViewer.xaml.cs");
+ Assert.Contains("Text=\"{Binding StreamDetail}\"", xaml);
+ Assert.Contains("ToolTip=\"{Binding StreamTechnicalDetail}\"", xaml);
+ Assert.Contains("SourceMac", vm);
+ Assert.Contains("DestinationMac", vm);
+ Assert.Contains("summary.Gaps", vm);
+ Assert.Contains("summary.Restarts", vm);
+ Assert.Contains("Text=\"{Binding CaptureBrief}\"", xaml);
+ Assert.Contains("ToolTip=\"{Binding CaptureStatus}\"", xaml);
+ Assert.Contains("\"SV capture error\"", vm);
+ Assert.Contains("\"HOLD · stream interrupted\"", vm);
+ }
+
+ [Fact]
+ public void WaveformAndPhasor_AvoidRepeatedPendingAndScaleExplanations()
+ {
+ var wave = Read("Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs");
+ var phasor = Read("Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs");
+ Assert.DoesNotContain("Signal frequency pending", wave);
+ Assert.DoesNotContain("Sample rate pending", wave);
+ Assert.Contains("if (laneIndex == 0)", wave);
+ Assert.Contains("DrawTimebaseLabel(dc, innerRect, snapshot);", wave);
+ Assert.Contains("\"VOLTAGE\"", wave);
+ Assert.Contains("\"CURRENT\"", wave);
+ Assert.Contains("U solid · I dashed · separate scales", phasor);
+ Assert.DoesNotContain("9-2LE · U solid / I dashed · independent scales", phasor);
+ var xaml = Read("Views/SmvInlineViewer.xaml");
+ Assert.Contains("Visibility=\"Collapsed\" ToolTip=\"{Binding WaveformStatus}\"", xaml);
+ Assert.Contains("ToolTip=\"{Binding WaveformStatus}\"", xaml);
+ Assert.Contains("Full measurement precision is shown below.", xaml);
+ }
+
+ private static string Read(string path)
+ {
+ for (DirectoryInfo? d = new(AppContext.BaseDirectory); d is not null; d = d.Parent)
+ {
+ var resolved = Path.Combine(d.FullName, path);
+ if (File.Exists(resolved)) return File.ReadAllText(resolved);
+ }
+ throw new FileNotFoundException(path);
+ }
+}
diff --git a/tests/ARSAS.Tests/SmvP1VisualDensityTests.cs b/tests/ARSAS.Tests/SmvP1VisualDensityTests.cs
new file mode 100644
index 000000000..62507e394
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvP1VisualDensityTests.cs
@@ -0,0 +1,117 @@
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+using ArIED61850Tester.Views;
+using ProcessBus.App.Wpf.Controls;
+using ProcessBus.Core.Models;
+
+namespace ARSAS.Tests;
+
+///
+/// P1 Windows FAT visual contracts: short RAW callouts, bounded polar side
+/// rails, four-row content-sized measurement panel and aligned Power metrics.
+/// No sample decoding, DSP or live-capture behavior is modified.
+///
+public sealed class SmvP1VisualDensityTests
+{
+ [Theory]
+ [InlineData("Ch1", 0, "Ch1 ∠0°")]
+ [InlineData("Ch2", -155, "Ch2 ∠205°")]
+ [InlineData("Ch5", -15, "Ch5 ∠345°")]
+ [InlineData("Ch6", -175, "Ch6 ∠185°")]
+ public void RawCallouts_ShowIdentityAndWrappedAngle_NotCountMagnitude(
+ string channel, double angle, string expected)
+ {
+ var label = PhasorDiagramControl.FormatPhasorLabel(new PhasorDisplayItem
+ {
+ Name = channel, Unit = "counts", Family = PhasorFamily.Voltage,
+ Magnitude = 999999.123, AngleDegrees = angle
+ });
+ Assert.Equal(expected, label);
+ Assert.DoesNotContain("counts", label, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("999", label, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("Ua", "V", 100, 0, "Ua 100V ∠0°")]
+ [InlineData("Ub", "V", 49.999, -160, "Ub 50V ∠200°")]
+ [InlineData("Ic", "A", 0.997, 150, "Ic 0.997A ∠150°")]
+ [InlineData("Ia", "A", 1.002, 15, "Ia 1.002A ∠15°")]
+ public void EngineeringCallouts_KeepMagnitudeWithCompactUnitAndPhase(
+ string channel, string unit, double magnitude, double angle, string expected)
+ {
+ var label = PhasorDiagramControl.FormatPhasorLabel(new PhasorDisplayItem
+ {
+ Name = channel, Unit = unit, Family = unit == "V" ? PhasorFamily.Voltage : PhasorFamily.Current,
+ Magnitude = magnitude, AngleDegrees = angle
+ });
+ Assert.Equal(expected, label);
+ }
+
+ [Fact]
+ public void NarrowPhasorHost_RetainsAngleAndVectorWithoutOverflowingMagnitude()
+ {
+ var item = new PhasorDisplayItem
+ {
+ Name = "Ub", Unit = "V", Family = PhasorFamily.Voltage,
+ Magnitude = 49.999, AngleDegrees = -160
+ };
+ Assert.Equal("Ub 50V ∠200°", PhasorDiagramControl.FormatPhasorLabel(item));
+ Assert.Equal("Ub ∠200°", PhasorDiagramControl.FormatPhasorLabel(item, fitNarrow: true));
+ var renderer = Source("Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs");
+ Assert.Contains("var fitNarrow = width < 420;", renderer);
+ Assert.Contains("engineeringLabels = !fitNarrow", renderer);
+ }
+
+ [Fact]
+ public void PhasorPlot_MeasuresTextWidthBeforePlacingCircle_AndKeepsLeaderTip()
+ {
+ var renderer = Source("Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs");
+ Assert.Contains("var labelCache = _visualItems.Where(IsRenderable)", renderer);
+ Assert.Contains("leftRail = Math.Max(leftRail, pair.Value.Width + 10.0)", renderer);
+ Assert.Contains("rightRail = Math.Max(rightRail, pair.Value.Width + 10.0)", renderer);
+ Assert.Contains("center.X - leftRail, width - rightRail - center.X", renderer);
+ Assert.Contains("labelCache[item]", renderer);
+ Assert.Contains("PhasorCalloutRouting.Route(", renderer);
+ Assert.Contains("vector.Tip.X, vector.Tip.Y", renderer);
+ Assert.DoesNotContain("width / 2.0 - 69", renderer);
+ }
+
+ [Fact]
+ public void CompactPowerAndMeasurementPanel_AreContentSized_WithoutBlankBottomArea()
+ {
+ var xaml = XDocument.Parse(Source("Views/SmvInlineViewer.xaml"));
+ var panel = xaml.Descendants().Single(n => n.Name.LocalName == "TextBlock" &&
+ (string?)n.Attribute("Text") == "Measurements").Parent!.Parent!;
+ var rows = panel.Elements().Single(n => n.Name.LocalName == "Grid.RowDefinitions")
+ .Elements().ToArray();
+ Assert.Equal(7, rows.Length);
+ Assert.Equal("Auto", (string?)rows[2].Attribute("Height"));
+
+ var power = panel.Descendants().Single(n => n.Name.LocalName == "Border" &&
+ (string?)n.Attribute("ToolTip") == "{Binding PowerDetails}");
+ Assert.Equal("{Binding PowerVisibility}", (string?)power.Attribute("Visibility"));
+ var powerLabels = power.Descendants().Where(n => n.Name.LocalName == "TextBlock")
+ .Select(n => (string?)n.Attribute("Text")).ToArray();
+ foreach (var field in new[] { "P", "Q₁", "S", "PF",
+ "{Binding PowerP}", "{Binding PowerQ}", "{Binding PowerS}", "{Binding PowerPf}" })
+ Assert.Contains(field, powerLabels);
+ Assert.DoesNotContain(power.Descendants(), n => n.Name.LocalName == "UniformGrid");
+
+ var source = Source("Views/SmvInlineViewer.xaml");
+ Assert.DoesNotContain("Height=\"0.9*\" MinHeight=\"220\"", source);
+ Assert.Contains("Height=\"*\" MinHeight=\"235\"", source);
+ Assert.Contains("