From 5a40c345d3b5c17ec93219640fab8a54df5211bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 11:12:16 +0700 Subject: [PATCH 1/2] integration(rc1): preserve accepted SSOT v2/Write and adopt final SMV without SG7 authority rollback --- ..._SMV_TAB_ACCEPTED_CHECKPOINT_2026-10-24.md | 44 ++ MainWindow.PersistentWorkbench.cs | 19 +- .../Controls/PhasorDiagramControl.cs | 95 +++- .../Controls/SmvWaveformTimeAxis.cs | 52 ++ .../Controls/WaveformCursorCalloutLayout.cs | 74 +++ .../Controls/WaveformPanelControl.cs | 481 +++++++++++++----- Resources/ArvrelMiniIedFascia.xaml | 4 +- Views/SmvAnglePresentation.cs | 28 + Views/SmvDigSubPresentationAdapter.cs | 17 +- Views/SmvEngineeringDisplay.cs | 22 +- Views/SmvInlineViewer.xaml | 275 +++++++--- Views/SmvInlineViewer.xaml.cs | 399 +++++++++++++-- Views/SmvMeasurementLiveRow.cs | 49 ++ Views/SmvPowerMeasurements.cs | 79 +++ Views/SmvSyncPresentation.cs | 58 +++ ...RC1_ACCEPTED_STACK_INTEGRATION_20261024.md | 18 + ...9WCommandDockLifecycleArchitectureTests.cs | 7 +- .../ARSAS.Tests/SmvEngineeringDisplayTests.cs | 12 + ...urementFidelityAndCursorRegressionTests.cs | 128 +++++ .../SmvMeasurementSplitGridRegressionTests.cs | 74 +++ .../SmvNativeTabRegressionTests.cs | 4 +- .../SmvP0RetainedPresentationTests.cs | 103 ++++ .../SmvP1InstrumentNoiseRegressionTests.cs | 96 ++++ tests/ARSAS.Tests/SmvP1VisualDensityTests.cs | 117 +++++ .../SmvP3EngineeringRegressionTests.cs | 131 +++++ .../ARSAS.Tests/SmvP3FatUxRegressionTests.cs | 104 ++++ .../SmvP3PresentationSourceRegressionTests.cs | 68 +++ ...mvPremiumLayoutAndSourceBoundariesTests.cs | 5 +- .../WaveformCursorCalloutLayoutTests.cs | 88 ++++ 29 files changed, 2364 insertions(+), 287 deletions(-) create mode 100644 HANDOFF_ARSAS_SMV_TAB_ACCEPTED_CHECKPOINT_2026-10-24.md create mode 100644 Ports/DigSubAnalyzer/Controls/SmvWaveformTimeAxis.cs create mode 100644 Ports/DigSubAnalyzer/Controls/WaveformCursorCalloutLayout.cs create mode 100644 Views/SmvAnglePresentation.cs create mode 100644 Views/SmvMeasurementLiveRow.cs create mode 100644 Views/SmvPowerMeasurements.cs create mode 100644 Views/SmvSyncPresentation.cs create mode 100644 docs/release/ARSAS_RC1_ACCEPTED_STACK_INTEGRATION_20261024.md create mode 100644 tests/ARSAS.Tests/SmvMeasurementFidelityAndCursorRegressionTests.cs create mode 100644 tests/ARSAS.Tests/SmvMeasurementSplitGridRegressionTests.cs create mode 100644 tests/ARSAS.Tests/SmvP0RetainedPresentationTests.cs create mode 100644 tests/ARSAS.Tests/SmvP1InstrumentNoiseRegressionTests.cs create mode 100644 tests/ARSAS.Tests/SmvP1VisualDensityTests.cs create mode 100644 tests/ARSAS.Tests/SmvP3EngineeringRegressionTests.cs create mode 100644 tests/ARSAS.Tests/SmvP3FatUxRegressionTests.cs create mode 100644 tests/ARSAS.Tests/SmvP3PresentationSourceRegressionTests.cs create mode 100644 tests/ARSAS.Tests/WaveformCursorCalloutLayoutTests.cs 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/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/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("