From 0cea3d38a200ea80df9ed1a34c5acf225e327077 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:12:36 +0700 Subject: [PATCH 01/12] fix(comtrade): animate scrub presentation at composition cadence --- Controls/ComtradeHarmonicsWorkstationView.cs | 142 ++++++++++++---- Controls/ComtradePhasorView.cs | 159 ++++++++++++++---- ...adePresentationAnimationRegressionTests.cs | 41 +++++ 3 files changed, 276 insertions(+), 66 deletions(-) create mode 100644 tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs diff --git a/Controls/ComtradeHarmonicsWorkstationView.cs b/Controls/ComtradeHarmonicsWorkstationView.cs index 73580609a..a9195a50f 100644 --- a/Controls/ComtradeHarmonicsWorkstationView.cs +++ b/Controls/ComtradeHarmonicsWorkstationView.cs @@ -67,9 +67,13 @@ public sealed class ComtradeHarmonicsWorkstationView : FrameworkElement private static readonly string[] OrderLabels = CreateOrderLabels(); private const double PresentationTimeConstantMs = 92.0; + private const double PresentationAnimationMaximumMs = 300.0; private IReadOnlyList _spectra = Array.Empty(); + private ComtradeHarmonicOverviewSpectrum[] _targetSpectra = Array.Empty(); private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); + private bool _presentationRenderingHooked; private long _lastPresentationTimestamp; + private long _presentationAnimationStartedTimestamp; private PreparedSpectrumRow[] _preparedRows = Array.Empty(); private int _maximumDisplayedOrder = -1; private string _title = "Harmonics"; @@ -81,6 +85,7 @@ public ComtradeHarmonicsWorkstationView() { Cursor = Cursors.Arrow; ToolTip = "Click any harmonic order to compare the same order across all visible analog channels."; + Unloaded += (_, _) => StopPresentationAnimation(); } internal void ShowSpectrum(string title, string subtitle, ComtradeHarmonicDisplaySpectrum spectrum) @@ -103,39 +108,33 @@ internal void ShowSpectrum(string title, string subtitle, ComtradeHarmonicDispla } internal void ShowSpectra( - string title, - string subtitle, - IReadOnlyList spectra) - { - _title = title ?? string.Empty; - _subtitle = subtitle ?? string.Empty; - var targetSpectra = spectra ?? Array.Empty(); - var now = Stopwatch.GetTimestamp(); - var elapsedMilliseconds = _lastPresentationTimestamp == 0 - ? double.PositiveInfinity - : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds; - _lastPresentationTimestamp = now; - _smoothedSpectra = SmoothSpectra(_smoothedSpectra, targetSpectra, elapsedMilliseconds); - _spectra = _smoothedSpectra; - _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_spectra); - _selectedOrder = Math.Clamp(_selectedOrder, 0, Math.Max(0, _maximumDisplayedOrder)); - _preparedRows = PrepareRows(_spectra, _maximumDisplayedOrder); - _rowTargets.Clear(); - InvalidateVisual(); - } + string title, + string subtitle, + IReadOnlyList spectra) +{ + _title = title ?? string.Empty; + _subtitle = subtitle ?? string.Empty; + _targetSpectra = CloneSpectra(spectra ?? Array.Empty()); + _smoothedSpectra = SmoothSpectra(_smoothedSpectra, _targetSpectra, 0.0); + RefreshPreparedSpectra(); + if (SpectraDiffer(_smoothedSpectra, _targetSpectra)) StartPresentationAnimation(); + else StopPresentationAnimation(); + InvalidateVisual(); +} internal void ShowMessage(string title, string message) - { - _title = title ?? string.Empty; - _subtitle = message ?? string.Empty; - _spectra = Array.Empty(); - _smoothedSpectra = Array.Empty(); - _lastPresentationTimestamp = 0; - _preparedRows = Array.Empty(); - _maximumDisplayedOrder = -1; - _rowTargets.Clear(); - InvalidateVisual(); - } +{ + _title = title ?? string.Empty; + _subtitle = message ?? string.Empty; + StopPresentationAnimation(); + _spectra = Array.Empty(); + _targetSpectra = Array.Empty(); + _smoothedSpectra = Array.Empty(); + _preparedRows = Array.Empty(); + _maximumDisplayedOrder = -1; + _rowTargets.Clear(); + InvalidateVisual(); +} protected override void OnRender(DrawingContext dc) { @@ -309,6 +308,87 @@ private void DrawFooter(DrawingContext dc, Rect bounds, double dpi) FooterRateBrush, new Point(bounds.Right - 16, y), dpi); } + private void StartPresentationAnimation() +{ + var now = Stopwatch.GetTimestamp(); + _lastPresentationTimestamp = now; + _presentationAnimationStartedTimestamp = now; + if (_presentationRenderingHooked) return; + CompositionTarget.Rendering += PresentationCompositionFrame; + _presentationRenderingHooked = true; +} + +private void StopPresentationAnimation() +{ + if (_presentationRenderingHooked) + { + CompositionTarget.Rendering -= PresentationCompositionFrame; + _presentationRenderingHooked = false; + } + _lastPresentationTimestamp = 0; + _presentationAnimationStartedTimestamp = 0; +} + +private void PresentationCompositionFrame(object? sender, EventArgs e) +{ + if (!_presentationRenderingHooked) return; + var now = Stopwatch.GetTimestamp(); + var dt = _lastPresentationTimestamp == 0 + ? 16.0 + : Math.Clamp(Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds, 0.0, 50.0); + _lastPresentationTimestamp = now; + _smoothedSpectra = SmoothSpectra(_smoothedSpectra, _targetSpectra, dt); + var age = _presentationAnimationStartedTimestamp == 0 + ? PresentationAnimationMaximumMs + : Stopwatch.GetElapsedTime(_presentationAnimationStartedTimestamp, now).TotalMilliseconds; + if (age >= PresentationAnimationMaximumMs) + { + _smoothedSpectra = CloneSpectra(_targetSpectra); + StopPresentationAnimation(); + } + RefreshPreparedSpectra(); + InvalidateVisual(); +} + +private void RefreshPreparedSpectra() +{ + _spectra = _smoothedSpectra; + _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_spectra); + _selectedOrder = Math.Clamp(_selectedOrder, 0, Math.Max(0, _maximumDisplayedOrder)); + _preparedRows = PrepareRows(_spectra, _maximumDisplayedOrder); + _rowTargets.Clear(); +} + +private static ComtradeHarmonicOverviewSpectrum[] CloneSpectra(IReadOnlyList source) +{ + if (source.Count == 0) return Array.Empty(); + var result = new ComtradeHarmonicOverviewSpectrum[source.Count]; + for (var i = 0; i < source.Count; i++) result[i] = source[i] with { Bins = source[i].Bins.ToArray() }; + return result; +} + +private static bool SpectraDiffer(IReadOnlyList left, IReadOnlyList right) +{ + if (left.Count != right.Count) return true; + for (var i = 0; i < left.Count; i++) + { + var a = left[i]; var b = right[i]; + if (!string.Equals(a.SignalName, b.SignalName, StringComparison.Ordinal) || + !string.Equals(a.Units, b.Units, StringComparison.Ordinal) || + !a.DcComponent.Equals(b.DcComponent) || !a.FundamentalRms.Equals(b.FundamentalRms) || + !a.ThdPercent.Equals(b.ThdPercent) || a.DominantOrder != b.DominantOrder || + !a.DominantRms.Equals(b.DominantRms) || !a.DominantPercent.Equals(b.DominantPercent) || + a.Bins.Count != b.Bins.Count) return true; + for (var j = 0; j < a.Bins.Count; j++) + { + var x = a.Bins[j]; var y = b.Bins[j]; + if (x.Order != y.Order || !x.MagnitudeRms.Equals(y.MagnitudeRms) || + !x.PercentOfFundamental.Equals(y.PercentOfFundamental) || !x.AngleDegrees.Equals(y.AngleDegrees)) return true; + } + } + return false; +} + private static ComtradeHarmonicOverviewSpectrum[] SmoothSpectra( IReadOnlyList previous, IReadOnlyList target, diff --git a/Controls/ComtradePhasorView.cs b/Controls/ComtradePhasorView.cs index 9564c3081..5436f51fb 100644 --- a/Controls/ComtradePhasorView.cs +++ b/Controls/ComtradePhasorView.cs @@ -37,50 +37,65 @@ public sealed class ComtradePhasorView : FrameworkElement private PreparedPhasorPanel _voltagePanel = PreparedPhasorPanel.Empty; private PreparedPhasorPanel _currentPanel = PreparedPhasorPanel.Empty; private const double PresentationTimeConstantMs = 78.0; + private const double PresentationAnimationMaximumMs = 260.0; private string _headerLabel = "Fundamental phasors at C1"; private string _referenceDetail = "Select a valid analysis reference"; private string _message = string.Empty; + private ComtradePhasorVector[] _targetVoltageVectors = Array.Empty(); + private ComtradePhasorVector[] _targetCurrentVectors = Array.Empty(); private ComtradePhasorVector[] _smoothedVoltageVectors = Array.Empty(); private ComtradePhasorVector[] _smoothedCurrentVectors = Array.Empty(); + private bool _presentationRenderingHooked; private long _lastPresentationTimestamp; + private long _presentationAnimationStartedTimestamp; - internal void ShowPhasors( - string referenceLabel, - string referenceDetail, - IReadOnlyList voltageVectors, - IReadOnlyList currentVectors) - { - var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel; - _headerLabel = $"Fundamental phasors at {resolvedReference}"; - _referenceDetail = referenceDetail ?? string.Empty; - - var now = Stopwatch.GetTimestamp(); - var elapsedMilliseconds = _lastPresentationTimestamp == 0 - ? double.PositiveInfinity - : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds; - _lastPresentationTimestamp = now; - - _smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, voltageVectors, elapsedMilliseconds); - _smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, currentVectors, elapsedMilliseconds); - _voltagePanel = PreparePanel(_smoothedVoltageVectors); - _currentPanel = PreparePanel(_smoothedCurrentVectors); - _message = string.Empty; - InvalidateVisual(); - } + public ComtradePhasorView() +{ + Unloaded += (_, _) => StopPresentationAnimation(); +} + +internal void ShowPhasors( + string referenceLabel, + string referenceDetail, + IReadOnlyList voltageVectors, + IReadOnlyList currentVectors) +{ + var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel; + _headerLabel = $"Fundamental phasors at {resolvedReference}"; + _referenceDetail = referenceDetail ?? string.Empty; + _targetVoltageVectors = CloneVectors(voltageVectors); + _targetCurrentVectors = CloneVectors(currentVectors); + + // First sample/topology changes snap. Normal scrub updates keep the current + // presentation and converge to the newest exact native target on composition frames. + _smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, _targetVoltageVectors, 0.0); + _smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, _targetCurrentVectors, 0.0); + RefreshPreparedPanels(); + _message = string.Empty; + + if (VectorsDiffer(_smoothedVoltageVectors, _targetVoltageVectors) || + VectorsDiffer(_smoothedCurrentVectors, _targetCurrentVectors)) + StartPresentationAnimation(); + else + StopPresentationAnimation(); + InvalidateVisual(); +} internal void ShowMessage(string title, string message) - { - var resolvedTitle = string.IsNullOrWhiteSpace(title) ? "Phasor" : title; - _headerLabel = $"Fundamental phasors at {resolvedTitle}"; - _referenceDetail = message ?? string.Empty; - _voltagePanel = PreparedPhasorPanel.Empty; - _currentPanel = PreparedPhasorPanel.Empty; - _smoothedVoltageVectors = Array.Empty(); - _smoothedCurrentVectors = Array.Empty(); - _lastPresentationTimestamp = 0; - _message = message ?? string.Empty; - InvalidateVisual(); - } +{ + var resolvedTitle = string.IsNullOrWhiteSpace(title) ? "Phasor" : title; + _headerLabel = $"Fundamental phasors at {resolvedTitle}"; + _referenceDetail = message ?? string.Empty; + StopPresentationAnimation(); + _voltagePanel = PreparedPhasorPanel.Empty; + _currentPanel = PreparedPhasorPanel.Empty; + _targetVoltageVectors = Array.Empty(); + _targetCurrentVectors = Array.Empty(); + _smoothedVoltageVectors = Array.Empty(); + _smoothedCurrentVectors = Array.Empty(); + _message = message ?? string.Empty; + InvalidateVisual(); +} protected override void OnRender(DrawingContext dc) { @@ -129,6 +144,80 @@ protected override void OnRender(DrawingContext dc) } } + private void StartPresentationAnimation() +{ + var now = Stopwatch.GetTimestamp(); + _lastPresentationTimestamp = now; + _presentationAnimationStartedTimestamp = now; + if (_presentationRenderingHooked) return; + CompositionTarget.Rendering += PresentationCompositionFrame; + _presentationRenderingHooked = true; +} + +private void StopPresentationAnimation() +{ + if (_presentationRenderingHooked) + { + CompositionTarget.Rendering -= PresentationCompositionFrame; + _presentationRenderingHooked = false; + } + _lastPresentationTimestamp = 0; + _presentationAnimationStartedTimestamp = 0; +} + +private void PresentationCompositionFrame(object? sender, EventArgs e) +{ + if (!_presentationRenderingHooked) return; + var now = Stopwatch.GetTimestamp(); + var dt = _lastPresentationTimestamp == 0 + ? 16.0 + : Math.Clamp(Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds, 0.0, 50.0); + _lastPresentationTimestamp = now; + _smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, _targetVoltageVectors, dt); + _smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, _targetCurrentVectors, dt); + + var age = _presentationAnimationStartedTimestamp == 0 + ? PresentationAnimationMaximumMs + : Stopwatch.GetElapsedTime(_presentationAnimationStartedTimestamp, now).TotalMilliseconds; + if (age >= PresentationAnimationMaximumMs) + { + // Presentation may ease; the settled engineering display is the exact native target. + _smoothedVoltageVectors = CloneVectors(_targetVoltageVectors); + _smoothedCurrentVectors = CloneVectors(_targetCurrentVectors); + StopPresentationAnimation(); + } + RefreshPreparedPanels(); + InvalidateVisual(); +} + +private void RefreshPreparedPanels() +{ + _voltagePanel = PreparePanel(_smoothedVoltageVectors); + _currentPanel = PreparePanel(_smoothedCurrentVectors); +} + +private static ComtradePhasorVector[] CloneVectors(IReadOnlyList? source) +{ + if (source is null || source.Count == 0) return Array.Empty(); + var result = new ComtradePhasorVector[source.Count]; + for (var i = 0; i < source.Count; i++) result[i] = source[i]; + return result; +} + +private static bool VectorsDiffer(IReadOnlyList left, IReadOnlyList right) +{ + if (left.Count != right.Count) return true; + for (var i = 0; i < left.Count; i++) + { + var a = left[i]; var b = right[i]; + if (!string.Equals(a.Label, b.Label, StringComparison.Ordinal) || + !string.Equals(a.Phase, b.Phase, StringComparison.Ordinal) || + !string.Equals(a.Units, b.Units, StringComparison.Ordinal) || + !a.MagnitudeRms.Equals(b.MagnitudeRms) || !a.AngleDegrees.Equals(b.AngleDegrees)) return true; + } + return false; +} + private static ComtradePhasorVector[] SmoothVectors( IReadOnlyList previous, IReadOnlyList? target, diff --git a/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs b/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs new file mode 100644 index 000000000..95fe09a99 --- /dev/null +++ b/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs @@ -0,0 +1,41 @@ +namespace ARSAS.Tests; + +public sealed class ComtradePresentationAnimationRegressionTests +{ + [Fact] + public void PresentationViews_AnimateAtCompositionCadence_ThenSettleExactly() + { + var phasor = File.ReadAllText(FindRepoFile("Controls/ComtradePhasorView.cs")); + var harmonic = File.ReadAllText(FindRepoFile("Controls/ComtradeHarmonicsWorkstationView.cs")); + + // Cursor/native analysis remains exact; only the derived presentation plane is eased. + Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", phasor, StringComparison.Ordinal); + Assert.Contains("_smoothedVoltageVectors = CloneVectors(_targetVoltageVectors)", phasor, StringComparison.Ordinal); + Assert.Contains("_smoothedCurrentVectors = CloneVectors(_targetCurrentVectors)", phasor, StringComparison.Ordinal); + Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", harmonic, StringComparison.Ordinal); + Assert.Contains("_smoothedSpectra = CloneSpectra(_targetSpectra)", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherTimer", phasor, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherTimer", harmonic, StringComparison.Ordinal); + } + + [Fact] + public void NativeScrub_RemainsLatestWinsSingleWorker() + { + var source = File.ReadAllText(FindRepoFile("ComtradeWorkspaceWindow.P1D4LiveScrub.cs")); + Assert.Contains("if (_p1d4ScrubWorkerRunning || !_p1d4ScrubDirty)", source, StringComparison.Ordinal); + Assert.Contains("_p1d4ScrubWorkerRunning = true", source, StringComparison.Ordinal); + Assert.Contains("if (_p1d4ScrubDirty)", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine(dir.FullName, relativePath); + if (File.Exists(candidate)) return candidate; + dir = dir.Parent; + } + throw new FileNotFoundException(relativePath); + } +} From da315f6b68ff84410e38f8ab3b1c54a32e604498 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:16:25 +0700 Subject: [PATCH 02/12] test(comtrade): match actual latest-wins scrub guard --- .../ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs b/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs index 95fe09a99..289da6fe2 100644 --- a/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs +++ b/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs @@ -24,7 +24,7 @@ public void NativeScrub_RemainsLatestWinsSingleWorker() var source = File.ReadAllText(FindRepoFile("ComtradeWorkspaceWindow.P1D4LiveScrub.cs")); Assert.Contains("if (_p1d4ScrubWorkerRunning || !_p1d4ScrubDirty)", source, StringComparison.Ordinal); Assert.Contains("_p1d4ScrubWorkerRunning = true", source, StringComparison.Ordinal); - Assert.Contains("if (_p1d4ScrubDirty)", source, StringComparison.Ordinal); + Assert.Contains("if (_p1d4ScrubDirty && _analysisMode != AnalysisMode.Waveform)", source, StringComparison.Ordinal); } private static string FindRepoFile(string relativePath) From 77b76567fa209429b1ef2eeefda4ca8dd1881009 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:43:47 +0700 Subject: [PATCH 03/12] ci: apply COMTRADE review fixes --- .../fix-comtrade-review-findings.yml | 593 ++++++++++++++++++ 1 file changed, 593 insertions(+) create mode 100644 .github/workflows/fix-comtrade-review-findings.yml diff --git a/.github/workflows/fix-comtrade-review-findings.yml b/.github/workflows/fix-comtrade-review-findings.yml new file mode 100644 index 000000000..19948c451 --- /dev/null +++ b/.github/workflows/fix-comtrade-review-findings.yml @@ -0,0 +1,593 @@ +name: Fix COMTRADE review findings + +on: + push: + branches: + - "fix/v1636-comtrade-interaction-easing" + paths: + - ".github/workflows/fix-comtrade-review-findings.yml" + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/v1636-comtrade-interaction-easing + fetch-depth: 1 + + - name: Apply review fixes + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + def cut(text: str, start_marker: str, end_marker: str, replacement: str, label: str) -> str: + start = text.find(start_marker) + end = text.find(end_marker, start + 1) if start >= 0 else -1 + if start < 0 or end < 0: + raise SystemExit(f"{label}: markers missing") + return text[:start] + replacement + text[end:] + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one match, got {count}") + return text.replace(old, new, 1) + + # P1: preserve the prior composition-frame timestamp when a new native target + # arrives while the pump is already hooked. Only restart the settle deadline. + phasor_path = Path("Controls/ComtradePhasorView.cs") + phasor = phasor_path.read_text(encoding="utf-8") + phasor_start = ''' private void StartPresentationAnimation() + { + var now = Stopwatch.GetTimestamp(); + _presentationAnimationStartedTimestamp = now; + if (_presentationRenderingHooked) return; + _lastPresentationTimestamp = now; + CompositionTarget.Rendering += PresentationCompositionFrame; + _presentationRenderingHooked = true; + } + + '''.replace('\n ', '\n') + phasor = cut( + phasor, + " private void StartPresentationAnimation()", + "private void StopPresentationAnimation()", + phasor_start, + "phasor StartPresentationAnimation") + phasor_path.write_text(phasor, encoding="utf-8", newline="\n") + + # P2: keep immutable native spectra as targets, but animate mutable numeric buffers. + # Static brushes/labels/arrays are rebuilt only on topology changes, never at 60 Hz. + harmonic_path = Path("Controls/ComtradeHarmonicsWorkstationView.cs") + harmonic = harmonic_path.read_text(encoding="utf-8") + + harmonic = replace_once( + harmonic, + ''' private const double PresentationTimeConstantMs = 92.0; + private const double PresentationAnimationMaximumMs = 300.0; + private IReadOnlyList _spectra = Array.Empty(); + private ComtradeHarmonicOverviewSpectrum[] _targetSpectra = Array.Empty(); + private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); + private bool _presentationRenderingHooked; + private long _lastPresentationTimestamp; + private long _presentationAnimationStartedTimestamp; + '''.replace(' ', ''), + ''' private const double PresentationTimeConstantMs = 92.0; + private const double PresentationAnimationMaximumMs = 300.0; + private ComtradeHarmonicOverviewSpectrum[] _targetSpectra = Array.Empty(); + private bool _presentationRenderingHooked; + private long _lastPresentationTimestamp; + private long _presentationAnimationStartedTimestamp; + '''.replace(' ', ''), + "harmonic fields") + + new_show = ''' internal void ShowSpectra( + string title, + string subtitle, + IReadOnlyList spectra) + { + _title = title ?? string.Empty; + _subtitle = subtitle ?? string.Empty; + _targetSpectra = SnapshotSpectra(spectra ?? Array.Empty()); + + var targetMaximumOrder = ResolveMaximumDisplayedOrder(_targetSpectra); + var topologyMatches = PreparedRowsMatchTopology(_preparedRows, _targetSpectra, targetMaximumOrder); + _maximumDisplayedOrder = targetMaximumOrder; + _selectedOrder = Math.Clamp(_selectedOrder, 0, Math.Max(0, _maximumDisplayedOrder)); + + if (!topologyMatches) + { + _preparedRows = PrepareRows(_targetSpectra, _maximumDisplayedOrder); + StopPresentationAnimation(); + } + else + { + UpdatePreparedMetadata(_preparedRows, _targetSpectra); + if (PreparedRowsDifferFromTarget(_preparedRows, _targetSpectra, _maximumDisplayedOrder)) + StartPresentationAnimation(); + else + StopPresentationAnimation(); + } + + _rowTargets.Clear(); + InvalidateVisual(); + } + + '''.replace('\n ', '\n') + harmonic = cut( + harmonic, + " internal void ShowSpectra(", + " internal void ShowMessage(", + new_show, + "harmonic ShowSpectra") + + new_message = ''' internal void ShowMessage(string title, string message) + { + _title = title ?? string.Empty; + _subtitle = message ?? string.Empty; + StopPresentationAnimation(); + _targetSpectra = Array.Empty(); + _preparedRows = Array.Empty(); + _maximumDisplayedOrder = -1; + _rowTargets.Clear(); + InvalidateVisual(); + } + + '''.replace('\n ', '\n') + harmonic = cut( + harmonic, + " internal void ShowMessage(", + " protected override void OnRender(", + new_message, + "harmonic ShowMessage") + + harmonic = replace_once(harmonic, "prepared.ThdLabel", "BuildThdLabel(prepared.ThdPercent)", "THD draw") + harmonic = replace_once( + harmonic, + "prepared.AxisMaximum, prepared.AxisTopLabel, dpi", + "prepared.AxisMaximum, BuildAxisTopLabel(prepared.AxisMaximum, prepared.AxisUnitSuffix), dpi", + "axis label draw") + harmonic = replace_once(harmonic, "bin.PercentLabel", "BuildPercentLabel(bin.PercentOfFundamental)", "percent draw") + harmonic = replace_once(harmonic, "bin.MagnitudeLabel", "FormatEngineering(bin.MagnitudeRms)", "magnitude draw") + + helpers = ''' private void StartPresentationAnimation() + { + var now = Stopwatch.GetTimestamp(); + _presentationAnimationStartedTimestamp = now; + if (_presentationRenderingHooked) return; + _lastPresentationTimestamp = now; + CompositionTarget.Rendering += PresentationCompositionFrame; + _presentationRenderingHooked = true; + } + + private void StopPresentationAnimation() + { + if (_presentationRenderingHooked) + { + CompositionTarget.Rendering -= PresentationCompositionFrame; + _presentationRenderingHooked = false; + } + _lastPresentationTimestamp = 0; + _presentationAnimationStartedTimestamp = 0; + } + + private void PresentationCompositionFrame(object? sender, EventArgs e) + { + if (!_presentationRenderingHooked) return; + + var now = Stopwatch.GetTimestamp(); + var elapsedMilliseconds = _lastPresentationTimestamp == 0 + ? 16.0 + : Math.Clamp(Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds, 0.0, 50.0); + _lastPresentationTimestamp = now; + + AdvancePreparedRows(_preparedRows, _targetSpectra, _maximumDisplayedOrder, elapsedMilliseconds); + + var animationAgeMilliseconds = _presentationAnimationStartedTimestamp == 0 + ? PresentationAnimationMaximumMs + : Stopwatch.GetElapsedTime(_presentationAnimationStartedTimestamp, now).TotalMilliseconds; + if (animationAgeMilliseconds >= PresentationAnimationMaximumMs) + { + SnapPreparedRowsToTarget(_preparedRows, _targetSpectra, _maximumDisplayedOrder); + StopPresentationAnimation(); + } + + InvalidateVisual(); + } + + private static ComtradeHarmonicOverviewSpectrum[] SnapshotSpectra( + IReadOnlyList source) + { + if (source.Count == 0) + return Array.Empty(); + + var result = new ComtradeHarmonicOverviewSpectrum[source.Count]; + for (var index = 0; index < source.Count; index++) + result[index] = source[index]; + return result; + } + + private static bool PreparedRowsMatchTopology( + IReadOnlyList rows, + IReadOnlyList target, + int maximumOrder) + { + if (maximumOrder < 0) + return rows.Count == 0 && target.Count == 0; + if (rows.Count != target.Count) + return false; + + for (var index = 0; index < target.Count; index++) + { + var row = rows[index]; + var spectrum = target[index]; + if (!string.Equals(row.SignalName, spectrum.SignalName, StringComparison.Ordinal) || + !string.Equals(row.Units, spectrum.Units, StringComparison.Ordinal) || + row.Bins.Length != maximumOrder + 1) + return false; + } + + return true; + } + + private static PreparedSpectrumRow[] PrepareRows( + IReadOnlyList spectra, + int maximumOrder) + { + if (spectra.Count == 0 || maximumOrder < 0) + return Array.Empty(); + + var rows = new PreparedSpectrumRow[spectra.Count]; + for (var index = 0; index < spectra.Count; index++) + { + var spectrum = spectra[index]; + var signalColor = SignalColor(spectrum.SignalName); + var unitSuffix = string.IsNullOrWhiteSpace(spectrum.Units) ? string.Empty : $"/{spectrum.Units}"; + var axisUnitSuffix = string.IsNullOrWhiteSpace(spectrum.Units) ? string.Empty : $" {spectrum.Units}"; + var row = new PreparedSpectrumRow( + spectrum.SignalName, + spectrum.Units, + new PlotBin[maximumOrder + 1], + FreezeBrush(signalColor), + FreezeBrush(WithAlpha(signalColor, 190)), + $"{spectrum.SignalName}{unitSuffix}", + axisUnitSuffix, + spectrum.EstimatedSampleRateHz, + BuildSampleRateLabel(spectrum.EstimatedSampleRateHz)); + rows[index] = row; + SnapPreparedRowToTarget(row, spectrum, maximumOrder); + } + + return rows; + } + + private static void UpdatePreparedMetadata( + IReadOnlyList rows, + IReadOnlyList target) + { + var count = Math.Min(rows.Count, target.Count); + for (var index = 0; index < count; index++) + { + rows[index].EstimatedSampleRateHz = target[index].EstimatedSampleRateHz; + rows[index].SampleRateLabel = BuildSampleRateLabel(target[index].EstimatedSampleRateHz); + } + } + + private static void AdvancePreparedRows( + IReadOnlyList rows, + IReadOnlyList target, + int maximumOrder, + double elapsedMilliseconds) + { + var count = Math.Min(rows.Count, target.Count); + for (var index = 0; index < count; index++) + AdvancePreparedRow(rows[index], target[index], maximumOrder, elapsedMilliseconds); + } + + private static void AdvancePreparedRow( + PreparedSpectrumRow row, + ComtradeHarmonicOverviewSpectrum target, + int maximumOrder, + double elapsedMilliseconds) + { + var targetFundamental = DisplayMagnitude(target.FundamentalRms); + var targetThd = DisplayScalar(target.ThdPercent); + row.FundamentalRms = PresentationEasingMath.Smooth( + row.FundamentalRms, targetFundamental, elapsedMilliseconds, PresentationTimeConstantMs); + row.ThdPercent = PresentationEasingMath.Smooth( + row.ThdPercent, targetThd, elapsedMilliseconds, PresentationTimeConstantMs); + + var maximumMagnitude = 0.0; + for (var order = 0; order <= maximumOrder; order++) + { + ResolveTargetBin(target, order, out var targetMagnitude, out var targetAngle); + var bin = row.Bins[order]; + var magnitude = order == 1 + ? row.FundamentalRms + : PresentationEasingMath.Smooth( + bin.MagnitudeRms, targetMagnitude, elapsedMilliseconds, PresentationTimeConstantMs); + var angle = PresentationEasingMath.SmoothAngleDegrees( + bin.AngleDegrees, targetAngle, elapsedMilliseconds, PresentationTimeConstantMs); + var percent = order == 1 && row.FundamentalRms > 0.0 + ? 100.0 + : ComtradeHarmonicsOverviewMath.PercentOfFundamental(magnitude, row.FundamentalRms); + row.Bins[order] = new PlotBin(order, magnitude, percent, angle); + maximumMagnitude = Math.Max(maximumMagnitude, magnitude); + } + + row.AxisMaximum = ComtradeHarmonicsOverviewMath.NiceMagnitudeAxisMaximum(maximumMagnitude); + } + + private static void SnapPreparedRowsToTarget( + IReadOnlyList rows, + IReadOnlyList target, + int maximumOrder) + { + var count = Math.Min(rows.Count, target.Count); + for (var index = 0; index < count; index++) + SnapPreparedRowToTarget(rows[index], target[index], maximumOrder); + } + + private static void SnapPreparedRowToTarget( + PreparedSpectrumRow row, + ComtradeHarmonicOverviewSpectrum target, + int maximumOrder) + { + row.FundamentalRms = DisplayMagnitude(target.FundamentalRms); + row.ThdPercent = DisplayScalar(target.ThdPercent); + + var maximumMagnitude = 0.0; + for (var order = 0; order <= maximumOrder; order++) + { + ResolveTargetBin(target, order, out var magnitude, out var angle); + if (order == 1) + magnitude = row.FundamentalRms; + var percent = order == 1 && row.FundamentalRms > 0.0 + ? 100.0 + : ComtradeHarmonicsOverviewMath.PercentOfFundamental(magnitude, row.FundamentalRms); + row.Bins[order] = new PlotBin(order, magnitude, percent, angle); + maximumMagnitude = Math.Max(maximumMagnitude, magnitude); + } + + row.AxisMaximum = ComtradeHarmonicsOverviewMath.NiceMagnitudeAxisMaximum(maximumMagnitude); + } + + private static bool PreparedRowsDifferFromTarget( + IReadOnlyList rows, + IReadOnlyList target, + int maximumOrder) + { + if (!PreparedRowsMatchTopology(rows, target, maximumOrder)) + return true; + + for (var index = 0; index < target.Count; index++) + { + var row = rows[index]; + var spectrum = target[index]; + if (!row.FundamentalRms.Equals(DisplayMagnitude(spectrum.FundamentalRms)) || + !row.ThdPercent.Equals(DisplayScalar(spectrum.ThdPercent))) + return true; + + for (var order = 0; order <= maximumOrder; order++) + { + ResolveTargetBin(spectrum, order, out var targetMagnitude, out var targetAngle); + if (order == 1) + targetMagnitude = DisplayMagnitude(spectrum.FundamentalRms); + var bin = row.Bins[order]; + if (!bin.MagnitudeRms.Equals(targetMagnitude) || + !PresentationEasingMath.NormalizeAngleDegrees(bin.AngleDegrees) + .Equals(PresentationEasingMath.NormalizeAngleDegrees(targetAngle))) + return true; + } + } + + return false; + } + + private static void ResolveTargetBin( + ComtradeHarmonicOverviewSpectrum spectrum, + int order, + out double magnitude, + out double angleDegrees) + { + if (order == 0) + { + magnitude = Math.Abs(DisplayScalar(spectrum.DcComponent)); + angleDegrees = 0.0; + return; + } + + magnitude = order == 1 ? DisplayMagnitude(spectrum.FundamentalRms) : 0.0; + angleDegrees = 0.0; + for (var index = 0; index < spectrum.Bins.Count; index++) + { + var source = spectrum.Bins[index]; + if (source.Order != order) + continue; + if (order != 1) + magnitude = DisplayMagnitude(source.MagnitudeRms); + angleDegrees = double.IsFinite(source.AngleDegrees) + ? PresentationEasingMath.NormalizeAngleDegrees(source.AngleDegrees) + : 0.0; + return; + } + } + + private static double DisplayMagnitude(double value) + => double.IsFinite(value) ? Math.Max(0.0, value) : 0.0; + + private static double DisplayScalar(double value) + => double.IsFinite(value) ? value : 0.0; + + private static string BuildThdLabel(double thdPercent) + => $"THD {thdPercent:G4}%"; + + private static string BuildAxisTopLabel(double axisMaximum, string axisUnitSuffix) + => $"{FormatEngineering(axisMaximum)}{axisUnitSuffix}"; + + private static string BuildPercentLabel(double percent) + => $"{percent:0.#}%"; + + private static string BuildSampleRateLabel(double rate) + => rate > 0.0 && double.IsFinite(rate) ? $"{rate:G6} Hz" : string.Empty; + + '''.replace('\n ', '\n') + + harmonic = cut( + harmonic, + " private void StartPresentationAnimation()", + " private static int ResolveMaximumDisplayedOrder(", + helpers, + "harmonic presentation helper block") + + record_block = ''' private readonly record struct PlotBin( + int Order, + double MagnitudeRms, + double PercentOfFundamental, + double AngleDegrees); + + private sealed class PreparedSpectrumRow + { + internal PreparedSpectrumRow( + string signalName, + string units, + PlotBin[] bins, + Brush signalBrush, + Brush secondarySignalBrush, + string signalLabel, + string axisUnitSuffix, + double estimatedSampleRateHz, + string sampleRateLabel) + { + SignalName = signalName ?? string.Empty; + Units = units ?? string.Empty; + Bins = bins; + SignalBrush = signalBrush; + SecondarySignalBrush = secondarySignalBrush; + SignalLabel = signalLabel; + AxisUnitSuffix = axisUnitSuffix; + EstimatedSampleRateHz = estimatedSampleRateHz; + SampleRateLabel = sampleRateLabel; + } + + internal string SignalName { get; } + internal string Units { get; } + internal PlotBin[] Bins { get; } + internal Brush SignalBrush { get; } + internal Brush SecondarySignalBrush { get; } + internal string SignalLabel { get; } + internal string AxisUnitSuffix { get; } + internal double FundamentalRms { get; set; } + internal double ThdPercent { get; set; } + internal double AxisMaximum { get; set; } = 1.0; + internal double EstimatedSampleRateHz { get; set; } + internal string SampleRateLabel { get; set; } + } + + '''.replace('\n ', '\n') + harmonic = cut( + harmonic, + " private readonly record struct PlotBin(", + " private readonly record struct RowHitTarget(", + record_block, + "harmonic prepared records") + + old_summary = '''/// Render-path rule: spectrum normalization, harmonic lookup, engineering-value formatting and +/// brush creation happen only when ShowSpectra receives a new immutable result. OnRender consumes +/// prepared rows and only performs geometry/text drawing required for the current element size. +''' + new_summary = '''/// Render-path rule: immutable native spectra remain the engineering targets. Static row assets are +/// prepared only when channel topology changes; composition frames mutate reusable numeric buffers +/// and OnRender draws the current presentation without rebuilding brushes or per-bin arrays. +''' + harmonic = replace_once(harmonic, old_summary, new_summary, "harmonic summary") + harmonic_path.write_text(harmonic, encoding="utf-8", newline="\n") + + # Keep a source-level regression for both review findings and the existing scrub scheduler. + test_path = Path("tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs") + test_path.write_text('''namespace ARSAS.Tests; + +public sealed class ComtradePresentationAnimationRegressionTests +{ + [Fact] + public void PresentationViews_AnimateAtCompositionCadence_AndPreserveFrameClockAcrossRetargets() + { + var phasor = File.ReadAllText(FindRepoFile("Controls/ComtradePhasorView.cs")); + var harmonic = File.ReadAllText(FindRepoFile("Controls/ComtradeHarmonicsWorkstationView.cs")); + + Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", phasor, StringComparison.Ordinal); + Assert.Contains("_presentationAnimationStartedTimestamp = now;", phasor, StringComparison.Ordinal); + Assert.Contains("if (_presentationRenderingHooked) return;", phasor, StringComparison.Ordinal); + Assert.Contains("_lastPresentationTimestamp = now;", phasor, StringComparison.Ordinal); + + Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", harmonic, StringComparison.Ordinal); + Assert.Contains("AdvancePreparedRows(_preparedRows, _targetSpectra", harmonic, StringComparison.Ordinal); + Assert.Contains("PreparedRowsMatchTopology", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("RefreshPreparedSpectra", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("SmoothSpectra", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherTimer", phasor, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherTimer", harmonic, StringComparison.Ordinal); + } + + [Fact] + public void NativeScrub_RemainsLatestWinsSingleWorker() + { + var source = File.ReadAllText(FindRepoFile("ComtradeWorkspaceWindow.P1D4LiveScrub.cs")); + Assert.Contains("if (_p1d4ScrubWorkerRunning || !_p1d4ScrubDirty)", source, StringComparison.Ordinal); + Assert.Contains("_p1d4ScrubWorkerRunning = true", source, StringComparison.Ordinal); + Assert.Contains("if (_p1d4ScrubDirty && _analysisMode != AnalysisMode.Waveform)", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} +''', encoding="utf-8", newline="\n") + + for path in (phasor_path, harmonic_path, test_path): + text = path.read_text(encoding="utf-8") + if "DispatcherTimer" in text and path != test_path: + raise SystemExit(f"{path}: DispatcherTimer forbidden") + + # Strong P1 ordering check: retarget deadline first, hooked guard second, frame clock third. + for path in (phasor_path, harmonic_path): + text = path.read_text(encoding="utf-8") + start = text.index("private void StartPresentationAnimation()") + block = text[start:text.index("private void StopPresentationAnimation()", start)] + a = block.index("_presentationAnimationStartedTimestamp = now;") + b = block.index("if (_presentationRenderingHooked) return;") + c = block.index("_lastPresentationTimestamp = now;") + if not (a < b < c): + raise SystemExit(f"{path}: frame clock ordering regression") + + harmonic_text = harmonic_path.read_text(encoding="utf-8") + for forbidden in ("RefreshPreparedSpectra", "SmoothSpectra", "_smoothedSpectra"): + if forbidden in harmonic_text: + raise SystemExit(f"harmonic hot-path allocation helper still present: {forbidden}") + PY + + git diff --check + + - name: Commit review fixes + shell: bash + run: | + git rm .github/workflows/fix-comtrade-review-findings.yml + git add Controls/ComtradePhasorView.cs Controls/ComtradeHarmonicsWorkstationView.cs tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs + git diff --cached --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "fix(comtrade): preserve frame clock and reuse harmonic buffers" + git push origin HEAD:fix/v1636-comtrade-interaction-easing From 58a662af93b506d26b176e35e829b8c855dd7b03 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:48:16 +0700 Subject: [PATCH 04/12] ci: stage COMTRADE review-fix script --- .../scripts/fix_comtrade_review_findings.py | 530 ++++++++++++++++++ 1 file changed, 530 insertions(+) create mode 100644 .github/scripts/fix_comtrade_review_findings.py diff --git a/.github/scripts/fix_comtrade_review_findings.py b/.github/scripts/fix_comtrade_review_findings.py new file mode 100644 index 000000000..1ebe72f00 --- /dev/null +++ b/.github/scripts/fix_comtrade_review_findings.py @@ -0,0 +1,530 @@ +from pathlib import Path + + +def cut(text: str, start_marker: str, end_marker: str, replacement: str, label: str) -> str: + start = text.find(start_marker) + end = text.find(end_marker, start + 1) if start >= 0 else -1 + if start < 0 or end < 0: + raise SystemExit(f"{label}: markers missing") + return text[:start] + replacement + text[end:] + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one match, got {count}") + return text.replace(old, new, 1) + + +# P1: preserve composition-frame elapsed time when a fresh native target arrives +# while the animation pump is already active. A retarget restarts only the settle +# deadline, not the previous-frame clock. +phasor_path = Path("Controls/ComtradePhasorView.cs") +phasor = phasor_path.read_text(encoding="utf-8") +phasor_start = """ private void StartPresentationAnimation() + { + var now = Stopwatch.GetTimestamp(); + _presentationAnimationStartedTimestamp = now; + if (_presentationRenderingHooked) return; + _lastPresentationTimestamp = now; + CompositionTarget.Rendering += PresentationCompositionFrame; + _presentationRenderingHooked = true; + } + +""" +phasor = cut( + phasor, + " private void StartPresentationAnimation()", + "private void StopPresentationAnimation()", + phasor_start, + "phasor StartPresentationAnimation") +phasor_path.write_text(phasor, encoding="utf-8", newline="\n") + + +# P2: harmonics must not rebuild arrays, spectrum/bin records, brushes or labels +# at composition cadence. Native results update exact target buffers only when a +# new analysis result arrives; rendering frames mutate only reusable numeric bins. +harmonic_path = Path("Controls/ComtradeHarmonicsWorkstationView.cs") +harmonic = harmonic_path.read_text(encoding="utf-8") + +harmonic = replace_once( + harmonic, + """ private const double PresentationTimeConstantMs = 92.0; + private const double PresentationAnimationMaximumMs = 300.0; + private IReadOnlyList _spectra = Array.Empty(); + private ComtradeHarmonicOverviewSpectrum[] _targetSpectra = Array.Empty(); + private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); + private bool _presentationRenderingHooked; + private long _lastPresentationTimestamp; + private long _presentationAnimationStartedTimestamp; +""", + """ private const double PresentationTimeConstantMs = 92.0; + private const double PresentationAnimationMaximumMs = 300.0; + private bool _presentationRenderingHooked; + private long _lastPresentationTimestamp; + private long _presentationAnimationStartedTimestamp; +""", + "harmonic fields") + +new_show = """ internal void ShowSpectra( + string title, + string subtitle, + IReadOnlyList spectra) + { + _title = title ?? string.Empty; + _subtitle = subtitle ?? string.Empty; + var targetSpectra = spectra ?? Array.Empty(); + var targetMaximumOrder = ResolveMaximumDisplayedOrder(targetSpectra); + var topologyMatches = PreparedRowsMatchTopology(_preparedRows, targetSpectra, targetMaximumOrder); + + _maximumDisplayedOrder = targetMaximumOrder; + _selectedOrder = Math.Clamp(_selectedOrder, 0, Math.Max(0, _maximumDisplayedOrder)); + + if (!topologyMatches) + { + _preparedRows = PrepareRows(targetSpectra, _maximumDisplayedOrder); + StopPresentationAnimation(); + } + else + { + UpdatePreparedTargets(_preparedRows, targetSpectra, _maximumDisplayedOrder); + if (PreparedRowsDifferFromTarget(_preparedRows)) + StartPresentationAnimation(); + else + StopPresentationAnimation(); + } + + _rowTargets.Clear(); + InvalidateVisual(); + } + +""" +harmonic = cut( + harmonic, + " internal void ShowSpectra(", + " internal void ShowMessage(", + new_show, + "harmonic ShowSpectra") + +new_message = """ internal void ShowMessage(string title, string message) + { + _title = title ?? string.Empty; + _subtitle = message ?? string.Empty; + StopPresentationAnimation(); + _preparedRows = Array.Empty(); + _maximumDisplayedOrder = -1; + _rowTargets.Clear(); + InvalidateVisual(); + } + +""" +harmonic = cut( + harmonic, + " internal void ShowMessage(", + " protected override void OnRender(", + new_message, + "harmonic ShowMessage") + +# Draw exact target labels cached at native-result cadence while only geometry eases. +harmonic = replace_once(harmonic, "prepared.ThdLabel", "prepared.TargetThdLabel", "THD draw") +harmonic = replace_once( + harmonic, + "prepared.AxisMaximum, prepared.AxisTopLabel, dpi", + "prepared.AxisMaximum, prepared.TargetAxisTopLabel, dpi", + "axis label draw") +harmonic = replace_once(harmonic, "bin.PercentLabel", "prepared.TargetPercentLabels[order]", "percent draw") +harmonic = replace_once(harmonic, "bin.MagnitudeLabel", "prepared.TargetMagnitudeLabels[order]", "magnitude draw") + +helpers = """ private void StartPresentationAnimation() + { + var now = Stopwatch.GetTimestamp(); + _presentationAnimationStartedTimestamp = now; + if (_presentationRenderingHooked) return; + _lastPresentationTimestamp = now; + CompositionTarget.Rendering += PresentationCompositionFrame; + _presentationRenderingHooked = true; + } + + private void StopPresentationAnimation() + { + if (_presentationRenderingHooked) + { + CompositionTarget.Rendering -= PresentationCompositionFrame; + _presentationRenderingHooked = false; + } + _lastPresentationTimestamp = 0; + _presentationAnimationStartedTimestamp = 0; + } + + private void PresentationCompositionFrame(object? sender, EventArgs e) + { + if (!_presentationRenderingHooked) return; + + var now = Stopwatch.GetTimestamp(); + var elapsedMilliseconds = _lastPresentationTimestamp == 0 + ? 16.0 + : Math.Clamp(Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds, 0.0, 50.0); + _lastPresentationTimestamp = now; + + AdvancePreparedRows(_preparedRows, elapsedMilliseconds); + + var animationAgeMilliseconds = _presentationAnimationStartedTimestamp == 0 + ? PresentationAnimationMaximumMs + : Stopwatch.GetElapsedTime(_presentationAnimationStartedTimestamp, now).TotalMilliseconds; + if (animationAgeMilliseconds >= PresentationAnimationMaximumMs) + { + SnapPreparedRowsToTarget(_preparedRows); + StopPresentationAnimation(); + } + + InvalidateVisual(); + } + + private static bool PreparedRowsMatchTopology( + IReadOnlyList rows, + IReadOnlyList target, + int maximumOrder) + { + if (maximumOrder < 0) + return rows.Count == 0 && target.Count == 0; + if (rows.Count != target.Count) + return false; + + for (var index = 0; index < target.Count; index++) + { + var row = rows[index]; + var spectrum = target[index]; + if (!string.Equals(row.SignalName, spectrum.SignalName, StringComparison.Ordinal) || + !string.Equals(row.Units, spectrum.Units, StringComparison.Ordinal) || + row.Bins.Length != maximumOrder + 1) + return false; + } + + return true; + } + + private static PreparedSpectrumRow[] PrepareRows( + IReadOnlyList spectra, + int maximumOrder) + { + if (spectra.Count == 0 || maximumOrder < 0) + return Array.Empty(); + + var rows = new PreparedSpectrumRow[spectra.Count]; + for (var index = 0; index < spectra.Count; index++) + { + var spectrum = spectra[index]; + var signalColor = SignalColor(spectrum.SignalName); + var unitSuffix = string.IsNullOrWhiteSpace(spectrum.Units) ? string.Empty : $"/{spectrum.Units}"; + var row = new PreparedSpectrumRow( + spectrum.SignalName, + spectrum.Units, + new PlotBin[maximumOrder + 1], + new double[maximumOrder + 1], + new double[maximumOrder + 1], + new string[maximumOrder + 1], + new string[maximumOrder + 1], + FreezeBrush(signalColor), + FreezeBrush(WithAlpha(signalColor, 190)), + $"{spectrum.SignalName}{unitSuffix}"); + rows[index] = row; + UpdatePreparedTarget(row, spectrum, maximumOrder); + SnapPreparedRowToTarget(row); + } + + return rows; + } + + private static void UpdatePreparedTargets( + IReadOnlyList rows, + IReadOnlyList target, + int maximumOrder) + { + var count = Math.Min(rows.Count, target.Count); + for (var index = 0; index < count; index++) + UpdatePreparedTarget(rows[index], target[index], maximumOrder); + } + + private static void UpdatePreparedTarget( + PreparedSpectrumRow row, + ComtradeHarmonicOverviewSpectrum target, + int maximumOrder) + { + var targetFundamental = DisplayMagnitude(target.FundamentalRms); + var maximumMagnitude = 0.0; + + for (var order = 0; order <= maximumOrder; order++) + { + ResolveTargetBin(target, order, targetFundamental, out var magnitude, out var angleDegrees); + var percent = order == 1 && targetFundamental > 0.0 + ? 100.0 + : ComtradeHarmonicsOverviewMath.PercentOfFundamental(magnitude, targetFundamental); + + row.TargetMagnitudes[order] = magnitude; + row.TargetAngles[order] = angleDegrees; + row.TargetPercentLabels[order] = $"{percent:0.#}%"; + row.TargetMagnitudeLabels[order] = FormatEngineering(magnitude); + maximumMagnitude = Math.Max(maximumMagnitude, magnitude); + } + + row.AxisMaximum = ComtradeHarmonicsOverviewMath.NiceMagnitudeAxisMaximum(maximumMagnitude); + var axisUnit = string.IsNullOrWhiteSpace(target.Units) ? string.Empty : $" {target.Units}"; + row.TargetAxisTopLabel = $"{FormatEngineering(row.AxisMaximum)}{axisUnit}"; + row.TargetThdLabel = $"THD {DisplayScalar(target.ThdPercent):G4}%"; + row.EstimatedSampleRateHz = target.EstimatedSampleRateHz; + row.SampleRateLabel = target.EstimatedSampleRateHz > 0.0 && double.IsFinite(target.EstimatedSampleRateHz) + ? $"{target.EstimatedSampleRateHz:G6} Hz" + : string.Empty; + } + + private static void ResolveTargetBin( + ComtradeHarmonicOverviewSpectrum spectrum, + int order, + double fundamentalRms, + out double magnitude, + out double angleDegrees) + { + if (order == 0) + { + magnitude = Math.Abs(DisplayScalar(spectrum.DcComponent)); + angleDegrees = 0.0; + return; + } + + magnitude = order == 1 ? fundamentalRms : 0.0; + angleDegrees = 0.0; + for (var index = 0; index < spectrum.Bins.Count; index++) + { + var source = spectrum.Bins[index]; + if (source.Order != order) + continue; + if (order != 1) + magnitude = DisplayMagnitude(source.MagnitudeRms); + angleDegrees = double.IsFinite(source.AngleDegrees) + ? PresentationEasingMath.NormalizeAngleDegrees(source.AngleDegrees) + : 0.0; + return; + } + } + + private static void AdvancePreparedRows( + IReadOnlyList rows, + double elapsedMilliseconds) + { + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + var row = rows[rowIndex]; + for (var order = 0; order < row.Bins.Length; order++) + { + var before = row.Bins[order]; + row.Bins[order] = new PlotBin( + order, + PresentationEasingMath.Smooth( + before.MagnitudeRms, + row.TargetMagnitudes[order], + elapsedMilliseconds, + PresentationTimeConstantMs), + PresentationEasingMath.SmoothAngleDegrees( + before.AngleDegrees, + row.TargetAngles[order], + elapsedMilliseconds, + PresentationTimeConstantMs)); + } + } + } + + private static void SnapPreparedRowsToTarget(IReadOnlyList rows) + { + for (var index = 0; index < rows.Count; index++) + SnapPreparedRowToTarget(rows[index]); + } + + private static void SnapPreparedRowToTarget(PreparedSpectrumRow row) + { + for (var order = 0; order < row.Bins.Length; order++) + row.Bins[order] = new PlotBin(order, row.TargetMagnitudes[order], row.TargetAngles[order]); + } + + private static bool PreparedRowsDifferFromTarget(IReadOnlyList rows) + { + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + var row = rows[rowIndex]; + for (var order = 0; order < row.Bins.Length; order++) + { + var bin = row.Bins[order]; + if (!bin.MagnitudeRms.Equals(row.TargetMagnitudes[order]) || + !PresentationEasingMath.NormalizeAngleDegrees(bin.AngleDegrees) + .Equals(PresentationEasingMath.NormalizeAngleDegrees(row.TargetAngles[order]))) + return true; + } + } + return false; + } + + private static double DisplayMagnitude(double value) + => double.IsFinite(value) ? Math.Max(0.0, value) : 0.0; + + private static double DisplayScalar(double value) + => double.IsFinite(value) ? value : 0.0; + +""" +harmonic = cut( + harmonic, + " private void StartPresentationAnimation()", + " private static int ResolveMaximumDisplayedOrder(", + helpers, + "harmonic presentation helper block") + +record_block = """ private readonly record struct PlotBin( + int Order, + double MagnitudeRms, + double AngleDegrees); + + private sealed class PreparedSpectrumRow + { + internal PreparedSpectrumRow( + string signalName, + string units, + PlotBin[] bins, + double[] targetMagnitudes, + double[] targetAngles, + string[] targetPercentLabels, + string[] targetMagnitudeLabels, + Brush signalBrush, + Brush secondarySignalBrush, + string signalLabel) + { + SignalName = signalName ?? string.Empty; + Units = units ?? string.Empty; + Bins = bins; + TargetMagnitudes = targetMagnitudes; + TargetAngles = targetAngles; + TargetPercentLabels = targetPercentLabels; + TargetMagnitudeLabels = targetMagnitudeLabels; + SignalBrush = signalBrush; + SecondarySignalBrush = secondarySignalBrush; + SignalLabel = signalLabel; + } + + internal string SignalName { get; } + internal string Units { get; } + internal PlotBin[] Bins { get; } + internal double[] TargetMagnitudes { get; } + internal double[] TargetAngles { get; } + internal string[] TargetPercentLabels { get; } + internal string[] TargetMagnitudeLabels { get; } + internal Brush SignalBrush { get; } + internal Brush SecondarySignalBrush { get; } + internal string SignalLabel { get; } + internal double AxisMaximum { get; set; } = 1.0; + internal string TargetAxisTopLabel { get; set; } = string.Empty; + internal string TargetThdLabel { get; set; } = string.Empty; + internal double EstimatedSampleRateHz { get; set; } + internal string SampleRateLabel { get; set; } = string.Empty; + } + +""" +harmonic = cut( + harmonic, + " private readonly record struct PlotBin(", + " private readonly record struct RowHitTarget(", + record_block, + "harmonic prepared records") + +old_summary = """/// Render-path rule: spectrum normalization, harmonic lookup, engineering-value formatting and +/// brush creation happen only when ShowSpectra receives a new immutable result. OnRender consumes +/// prepared rows and only performs geometry/text drawing required for the current element size. +""" +new_summary = """/// Render-path rule: native results update exact cached targets at analysis cadence. Static row +/// assets and reusable numeric buffers are rebuilt only on topology changes; composition frames +/// mutate only numeric presentation values and never rebuild brushes, labels or per-bin arrays. +""" +harmonic = replace_once(harmonic, old_summary, new_summary, "harmonic summary") +harmonic_path.write_text(harmonic, encoding="utf-8", newline="\n") + + +# Regression locks P1 frame-clock ordering, P2 reusable harmonic buffers, and +# the existing latest-wins single-worker native scheduler. +test_path = Path("tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs") +test_path.write_text( + """namespace ARSAS.Tests; + +public sealed class ComtradePresentationAnimationRegressionTests +{ + [Fact] + public void PresentationViews_PreserveFrameClock_AndReuseHarmonicBuffers() + { + var phasor = File.ReadAllText(FindRepoFile("Controls/ComtradePhasorView.cs")); + var harmonic = File.ReadAllText(FindRepoFile("Controls/ComtradeHarmonicsWorkstationView.cs")); + + Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", phasor, StringComparison.Ordinal); + Assert.Contains("_presentationAnimationStartedTimestamp = now;", phasor, StringComparison.Ordinal); + Assert.Contains("if (_presentationRenderingHooked) return;", phasor, StringComparison.Ordinal); + + Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", harmonic, StringComparison.Ordinal); + Assert.Contains("AdvancePreparedRows(_preparedRows, elapsedMilliseconds)", harmonic, StringComparison.Ordinal); + Assert.Contains("TargetMagnitudes", harmonic, StringComparison.Ordinal); + Assert.Contains("TargetMagnitudeLabels", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("RefreshPreparedSpectra", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("SmoothSpectra", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("_smoothedSpectra", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherTimer", phasor, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherTimer", harmonic, StringComparison.Ordinal); + } + + [Fact] + public void NativeScrub_RemainsLatestWinsSingleWorker() + { + var source = File.ReadAllText(FindRepoFile("ComtradeWorkspaceWindow.P1D4LiveScrub.cs")); + Assert.Contains("if (_p1d4ScrubWorkerRunning || !_p1d4ScrubDirty)", source, StringComparison.Ordinal); + Assert.Contains("_p1d4ScrubWorkerRunning = true", source, StringComparison.Ordinal); + Assert.Contains("if (_p1d4ScrubDirty && _analysisMode != AnalysisMode.Waveform)", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} +""", + encoding="utf-8", + newline="\n") + + +# Fail closed if the intended hot-path architecture did not land. +for path in (phasor_path, harmonic_path): + source = path.read_text(encoding="utf-8") + start = source.index("private void StartPresentationAnimation()") + stop = source.index("private void StopPresentationAnimation()", start) + block = source[start:stop] + deadline = block.index("_presentationAnimationStartedTimestamp = now;") + hooked = block.index("if (_presentationRenderingHooked) return;") + frame_clock = block.index("_lastPresentationTimestamp = now;") + if not deadline < hooked < frame_clock: + raise SystemExit(f"{path}: retarget/frame-clock ordering regression") + if "DispatcherTimer" in source: + raise SystemExit(f"{path}: DispatcherTimer forbidden") + +harmonic_source = harmonic_path.read_text(encoding="utf-8") +for forbidden in ("RefreshPreparedSpectra", "SmoothSpectra", "_smoothedSpectra"): + if forbidden in harmonic_source: + raise SystemExit(f"harmonic hot-path allocation helper still present: {forbidden}") + +required = ( + "AdvancePreparedRows(_preparedRows, elapsedMilliseconds)", + "TargetMagnitudes", + "TargetAngles", + "TargetPercentLabels", + "TargetMagnitudeLabels", +) +for token in required: + if token not in harmonic_source: + raise SystemExit(f"harmonic reusable-buffer contract missing: {token}") From 13e9e0f55cca2f21817382339c0f79691e12b1c4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 15:48:29 +0700 Subject: [PATCH 05/12] ci: run staged COMTRADE review fixes --- .../fix-comtrade-review-findings.yml | 565 +----------------- 1 file changed, 4 insertions(+), 561 deletions(-) diff --git a/.github/workflows/fix-comtrade-review-findings.yml b/.github/workflows/fix-comtrade-review-findings.yml index 19948c451..7691b2e86 100644 --- a/.github/workflows/fix-comtrade-review-findings.yml +++ b/.github/workflows/fix-comtrade-review-findings.yml @@ -20,571 +20,14 @@ jobs: fetch-depth: 1 - name: Apply review fixes - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - def cut(text: str, start_marker: str, end_marker: str, replacement: str, label: str) -> str: - start = text.find(start_marker) - end = text.find(end_marker, start + 1) if start >= 0 else -1 - if start < 0 or end < 0: - raise SystemExit(f"{label}: markers missing") - return text[:start] + replacement + text[end:] - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one match, got {count}") - return text.replace(old, new, 1) - - # P1: preserve the prior composition-frame timestamp when a new native target - # arrives while the pump is already hooked. Only restart the settle deadline. - phasor_path = Path("Controls/ComtradePhasorView.cs") - phasor = phasor_path.read_text(encoding="utf-8") - phasor_start = ''' private void StartPresentationAnimation() - { - var now = Stopwatch.GetTimestamp(); - _presentationAnimationStartedTimestamp = now; - if (_presentationRenderingHooked) return; - _lastPresentationTimestamp = now; - CompositionTarget.Rendering += PresentationCompositionFrame; - _presentationRenderingHooked = true; - } - - '''.replace('\n ', '\n') - phasor = cut( - phasor, - " private void StartPresentationAnimation()", - "private void StopPresentationAnimation()", - phasor_start, - "phasor StartPresentationAnimation") - phasor_path.write_text(phasor, encoding="utf-8", newline="\n") - - # P2: keep immutable native spectra as targets, but animate mutable numeric buffers. - # Static brushes/labels/arrays are rebuilt only on topology changes, never at 60 Hz. - harmonic_path = Path("Controls/ComtradeHarmonicsWorkstationView.cs") - harmonic = harmonic_path.read_text(encoding="utf-8") - - harmonic = replace_once( - harmonic, - ''' private const double PresentationTimeConstantMs = 92.0; - private const double PresentationAnimationMaximumMs = 300.0; - private IReadOnlyList _spectra = Array.Empty(); - private ComtradeHarmonicOverviewSpectrum[] _targetSpectra = Array.Empty(); - private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); - private bool _presentationRenderingHooked; - private long _lastPresentationTimestamp; - private long _presentationAnimationStartedTimestamp; - '''.replace(' ', ''), - ''' private const double PresentationTimeConstantMs = 92.0; - private const double PresentationAnimationMaximumMs = 300.0; - private ComtradeHarmonicOverviewSpectrum[] _targetSpectra = Array.Empty(); - private bool _presentationRenderingHooked; - private long _lastPresentationTimestamp; - private long _presentationAnimationStartedTimestamp; - '''.replace(' ', ''), - "harmonic fields") - - new_show = ''' internal void ShowSpectra( - string title, - string subtitle, - IReadOnlyList spectra) - { - _title = title ?? string.Empty; - _subtitle = subtitle ?? string.Empty; - _targetSpectra = SnapshotSpectra(spectra ?? Array.Empty()); - - var targetMaximumOrder = ResolveMaximumDisplayedOrder(_targetSpectra); - var topologyMatches = PreparedRowsMatchTopology(_preparedRows, _targetSpectra, targetMaximumOrder); - _maximumDisplayedOrder = targetMaximumOrder; - _selectedOrder = Math.Clamp(_selectedOrder, 0, Math.Max(0, _maximumDisplayedOrder)); - - if (!topologyMatches) - { - _preparedRows = PrepareRows(_targetSpectra, _maximumDisplayedOrder); - StopPresentationAnimation(); - } - else - { - UpdatePreparedMetadata(_preparedRows, _targetSpectra); - if (PreparedRowsDifferFromTarget(_preparedRows, _targetSpectra, _maximumDisplayedOrder)) - StartPresentationAnimation(); - else - StopPresentationAnimation(); - } - - _rowTargets.Clear(); - InvalidateVisual(); - } - - '''.replace('\n ', '\n') - harmonic = cut( - harmonic, - " internal void ShowSpectra(", - " internal void ShowMessage(", - new_show, - "harmonic ShowSpectra") - - new_message = ''' internal void ShowMessage(string title, string message) - { - _title = title ?? string.Empty; - _subtitle = message ?? string.Empty; - StopPresentationAnimation(); - _targetSpectra = Array.Empty(); - _preparedRows = Array.Empty(); - _maximumDisplayedOrder = -1; - _rowTargets.Clear(); - InvalidateVisual(); - } - - '''.replace('\n ', '\n') - harmonic = cut( - harmonic, - " internal void ShowMessage(", - " protected override void OnRender(", - new_message, - "harmonic ShowMessage") - - harmonic = replace_once(harmonic, "prepared.ThdLabel", "BuildThdLabel(prepared.ThdPercent)", "THD draw") - harmonic = replace_once( - harmonic, - "prepared.AxisMaximum, prepared.AxisTopLabel, dpi", - "prepared.AxisMaximum, BuildAxisTopLabel(prepared.AxisMaximum, prepared.AxisUnitSuffix), dpi", - "axis label draw") - harmonic = replace_once(harmonic, "bin.PercentLabel", "BuildPercentLabel(bin.PercentOfFundamental)", "percent draw") - harmonic = replace_once(harmonic, "bin.MagnitudeLabel", "FormatEngineering(bin.MagnitudeRms)", "magnitude draw") - - helpers = ''' private void StartPresentationAnimation() - { - var now = Stopwatch.GetTimestamp(); - _presentationAnimationStartedTimestamp = now; - if (_presentationRenderingHooked) return; - _lastPresentationTimestamp = now; - CompositionTarget.Rendering += PresentationCompositionFrame; - _presentationRenderingHooked = true; - } - - private void StopPresentationAnimation() - { - if (_presentationRenderingHooked) - { - CompositionTarget.Rendering -= PresentationCompositionFrame; - _presentationRenderingHooked = false; - } - _lastPresentationTimestamp = 0; - _presentationAnimationStartedTimestamp = 0; - } - - private void PresentationCompositionFrame(object? sender, EventArgs e) - { - if (!_presentationRenderingHooked) return; - - var now = Stopwatch.GetTimestamp(); - var elapsedMilliseconds = _lastPresentationTimestamp == 0 - ? 16.0 - : Math.Clamp(Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds, 0.0, 50.0); - _lastPresentationTimestamp = now; - - AdvancePreparedRows(_preparedRows, _targetSpectra, _maximumDisplayedOrder, elapsedMilliseconds); - - var animationAgeMilliseconds = _presentationAnimationStartedTimestamp == 0 - ? PresentationAnimationMaximumMs - : Stopwatch.GetElapsedTime(_presentationAnimationStartedTimestamp, now).TotalMilliseconds; - if (animationAgeMilliseconds >= PresentationAnimationMaximumMs) - { - SnapPreparedRowsToTarget(_preparedRows, _targetSpectra, _maximumDisplayedOrder); - StopPresentationAnimation(); - } - - InvalidateVisual(); - } - - private static ComtradeHarmonicOverviewSpectrum[] SnapshotSpectra( - IReadOnlyList source) - { - if (source.Count == 0) - return Array.Empty(); - - var result = new ComtradeHarmonicOverviewSpectrum[source.Count]; - for (var index = 0; index < source.Count; index++) - result[index] = source[index]; - return result; - } - - private static bool PreparedRowsMatchTopology( - IReadOnlyList rows, - IReadOnlyList target, - int maximumOrder) - { - if (maximumOrder < 0) - return rows.Count == 0 && target.Count == 0; - if (rows.Count != target.Count) - return false; - - for (var index = 0; index < target.Count; index++) - { - var row = rows[index]; - var spectrum = target[index]; - if (!string.Equals(row.SignalName, spectrum.SignalName, StringComparison.Ordinal) || - !string.Equals(row.Units, spectrum.Units, StringComparison.Ordinal) || - row.Bins.Length != maximumOrder + 1) - return false; - } - - return true; - } - - private static PreparedSpectrumRow[] PrepareRows( - IReadOnlyList spectra, - int maximumOrder) - { - if (spectra.Count == 0 || maximumOrder < 0) - return Array.Empty(); - - var rows = new PreparedSpectrumRow[spectra.Count]; - for (var index = 0; index < spectra.Count; index++) - { - var spectrum = spectra[index]; - var signalColor = SignalColor(spectrum.SignalName); - var unitSuffix = string.IsNullOrWhiteSpace(spectrum.Units) ? string.Empty : $"/{spectrum.Units}"; - var axisUnitSuffix = string.IsNullOrWhiteSpace(spectrum.Units) ? string.Empty : $" {spectrum.Units}"; - var row = new PreparedSpectrumRow( - spectrum.SignalName, - spectrum.Units, - new PlotBin[maximumOrder + 1], - FreezeBrush(signalColor), - FreezeBrush(WithAlpha(signalColor, 190)), - $"{spectrum.SignalName}{unitSuffix}", - axisUnitSuffix, - spectrum.EstimatedSampleRateHz, - BuildSampleRateLabel(spectrum.EstimatedSampleRateHz)); - rows[index] = row; - SnapPreparedRowToTarget(row, spectrum, maximumOrder); - } - - return rows; - } - - private static void UpdatePreparedMetadata( - IReadOnlyList rows, - IReadOnlyList target) - { - var count = Math.Min(rows.Count, target.Count); - for (var index = 0; index < count; index++) - { - rows[index].EstimatedSampleRateHz = target[index].EstimatedSampleRateHz; - rows[index].SampleRateLabel = BuildSampleRateLabel(target[index].EstimatedSampleRateHz); - } - } - - private static void AdvancePreparedRows( - IReadOnlyList rows, - IReadOnlyList target, - int maximumOrder, - double elapsedMilliseconds) - { - var count = Math.Min(rows.Count, target.Count); - for (var index = 0; index < count; index++) - AdvancePreparedRow(rows[index], target[index], maximumOrder, elapsedMilliseconds); - } - - private static void AdvancePreparedRow( - PreparedSpectrumRow row, - ComtradeHarmonicOverviewSpectrum target, - int maximumOrder, - double elapsedMilliseconds) - { - var targetFundamental = DisplayMagnitude(target.FundamentalRms); - var targetThd = DisplayScalar(target.ThdPercent); - row.FundamentalRms = PresentationEasingMath.Smooth( - row.FundamentalRms, targetFundamental, elapsedMilliseconds, PresentationTimeConstantMs); - row.ThdPercent = PresentationEasingMath.Smooth( - row.ThdPercent, targetThd, elapsedMilliseconds, PresentationTimeConstantMs); - - var maximumMagnitude = 0.0; - for (var order = 0; order <= maximumOrder; order++) - { - ResolveTargetBin(target, order, out var targetMagnitude, out var targetAngle); - var bin = row.Bins[order]; - var magnitude = order == 1 - ? row.FundamentalRms - : PresentationEasingMath.Smooth( - bin.MagnitudeRms, targetMagnitude, elapsedMilliseconds, PresentationTimeConstantMs); - var angle = PresentationEasingMath.SmoothAngleDegrees( - bin.AngleDegrees, targetAngle, elapsedMilliseconds, PresentationTimeConstantMs); - var percent = order == 1 && row.FundamentalRms > 0.0 - ? 100.0 - : ComtradeHarmonicsOverviewMath.PercentOfFundamental(magnitude, row.FundamentalRms); - row.Bins[order] = new PlotBin(order, magnitude, percent, angle); - maximumMagnitude = Math.Max(maximumMagnitude, magnitude); - } - - row.AxisMaximum = ComtradeHarmonicsOverviewMath.NiceMagnitudeAxisMaximum(maximumMagnitude); - } + run: python .github/scripts/fix_comtrade_review_findings.py - private static void SnapPreparedRowsToTarget( - IReadOnlyList rows, - IReadOnlyList target, - int maximumOrder) - { - var count = Math.Min(rows.Count, target.Count); - for (var index = 0; index < count; index++) - SnapPreparedRowToTarget(rows[index], target[index], maximumOrder); - } - - private static void SnapPreparedRowToTarget( - PreparedSpectrumRow row, - ComtradeHarmonicOverviewSpectrum target, - int maximumOrder) - { - row.FundamentalRms = DisplayMagnitude(target.FundamentalRms); - row.ThdPercent = DisplayScalar(target.ThdPercent); - - var maximumMagnitude = 0.0; - for (var order = 0; order <= maximumOrder; order++) - { - ResolveTargetBin(target, order, out var magnitude, out var angle); - if (order == 1) - magnitude = row.FundamentalRms; - var percent = order == 1 && row.FundamentalRms > 0.0 - ? 100.0 - : ComtradeHarmonicsOverviewMath.PercentOfFundamental(magnitude, row.FundamentalRms); - row.Bins[order] = new PlotBin(order, magnitude, percent, angle); - maximumMagnitude = Math.Max(maximumMagnitude, magnitude); - } - - row.AxisMaximum = ComtradeHarmonicsOverviewMath.NiceMagnitudeAxisMaximum(maximumMagnitude); - } - - private static bool PreparedRowsDifferFromTarget( - IReadOnlyList rows, - IReadOnlyList target, - int maximumOrder) - { - if (!PreparedRowsMatchTopology(rows, target, maximumOrder)) - return true; - - for (var index = 0; index < target.Count; index++) - { - var row = rows[index]; - var spectrum = target[index]; - if (!row.FundamentalRms.Equals(DisplayMagnitude(spectrum.FundamentalRms)) || - !row.ThdPercent.Equals(DisplayScalar(spectrum.ThdPercent))) - return true; - - for (var order = 0; order <= maximumOrder; order++) - { - ResolveTargetBin(spectrum, order, out var targetMagnitude, out var targetAngle); - if (order == 1) - targetMagnitude = DisplayMagnitude(spectrum.FundamentalRms); - var bin = row.Bins[order]; - if (!bin.MagnitudeRms.Equals(targetMagnitude) || - !PresentationEasingMath.NormalizeAngleDegrees(bin.AngleDegrees) - .Equals(PresentationEasingMath.NormalizeAngleDegrees(targetAngle))) - return true; - } - } - - return false; - } - - private static void ResolveTargetBin( - ComtradeHarmonicOverviewSpectrum spectrum, - int order, - out double magnitude, - out double angleDegrees) - { - if (order == 0) - { - magnitude = Math.Abs(DisplayScalar(spectrum.DcComponent)); - angleDegrees = 0.0; - return; - } - - magnitude = order == 1 ? DisplayMagnitude(spectrum.FundamentalRms) : 0.0; - angleDegrees = 0.0; - for (var index = 0; index < spectrum.Bins.Count; index++) - { - var source = spectrum.Bins[index]; - if (source.Order != order) - continue; - if (order != 1) - magnitude = DisplayMagnitude(source.MagnitudeRms); - angleDegrees = double.IsFinite(source.AngleDegrees) - ? PresentationEasingMath.NormalizeAngleDegrees(source.AngleDegrees) - : 0.0; - return; - } - } - - private static double DisplayMagnitude(double value) - => double.IsFinite(value) ? Math.Max(0.0, value) : 0.0; - - private static double DisplayScalar(double value) - => double.IsFinite(value) ? value : 0.0; - - private static string BuildThdLabel(double thdPercent) - => $"THD {thdPercent:G4}%"; - - private static string BuildAxisTopLabel(double axisMaximum, string axisUnitSuffix) - => $"{FormatEngineering(axisMaximum)}{axisUnitSuffix}"; - - private static string BuildPercentLabel(double percent) - => $"{percent:0.#}%"; - - private static string BuildSampleRateLabel(double rate) - => rate > 0.0 && double.IsFinite(rate) ? $"{rate:G6} Hz" : string.Empty; - - '''.replace('\n ', '\n') - - harmonic = cut( - harmonic, - " private void StartPresentationAnimation()", - " private static int ResolveMaximumDisplayedOrder(", - helpers, - "harmonic presentation helper block") - - record_block = ''' private readonly record struct PlotBin( - int Order, - double MagnitudeRms, - double PercentOfFundamental, - double AngleDegrees); - - private sealed class PreparedSpectrumRow - { - internal PreparedSpectrumRow( - string signalName, - string units, - PlotBin[] bins, - Brush signalBrush, - Brush secondarySignalBrush, - string signalLabel, - string axisUnitSuffix, - double estimatedSampleRateHz, - string sampleRateLabel) - { - SignalName = signalName ?? string.Empty; - Units = units ?? string.Empty; - Bins = bins; - SignalBrush = signalBrush; - SecondarySignalBrush = secondarySignalBrush; - SignalLabel = signalLabel; - AxisUnitSuffix = axisUnitSuffix; - EstimatedSampleRateHz = estimatedSampleRateHz; - SampleRateLabel = sampleRateLabel; - } - - internal string SignalName { get; } - internal string Units { get; } - internal PlotBin[] Bins { get; } - internal Brush SignalBrush { get; } - internal Brush SecondarySignalBrush { get; } - internal string SignalLabel { get; } - internal string AxisUnitSuffix { get; } - internal double FundamentalRms { get; set; } - internal double ThdPercent { get; set; } - internal double AxisMaximum { get; set; } = 1.0; - internal double EstimatedSampleRateHz { get; set; } - internal string SampleRateLabel { get; set; } - } - - '''.replace('\n ', '\n') - harmonic = cut( - harmonic, - " private readonly record struct PlotBin(", - " private readonly record struct RowHitTarget(", - record_block, - "harmonic prepared records") - - old_summary = '''/// Render-path rule: spectrum normalization, harmonic lookup, engineering-value formatting and -/// brush creation happen only when ShowSpectra receives a new immutable result. OnRender consumes -/// prepared rows and only performs geometry/text drawing required for the current element size. -''' - new_summary = '''/// Render-path rule: immutable native spectra remain the engineering targets. Static row assets are -/// prepared only when channel topology changes; composition frames mutate reusable numeric buffers -/// and OnRender draws the current presentation without rebuilding brushes or per-bin arrays. -''' - harmonic = replace_once(harmonic, old_summary, new_summary, "harmonic summary") - harmonic_path.write_text(harmonic, encoding="utf-8", newline="\n") - - # Keep a source-level regression for both review findings and the existing scrub scheduler. - test_path = Path("tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs") - test_path.write_text('''namespace ARSAS.Tests; - -public sealed class ComtradePresentationAnimationRegressionTests -{ - [Fact] - public void PresentationViews_AnimateAtCompositionCadence_AndPreserveFrameClockAcrossRetargets() - { - var phasor = File.ReadAllText(FindRepoFile("Controls/ComtradePhasorView.cs")); - var harmonic = File.ReadAllText(FindRepoFile("Controls/ComtradeHarmonicsWorkstationView.cs")); - - Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", phasor, StringComparison.Ordinal); - Assert.Contains("_presentationAnimationStartedTimestamp = now;", phasor, StringComparison.Ordinal); - Assert.Contains("if (_presentationRenderingHooked) return;", phasor, StringComparison.Ordinal); - Assert.Contains("_lastPresentationTimestamp = now;", phasor, StringComparison.Ordinal); - - Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", harmonic, StringComparison.Ordinal); - Assert.Contains("AdvancePreparedRows(_preparedRows, _targetSpectra", harmonic, StringComparison.Ordinal); - Assert.Contains("PreparedRowsMatchTopology", harmonic, StringComparison.Ordinal); - Assert.DoesNotContain("RefreshPreparedSpectra", harmonic, StringComparison.Ordinal); - Assert.DoesNotContain("SmoothSpectra", harmonic, StringComparison.Ordinal); - Assert.DoesNotContain("DispatcherTimer", phasor, StringComparison.Ordinal); - Assert.DoesNotContain("DispatcherTimer", harmonic, StringComparison.Ordinal); - } - - [Fact] - public void NativeScrub_RemainsLatestWinsSingleWorker() - { - var source = File.ReadAllText(FindRepoFile("ComtradeWorkspaceWindow.P1D4LiveScrub.cs")); - Assert.Contains("if (_p1d4ScrubWorkerRunning || !_p1d4ScrubDirty)", source, StringComparison.Ordinal); - Assert.Contains("_p1d4ScrubWorkerRunning = true", source, StringComparison.Ordinal); - Assert.Contains("if (_p1d4ScrubDirty && _analysisMode != AnalysisMode.Waveform)", source, StringComparison.Ordinal); - } - - private static string FindRepoFile(string relativePath) - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory != null) - { - var candidate = Path.Combine(directory.FullName, relativePath); - if (File.Exists(candidate)) return candidate; - directory = directory.Parent; - } - throw new FileNotFoundException(relativePath); - } -} -''', encoding="utf-8", newline="\n") - - for path in (phasor_path, harmonic_path, test_path): - text = path.read_text(encoding="utf-8") - if "DispatcherTimer" in text and path != test_path: - raise SystemExit(f"{path}: DispatcherTimer forbidden") - - # Strong P1 ordering check: retarget deadline first, hooked guard second, frame clock third. - for path in (phasor_path, harmonic_path): - text = path.read_text(encoding="utf-8") - start = text.index("private void StartPresentationAnimation()") - block = text[start:text.index("private void StopPresentationAnimation()", start)] - a = block.index("_presentationAnimationStartedTimestamp = now;") - b = block.index("if (_presentationRenderingHooked) return;") - c = block.index("_lastPresentationTimestamp = now;") - if not (a < b < c): - raise SystemExit(f"{path}: frame clock ordering regression") - - harmonic_text = harmonic_path.read_text(encoding="utf-8") - for forbidden in ("RefreshPreparedSpectra", "SmoothSpectra", "_smoothedSpectra"): - if forbidden in harmonic_text: - raise SystemExit(f"harmonic hot-path allocation helper still present: {forbidden}") - PY - - git diff --check - - - name: Commit review fixes + - name: Validate and commit review fixes shell: bash run: | + git diff --check git rm .github/workflows/fix-comtrade-review-findings.yml + git rm .github/scripts/fix_comtrade_review_findings.py git add Controls/ComtradePhasorView.cs Controls/ComtradeHarmonicsWorkstationView.cs tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs git diff --cached --check git config user.name "github-actions[bot]" From 28825e3bce7e3025b191d726f050ab4a404a3ef8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:59:37 +0000 Subject: [PATCH 06/12] fix(comtrade): preserve frame clock and reuse harmonic buffers --- .../scripts/fix_comtrade_review_findings.py | 530 ------------------ .../fix-comtrade-review-findings.yml | 36 -- Controls/ComtradeHarmonicsWorkstationView.cs | 522 +++++++++-------- Controls/ComtradePhasorView.cs | 16 +- ...adePresentationAnimationRegressionTests.cs | 23 +- 5 files changed, 295 insertions(+), 832 deletions(-) delete mode 100644 .github/scripts/fix_comtrade_review_findings.py delete mode 100644 .github/workflows/fix-comtrade-review-findings.yml diff --git a/.github/scripts/fix_comtrade_review_findings.py b/.github/scripts/fix_comtrade_review_findings.py deleted file mode 100644 index 1ebe72f00..000000000 --- a/.github/scripts/fix_comtrade_review_findings.py +++ /dev/null @@ -1,530 +0,0 @@ -from pathlib import Path - - -def cut(text: str, start_marker: str, end_marker: str, replacement: str, label: str) -> str: - start = text.find(start_marker) - end = text.find(end_marker, start + 1) if start >= 0 else -1 - if start < 0 or end < 0: - raise SystemExit(f"{label}: markers missing") - return text[:start] + replacement + text[end:] - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one match, got {count}") - return text.replace(old, new, 1) - - -# P1: preserve composition-frame elapsed time when a fresh native target arrives -# while the animation pump is already active. A retarget restarts only the settle -# deadline, not the previous-frame clock. -phasor_path = Path("Controls/ComtradePhasorView.cs") -phasor = phasor_path.read_text(encoding="utf-8") -phasor_start = """ private void StartPresentationAnimation() - { - var now = Stopwatch.GetTimestamp(); - _presentationAnimationStartedTimestamp = now; - if (_presentationRenderingHooked) return; - _lastPresentationTimestamp = now; - CompositionTarget.Rendering += PresentationCompositionFrame; - _presentationRenderingHooked = true; - } - -""" -phasor = cut( - phasor, - " private void StartPresentationAnimation()", - "private void StopPresentationAnimation()", - phasor_start, - "phasor StartPresentationAnimation") -phasor_path.write_text(phasor, encoding="utf-8", newline="\n") - - -# P2: harmonics must not rebuild arrays, spectrum/bin records, brushes or labels -# at composition cadence. Native results update exact target buffers only when a -# new analysis result arrives; rendering frames mutate only reusable numeric bins. -harmonic_path = Path("Controls/ComtradeHarmonicsWorkstationView.cs") -harmonic = harmonic_path.read_text(encoding="utf-8") - -harmonic = replace_once( - harmonic, - """ private const double PresentationTimeConstantMs = 92.0; - private const double PresentationAnimationMaximumMs = 300.0; - private IReadOnlyList _spectra = Array.Empty(); - private ComtradeHarmonicOverviewSpectrum[] _targetSpectra = Array.Empty(); - private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); - private bool _presentationRenderingHooked; - private long _lastPresentationTimestamp; - private long _presentationAnimationStartedTimestamp; -""", - """ private const double PresentationTimeConstantMs = 92.0; - private const double PresentationAnimationMaximumMs = 300.0; - private bool _presentationRenderingHooked; - private long _lastPresentationTimestamp; - private long _presentationAnimationStartedTimestamp; -""", - "harmonic fields") - -new_show = """ internal void ShowSpectra( - string title, - string subtitle, - IReadOnlyList spectra) - { - _title = title ?? string.Empty; - _subtitle = subtitle ?? string.Empty; - var targetSpectra = spectra ?? Array.Empty(); - var targetMaximumOrder = ResolveMaximumDisplayedOrder(targetSpectra); - var topologyMatches = PreparedRowsMatchTopology(_preparedRows, targetSpectra, targetMaximumOrder); - - _maximumDisplayedOrder = targetMaximumOrder; - _selectedOrder = Math.Clamp(_selectedOrder, 0, Math.Max(0, _maximumDisplayedOrder)); - - if (!topologyMatches) - { - _preparedRows = PrepareRows(targetSpectra, _maximumDisplayedOrder); - StopPresentationAnimation(); - } - else - { - UpdatePreparedTargets(_preparedRows, targetSpectra, _maximumDisplayedOrder); - if (PreparedRowsDifferFromTarget(_preparedRows)) - StartPresentationAnimation(); - else - StopPresentationAnimation(); - } - - _rowTargets.Clear(); - InvalidateVisual(); - } - -""" -harmonic = cut( - harmonic, - " internal void ShowSpectra(", - " internal void ShowMessage(", - new_show, - "harmonic ShowSpectra") - -new_message = """ internal void ShowMessage(string title, string message) - { - _title = title ?? string.Empty; - _subtitle = message ?? string.Empty; - StopPresentationAnimation(); - _preparedRows = Array.Empty(); - _maximumDisplayedOrder = -1; - _rowTargets.Clear(); - InvalidateVisual(); - } - -""" -harmonic = cut( - harmonic, - " internal void ShowMessage(", - " protected override void OnRender(", - new_message, - "harmonic ShowMessage") - -# Draw exact target labels cached at native-result cadence while only geometry eases. -harmonic = replace_once(harmonic, "prepared.ThdLabel", "prepared.TargetThdLabel", "THD draw") -harmonic = replace_once( - harmonic, - "prepared.AxisMaximum, prepared.AxisTopLabel, dpi", - "prepared.AxisMaximum, prepared.TargetAxisTopLabel, dpi", - "axis label draw") -harmonic = replace_once(harmonic, "bin.PercentLabel", "prepared.TargetPercentLabels[order]", "percent draw") -harmonic = replace_once(harmonic, "bin.MagnitudeLabel", "prepared.TargetMagnitudeLabels[order]", "magnitude draw") - -helpers = """ private void StartPresentationAnimation() - { - var now = Stopwatch.GetTimestamp(); - _presentationAnimationStartedTimestamp = now; - if (_presentationRenderingHooked) return; - _lastPresentationTimestamp = now; - CompositionTarget.Rendering += PresentationCompositionFrame; - _presentationRenderingHooked = true; - } - - private void StopPresentationAnimation() - { - if (_presentationRenderingHooked) - { - CompositionTarget.Rendering -= PresentationCompositionFrame; - _presentationRenderingHooked = false; - } - _lastPresentationTimestamp = 0; - _presentationAnimationStartedTimestamp = 0; - } - - private void PresentationCompositionFrame(object? sender, EventArgs e) - { - if (!_presentationRenderingHooked) return; - - var now = Stopwatch.GetTimestamp(); - var elapsedMilliseconds = _lastPresentationTimestamp == 0 - ? 16.0 - : Math.Clamp(Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds, 0.0, 50.0); - _lastPresentationTimestamp = now; - - AdvancePreparedRows(_preparedRows, elapsedMilliseconds); - - var animationAgeMilliseconds = _presentationAnimationStartedTimestamp == 0 - ? PresentationAnimationMaximumMs - : Stopwatch.GetElapsedTime(_presentationAnimationStartedTimestamp, now).TotalMilliseconds; - if (animationAgeMilliseconds >= PresentationAnimationMaximumMs) - { - SnapPreparedRowsToTarget(_preparedRows); - StopPresentationAnimation(); - } - - InvalidateVisual(); - } - - private static bool PreparedRowsMatchTopology( - IReadOnlyList rows, - IReadOnlyList target, - int maximumOrder) - { - if (maximumOrder < 0) - return rows.Count == 0 && target.Count == 0; - if (rows.Count != target.Count) - return false; - - for (var index = 0; index < target.Count; index++) - { - var row = rows[index]; - var spectrum = target[index]; - if (!string.Equals(row.SignalName, spectrum.SignalName, StringComparison.Ordinal) || - !string.Equals(row.Units, spectrum.Units, StringComparison.Ordinal) || - row.Bins.Length != maximumOrder + 1) - return false; - } - - return true; - } - - private static PreparedSpectrumRow[] PrepareRows( - IReadOnlyList spectra, - int maximumOrder) - { - if (spectra.Count == 0 || maximumOrder < 0) - return Array.Empty(); - - var rows = new PreparedSpectrumRow[spectra.Count]; - for (var index = 0; index < spectra.Count; index++) - { - var spectrum = spectra[index]; - var signalColor = SignalColor(spectrum.SignalName); - var unitSuffix = string.IsNullOrWhiteSpace(spectrum.Units) ? string.Empty : $"/{spectrum.Units}"; - var row = new PreparedSpectrumRow( - spectrum.SignalName, - spectrum.Units, - new PlotBin[maximumOrder + 1], - new double[maximumOrder + 1], - new double[maximumOrder + 1], - new string[maximumOrder + 1], - new string[maximumOrder + 1], - FreezeBrush(signalColor), - FreezeBrush(WithAlpha(signalColor, 190)), - $"{spectrum.SignalName}{unitSuffix}"); - rows[index] = row; - UpdatePreparedTarget(row, spectrum, maximumOrder); - SnapPreparedRowToTarget(row); - } - - return rows; - } - - private static void UpdatePreparedTargets( - IReadOnlyList rows, - IReadOnlyList target, - int maximumOrder) - { - var count = Math.Min(rows.Count, target.Count); - for (var index = 0; index < count; index++) - UpdatePreparedTarget(rows[index], target[index], maximumOrder); - } - - private static void UpdatePreparedTarget( - PreparedSpectrumRow row, - ComtradeHarmonicOverviewSpectrum target, - int maximumOrder) - { - var targetFundamental = DisplayMagnitude(target.FundamentalRms); - var maximumMagnitude = 0.0; - - for (var order = 0; order <= maximumOrder; order++) - { - ResolveTargetBin(target, order, targetFundamental, out var magnitude, out var angleDegrees); - var percent = order == 1 && targetFundamental > 0.0 - ? 100.0 - : ComtradeHarmonicsOverviewMath.PercentOfFundamental(magnitude, targetFundamental); - - row.TargetMagnitudes[order] = magnitude; - row.TargetAngles[order] = angleDegrees; - row.TargetPercentLabels[order] = $"{percent:0.#}%"; - row.TargetMagnitudeLabels[order] = FormatEngineering(magnitude); - maximumMagnitude = Math.Max(maximumMagnitude, magnitude); - } - - row.AxisMaximum = ComtradeHarmonicsOverviewMath.NiceMagnitudeAxisMaximum(maximumMagnitude); - var axisUnit = string.IsNullOrWhiteSpace(target.Units) ? string.Empty : $" {target.Units}"; - row.TargetAxisTopLabel = $"{FormatEngineering(row.AxisMaximum)}{axisUnit}"; - row.TargetThdLabel = $"THD {DisplayScalar(target.ThdPercent):G4}%"; - row.EstimatedSampleRateHz = target.EstimatedSampleRateHz; - row.SampleRateLabel = target.EstimatedSampleRateHz > 0.0 && double.IsFinite(target.EstimatedSampleRateHz) - ? $"{target.EstimatedSampleRateHz:G6} Hz" - : string.Empty; - } - - private static void ResolveTargetBin( - ComtradeHarmonicOverviewSpectrum spectrum, - int order, - double fundamentalRms, - out double magnitude, - out double angleDegrees) - { - if (order == 0) - { - magnitude = Math.Abs(DisplayScalar(spectrum.DcComponent)); - angleDegrees = 0.0; - return; - } - - magnitude = order == 1 ? fundamentalRms : 0.0; - angleDegrees = 0.0; - for (var index = 0; index < spectrum.Bins.Count; index++) - { - var source = spectrum.Bins[index]; - if (source.Order != order) - continue; - if (order != 1) - magnitude = DisplayMagnitude(source.MagnitudeRms); - angleDegrees = double.IsFinite(source.AngleDegrees) - ? PresentationEasingMath.NormalizeAngleDegrees(source.AngleDegrees) - : 0.0; - return; - } - } - - private static void AdvancePreparedRows( - IReadOnlyList rows, - double elapsedMilliseconds) - { - for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) - { - var row = rows[rowIndex]; - for (var order = 0; order < row.Bins.Length; order++) - { - var before = row.Bins[order]; - row.Bins[order] = new PlotBin( - order, - PresentationEasingMath.Smooth( - before.MagnitudeRms, - row.TargetMagnitudes[order], - elapsedMilliseconds, - PresentationTimeConstantMs), - PresentationEasingMath.SmoothAngleDegrees( - before.AngleDegrees, - row.TargetAngles[order], - elapsedMilliseconds, - PresentationTimeConstantMs)); - } - } - } - - private static void SnapPreparedRowsToTarget(IReadOnlyList rows) - { - for (var index = 0; index < rows.Count; index++) - SnapPreparedRowToTarget(rows[index]); - } - - private static void SnapPreparedRowToTarget(PreparedSpectrumRow row) - { - for (var order = 0; order < row.Bins.Length; order++) - row.Bins[order] = new PlotBin(order, row.TargetMagnitudes[order], row.TargetAngles[order]); - } - - private static bool PreparedRowsDifferFromTarget(IReadOnlyList rows) - { - for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) - { - var row = rows[rowIndex]; - for (var order = 0; order < row.Bins.Length; order++) - { - var bin = row.Bins[order]; - if (!bin.MagnitudeRms.Equals(row.TargetMagnitudes[order]) || - !PresentationEasingMath.NormalizeAngleDegrees(bin.AngleDegrees) - .Equals(PresentationEasingMath.NormalizeAngleDegrees(row.TargetAngles[order]))) - return true; - } - } - return false; - } - - private static double DisplayMagnitude(double value) - => double.IsFinite(value) ? Math.Max(0.0, value) : 0.0; - - private static double DisplayScalar(double value) - => double.IsFinite(value) ? value : 0.0; - -""" -harmonic = cut( - harmonic, - " private void StartPresentationAnimation()", - " private static int ResolveMaximumDisplayedOrder(", - helpers, - "harmonic presentation helper block") - -record_block = """ private readonly record struct PlotBin( - int Order, - double MagnitudeRms, - double AngleDegrees); - - private sealed class PreparedSpectrumRow - { - internal PreparedSpectrumRow( - string signalName, - string units, - PlotBin[] bins, - double[] targetMagnitudes, - double[] targetAngles, - string[] targetPercentLabels, - string[] targetMagnitudeLabels, - Brush signalBrush, - Brush secondarySignalBrush, - string signalLabel) - { - SignalName = signalName ?? string.Empty; - Units = units ?? string.Empty; - Bins = bins; - TargetMagnitudes = targetMagnitudes; - TargetAngles = targetAngles; - TargetPercentLabels = targetPercentLabels; - TargetMagnitudeLabels = targetMagnitudeLabels; - SignalBrush = signalBrush; - SecondarySignalBrush = secondarySignalBrush; - SignalLabel = signalLabel; - } - - internal string SignalName { get; } - internal string Units { get; } - internal PlotBin[] Bins { get; } - internal double[] TargetMagnitudes { get; } - internal double[] TargetAngles { get; } - internal string[] TargetPercentLabels { get; } - internal string[] TargetMagnitudeLabels { get; } - internal Brush SignalBrush { get; } - internal Brush SecondarySignalBrush { get; } - internal string SignalLabel { get; } - internal double AxisMaximum { get; set; } = 1.0; - internal string TargetAxisTopLabel { get; set; } = string.Empty; - internal string TargetThdLabel { get; set; } = string.Empty; - internal double EstimatedSampleRateHz { get; set; } - internal string SampleRateLabel { get; set; } = string.Empty; - } - -""" -harmonic = cut( - harmonic, - " private readonly record struct PlotBin(", - " private readonly record struct RowHitTarget(", - record_block, - "harmonic prepared records") - -old_summary = """/// Render-path rule: spectrum normalization, harmonic lookup, engineering-value formatting and -/// brush creation happen only when ShowSpectra receives a new immutable result. OnRender consumes -/// prepared rows and only performs geometry/text drawing required for the current element size. -""" -new_summary = """/// Render-path rule: native results update exact cached targets at analysis cadence. Static row -/// assets and reusable numeric buffers are rebuilt only on topology changes; composition frames -/// mutate only numeric presentation values and never rebuild brushes, labels or per-bin arrays. -""" -harmonic = replace_once(harmonic, old_summary, new_summary, "harmonic summary") -harmonic_path.write_text(harmonic, encoding="utf-8", newline="\n") - - -# Regression locks P1 frame-clock ordering, P2 reusable harmonic buffers, and -# the existing latest-wins single-worker native scheduler. -test_path = Path("tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs") -test_path.write_text( - """namespace ARSAS.Tests; - -public sealed class ComtradePresentationAnimationRegressionTests -{ - [Fact] - public void PresentationViews_PreserveFrameClock_AndReuseHarmonicBuffers() - { - var phasor = File.ReadAllText(FindRepoFile("Controls/ComtradePhasorView.cs")); - var harmonic = File.ReadAllText(FindRepoFile("Controls/ComtradeHarmonicsWorkstationView.cs")); - - Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", phasor, StringComparison.Ordinal); - Assert.Contains("_presentationAnimationStartedTimestamp = now;", phasor, StringComparison.Ordinal); - Assert.Contains("if (_presentationRenderingHooked) return;", phasor, StringComparison.Ordinal); - - Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", harmonic, StringComparison.Ordinal); - Assert.Contains("AdvancePreparedRows(_preparedRows, elapsedMilliseconds)", harmonic, StringComparison.Ordinal); - Assert.Contains("TargetMagnitudes", harmonic, StringComparison.Ordinal); - Assert.Contains("TargetMagnitudeLabels", harmonic, StringComparison.Ordinal); - Assert.DoesNotContain("RefreshPreparedSpectra", harmonic, StringComparison.Ordinal); - Assert.DoesNotContain("SmoothSpectra", harmonic, StringComparison.Ordinal); - Assert.DoesNotContain("_smoothedSpectra", harmonic, StringComparison.Ordinal); - Assert.DoesNotContain("DispatcherTimer", phasor, StringComparison.Ordinal); - Assert.DoesNotContain("DispatcherTimer", harmonic, StringComparison.Ordinal); - } - - [Fact] - public void NativeScrub_RemainsLatestWinsSingleWorker() - { - var source = File.ReadAllText(FindRepoFile("ComtradeWorkspaceWindow.P1D4LiveScrub.cs")); - Assert.Contains("if (_p1d4ScrubWorkerRunning || !_p1d4ScrubDirty)", source, StringComparison.Ordinal); - Assert.Contains("_p1d4ScrubWorkerRunning = true", source, StringComparison.Ordinal); - Assert.Contains("if (_p1d4ScrubDirty && _analysisMode != AnalysisMode.Waveform)", source, StringComparison.Ordinal); - } - - private static string FindRepoFile(string relativePath) - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory != null) - { - var candidate = Path.Combine(directory.FullName, relativePath); - if (File.Exists(candidate)) return candidate; - directory = directory.Parent; - } - throw new FileNotFoundException(relativePath); - } -} -""", - encoding="utf-8", - newline="\n") - - -# Fail closed if the intended hot-path architecture did not land. -for path in (phasor_path, harmonic_path): - source = path.read_text(encoding="utf-8") - start = source.index("private void StartPresentationAnimation()") - stop = source.index("private void StopPresentationAnimation()", start) - block = source[start:stop] - deadline = block.index("_presentationAnimationStartedTimestamp = now;") - hooked = block.index("if (_presentationRenderingHooked) return;") - frame_clock = block.index("_lastPresentationTimestamp = now;") - if not deadline < hooked < frame_clock: - raise SystemExit(f"{path}: retarget/frame-clock ordering regression") - if "DispatcherTimer" in source: - raise SystemExit(f"{path}: DispatcherTimer forbidden") - -harmonic_source = harmonic_path.read_text(encoding="utf-8") -for forbidden in ("RefreshPreparedSpectra", "SmoothSpectra", "_smoothedSpectra"): - if forbidden in harmonic_source: - raise SystemExit(f"harmonic hot-path allocation helper still present: {forbidden}") - -required = ( - "AdvancePreparedRows(_preparedRows, elapsedMilliseconds)", - "TargetMagnitudes", - "TargetAngles", - "TargetPercentLabels", - "TargetMagnitudeLabels", -) -for token in required: - if token not in harmonic_source: - raise SystemExit(f"harmonic reusable-buffer contract missing: {token}") diff --git a/.github/workflows/fix-comtrade-review-findings.yml b/.github/workflows/fix-comtrade-review-findings.yml deleted file mode 100644 index 7691b2e86..000000000 --- a/.github/workflows/fix-comtrade-review-findings.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Fix COMTRADE review findings - -on: - push: - branches: - - "fix/v1636-comtrade-interaction-easing" - paths: - - ".github/workflows/fix-comtrade-review-findings.yml" - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/v1636-comtrade-interaction-easing - fetch-depth: 1 - - - name: Apply review fixes - run: python .github/scripts/fix_comtrade_review_findings.py - - - name: Validate and commit review fixes - shell: bash - run: | - git diff --check - git rm .github/workflows/fix-comtrade-review-findings.yml - git rm .github/scripts/fix_comtrade_review_findings.py - git add Controls/ComtradePhasorView.cs Controls/ComtradeHarmonicsWorkstationView.cs tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs - git diff --cached --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "fix(comtrade): preserve frame clock and reuse harmonic buffers" - git push origin HEAD:fix/v1636-comtrade-interaction-easing diff --git a/Controls/ComtradeHarmonicsWorkstationView.cs b/Controls/ComtradeHarmonicsWorkstationView.cs index a9195a50f..436fef9a2 100644 --- a/Controls/ComtradeHarmonicsWorkstationView.cs +++ b/Controls/ComtradeHarmonicsWorkstationView.cs @@ -25,9 +25,9 @@ internal sealed record ComtradeHarmonicOverviewSpectrum( /// control only renders compact, aligned small multiples so several checked analog channels can be /// compared at the same H cursor without switching channel-by-channel. /// -/// Render-path rule: spectrum normalization, harmonic lookup, engineering-value formatting and -/// brush creation happen only when ShowSpectra receives a new immutable result. OnRender consumes -/// prepared rows and only performs geometry/text drawing required for the current element size. +/// Render-path rule: native results update exact cached targets at analysis cadence. Static row +/// assets and reusable numeric buffers are rebuilt only on topology changes; composition frames +/// mutate only numeric presentation values and never rebuild brushes, labels or per-bin arrays. /// public sealed class ComtradeHarmonicsWorkstationView : FrameworkElement { @@ -68,9 +68,6 @@ public sealed class ComtradeHarmonicsWorkstationView : FrameworkElement private const double PresentationTimeConstantMs = 92.0; private const double PresentationAnimationMaximumMs = 300.0; - private IReadOnlyList _spectra = Array.Empty(); - private ComtradeHarmonicOverviewSpectrum[] _targetSpectra = Array.Empty(); - private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); private bool _presentationRenderingHooked; private long _lastPresentationTimestamp; private long _presentationAnimationStartedTimestamp; @@ -108,33 +105,47 @@ internal void ShowSpectrum(string title, string subtitle, ComtradeHarmonicDispla } internal void ShowSpectra( - string title, - string subtitle, - IReadOnlyList spectra) -{ - _title = title ?? string.Empty; - _subtitle = subtitle ?? string.Empty; - _targetSpectra = CloneSpectra(spectra ?? Array.Empty()); - _smoothedSpectra = SmoothSpectra(_smoothedSpectra, _targetSpectra, 0.0); - RefreshPreparedSpectra(); - if (SpectraDiffer(_smoothedSpectra, _targetSpectra)) StartPresentationAnimation(); - else StopPresentationAnimation(); - InvalidateVisual(); -} + string title, + string subtitle, + IReadOnlyList spectra) + { + _title = title ?? string.Empty; + _subtitle = subtitle ?? string.Empty; + var targetSpectra = spectra ?? Array.Empty(); + var targetMaximumOrder = ResolveMaximumDisplayedOrder(targetSpectra); + var topologyMatches = PreparedRowsMatchTopology(_preparedRows, targetSpectra, targetMaximumOrder); + + _maximumDisplayedOrder = targetMaximumOrder; + _selectedOrder = Math.Clamp(_selectedOrder, 0, Math.Max(0, _maximumDisplayedOrder)); + + if (!topologyMatches) + { + _preparedRows = PrepareRows(targetSpectra, _maximumDisplayedOrder); + StopPresentationAnimation(); + } + else + { + UpdatePreparedTargets(_preparedRows, targetSpectra, _maximumDisplayedOrder); + if (PreparedRowsDifferFromTarget(_preparedRows)) + StartPresentationAnimation(); + else + StopPresentationAnimation(); + } + + _rowTargets.Clear(); + InvalidateVisual(); + } internal void ShowMessage(string title, string message) -{ - _title = title ?? string.Empty; - _subtitle = message ?? string.Empty; - StopPresentationAnimation(); - _spectra = Array.Empty(); - _targetSpectra = Array.Empty(); - _smoothedSpectra = Array.Empty(); - _preparedRows = Array.Empty(); - _maximumDisplayedOrder = -1; - _rowTargets.Clear(); - InvalidateVisual(); -} + { + _title = title ?? string.Empty; + _subtitle = message ?? string.Empty; + StopPresentationAnimation(); + _preparedRows = Array.Empty(); + _maximumDisplayedOrder = -1; + _rowTargets.Clear(); + InvalidateVisual(); + } protected override void OnRender(DrawingContext dc) { @@ -216,7 +227,7 @@ private void DrawSpectrumRow( DrawText(dc, prepared.SignalLabel, 9.5, SemiboldTypeface, PrimaryTextBrush, new Point(18, row.Top + 5), dpi, LabelWidth - 22); - DrawText(dc, prepared.ThdLabel, 7.7, BodyTypeface, + DrawText(dc, prepared.TargetThdLabel, 7.7, BodyTypeface, SecondaryTextBrush, new Point(18, row.Top + 22), dpi, LabelWidth - 22); var plot = new Rect( @@ -226,7 +237,7 @@ private void DrawSpectrumRow( Math.Max(38, row.Height - 27)); _rowTargets.Add(new RowHitTarget(plot, maximumOrder)); - DrawMagnitudeGrid(dc, plot, prepared.AxisMaximum, prepared.AxisTopLabel, dpi); + DrawMagnitudeGrid(dc, plot, prepared.AxisMaximum, prepared.TargetAxisTopLabel, dpi); var slot = plot.Width / Math.Max(1, maximumOrder + 1); var barWidth = Math.Clamp(slot * 0.64, 4.0, 38.0); @@ -261,9 +272,9 @@ private void DrawSpectrumRow( if (bin.MagnitudeRms <= 0 && order != 1) continue; var labelY = Math.Max(plot.Top + 1, barRect.Top - 23); - DrawCenteredText(dc, bin.PercentLabel, 7.1, SemiboldTypeface, + DrawCenteredText(dc, prepared.TargetPercentLabels[order], 7.1, SemiboldTypeface, HarmonicLabelBrush, new Point(centerX, labelY), dpi); - DrawCenteredText(dc, bin.MagnitudeLabel, 6.9, BodyTypeface, + DrawCenteredText(dc, prepared.TargetMagnitudeLabels[order], 6.9, BodyTypeface, MagnitudeLabelBrush, new Point(centerX, labelY + 10), dpi); } } @@ -309,157 +320,71 @@ private void DrawFooter(DrawingContext dc, Rect bounds, double dpi) } private void StartPresentationAnimation() -{ - var now = Stopwatch.GetTimestamp(); - _lastPresentationTimestamp = now; - _presentationAnimationStartedTimestamp = now; - if (_presentationRenderingHooked) return; - CompositionTarget.Rendering += PresentationCompositionFrame; - _presentationRenderingHooked = true; -} - -private void StopPresentationAnimation() -{ - if (_presentationRenderingHooked) { - CompositionTarget.Rendering -= PresentationCompositionFrame; - _presentationRenderingHooked = false; + var now = Stopwatch.GetTimestamp(); + _presentationAnimationStartedTimestamp = now; + if (_presentationRenderingHooked) return; + _lastPresentationTimestamp = now; + CompositionTarget.Rendering += PresentationCompositionFrame; + _presentationRenderingHooked = true; } - _lastPresentationTimestamp = 0; - _presentationAnimationStartedTimestamp = 0; -} -private void PresentationCompositionFrame(object? sender, EventArgs e) -{ - if (!_presentationRenderingHooked) return; - var now = Stopwatch.GetTimestamp(); - var dt = _lastPresentationTimestamp == 0 - ? 16.0 - : Math.Clamp(Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds, 0.0, 50.0); - _lastPresentationTimestamp = now; - _smoothedSpectra = SmoothSpectra(_smoothedSpectra, _targetSpectra, dt); - var age = _presentationAnimationStartedTimestamp == 0 - ? PresentationAnimationMaximumMs - : Stopwatch.GetElapsedTime(_presentationAnimationStartedTimestamp, now).TotalMilliseconds; - if (age >= PresentationAnimationMaximumMs) - { - _smoothedSpectra = CloneSpectra(_targetSpectra); - StopPresentationAnimation(); + private void StopPresentationAnimation() + { + if (_presentationRenderingHooked) + { + CompositionTarget.Rendering -= PresentationCompositionFrame; + _presentationRenderingHooked = false; + } + _lastPresentationTimestamp = 0; + _presentationAnimationStartedTimestamp = 0; } - RefreshPreparedSpectra(); - InvalidateVisual(); -} -private void RefreshPreparedSpectra() -{ - _spectra = _smoothedSpectra; - _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_spectra); - _selectedOrder = Math.Clamp(_selectedOrder, 0, Math.Max(0, _maximumDisplayedOrder)); - _preparedRows = PrepareRows(_spectra, _maximumDisplayedOrder); - _rowTargets.Clear(); -} + private void PresentationCompositionFrame(object? sender, EventArgs e) + { + if (!_presentationRenderingHooked) return; -private static ComtradeHarmonicOverviewSpectrum[] CloneSpectra(IReadOnlyList source) -{ - if (source.Count == 0) return Array.Empty(); - var result = new ComtradeHarmonicOverviewSpectrum[source.Count]; - for (var i = 0; i < source.Count; i++) result[i] = source[i] with { Bins = source[i].Bins.ToArray() }; - return result; -} + var now = Stopwatch.GetTimestamp(); + var elapsedMilliseconds = _lastPresentationTimestamp == 0 + ? 16.0 + : Math.Clamp(Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds, 0.0, 50.0); + _lastPresentationTimestamp = now; -private static bool SpectraDiffer(IReadOnlyList left, IReadOnlyList right) -{ - if (left.Count != right.Count) return true; - for (var i = 0; i < left.Count; i++) - { - var a = left[i]; var b = right[i]; - if (!string.Equals(a.SignalName, b.SignalName, StringComparison.Ordinal) || - !string.Equals(a.Units, b.Units, StringComparison.Ordinal) || - !a.DcComponent.Equals(b.DcComponent) || !a.FundamentalRms.Equals(b.FundamentalRms) || - !a.ThdPercent.Equals(b.ThdPercent) || a.DominantOrder != b.DominantOrder || - !a.DominantRms.Equals(b.DominantRms) || !a.DominantPercent.Equals(b.DominantPercent) || - a.Bins.Count != b.Bins.Count) return true; - for (var j = 0; j < a.Bins.Count; j++) + AdvancePreparedRows(_preparedRows, elapsedMilliseconds); + + var animationAgeMilliseconds = _presentationAnimationStartedTimestamp == 0 + ? PresentationAnimationMaximumMs + : Stopwatch.GetElapsedTime(_presentationAnimationStartedTimestamp, now).TotalMilliseconds; + if (animationAgeMilliseconds >= PresentationAnimationMaximumMs) { - var x = a.Bins[j]; var y = b.Bins[j]; - if (x.Order != y.Order || !x.MagnitudeRms.Equals(y.MagnitudeRms) || - !x.PercentOfFundamental.Equals(y.PercentOfFundamental) || !x.AngleDegrees.Equals(y.AngleDegrees)) return true; + SnapPreparedRowsToTarget(_preparedRows); + StopPresentationAnimation(); } + + InvalidateVisual(); } - return false; -} - private static ComtradeHarmonicOverviewSpectrum[] SmoothSpectra( - IReadOnlyList previous, + private static bool PreparedRowsMatchTopology( + IReadOnlyList rows, IReadOnlyList target, - double elapsedMilliseconds) + int maximumOrder) { - if (target.Count == 0) - return Array.Empty(); + if (maximumOrder < 0) + return rows.Count == 0 && target.Count == 0; + if (rows.Count != target.Count) + return false; - var topologyMatches = previous.Count == target.Count && previous.Count > 0; - if (topologyMatches) + for (var index = 0; index < target.Count; index++) { - for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++) - { - var before = previous[spectrumIndex]; - var next = target[spectrumIndex]; - if (!string.Equals(before.SignalName, next.SignalName, StringComparison.Ordinal) || - !string.Equals(before.Units, next.Units, StringComparison.Ordinal) || - before.Bins.Count != next.Bins.Count) - { - topologyMatches = false; - break; - } - for (var binIndex = 0; binIndex < next.Bins.Count; binIndex++) - { - if (before.Bins[binIndex].Order != next.Bins[binIndex].Order) - { - topologyMatches = false; - break; - } - } - if (!topologyMatches) break; - } + var row = rows[index]; + var spectrum = target[index]; + if (!string.Equals(row.SignalName, spectrum.SignalName, StringComparison.Ordinal) || + !string.Equals(row.Units, spectrum.Units, StringComparison.Ordinal) || + row.Bins.Length != maximumOrder + 1) + return false; } - var result = new ComtradeHarmonicOverviewSpectrum[target.Count]; - for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++) - { - var next = target[spectrumIndex]; - if (!topologyMatches) - { - result[spectrumIndex] = next with { Bins = next.Bins.ToArray() }; - continue; // First sample/channel-set change snaps; never invent a ramp from zero. - } - - var before = previous[spectrumIndex]; - var bins = new ComtradeHarmonicDisplayBin[next.Bins.Count]; - for (var binIndex = 0; binIndex < bins.Length; binIndex++) - { - var previousBin = before.Bins[binIndex]; - var targetBin = next.Bins[binIndex]; - bins[binIndex] = new ComtradeHarmonicDisplayBin( - targetBin.Order, - PresentationEasingMath.Smooth(previousBin.MagnitudeRms, targetBin.MagnitudeRms, elapsedMilliseconds, PresentationTimeConstantMs), - PresentationEasingMath.Smooth(previousBin.PercentOfFundamental, targetBin.PercentOfFundamental, elapsedMilliseconds, PresentationTimeConstantMs), - PresentationEasingMath.SmoothAngleDegrees(previousBin.AngleDegrees, targetBin.AngleDegrees, elapsedMilliseconds, PresentationTimeConstantMs)); - } - - result[spectrumIndex] = new ComtradeHarmonicOverviewSpectrum( - next.SignalName, - next.Units, - PresentationEasingMath.Smooth(before.DcComponent, next.DcComponent, elapsedMilliseconds, PresentationTimeConstantMs), - PresentationEasingMath.Smooth(before.FundamentalRms, next.FundamentalRms, elapsedMilliseconds, PresentationTimeConstantMs), - PresentationEasingMath.Smooth(before.ThdPercent, next.ThdPercent, elapsedMilliseconds, PresentationTimeConstantMs), - next.DominantOrder, - PresentationEasingMath.Smooth(before.DominantRms, next.DominantRms, elapsedMilliseconds, PresentationTimeConstantMs), - PresentationEasingMath.Smooth(before.DominantPercent, next.DominantPercent, elapsedMilliseconds, PresentationTimeConstantMs), - next.EstimatedSampleRateHz, - next.MaximumResolvableOrder, - bins); - } - return result; + return true; } private static PreparedSpectrumRow[] PrepareRows( @@ -473,89 +398,159 @@ private static PreparedSpectrumRow[] PrepareRows( for (var index = 0; index < spectra.Count; index++) { var spectrum = spectra[index]; - var bins = BuildPlotBins(spectrum, maximumOrder, out var maximumMagnitude); - var axisMaximum = ComtradeHarmonicsOverviewMath.NiceMagnitudeAxisMaximum(maximumMagnitude); var signalColor = SignalColor(spectrum.SignalName); - var signalBrush = FreezeBrush(signalColor); - var secondarySignalBrush = FreezeBrush(WithAlpha(signalColor, 190)); var unitSuffix = string.IsNullOrWhiteSpace(spectrum.Units) ? string.Empty : $"/{spectrum.Units}"; - var axisUnit = string.IsNullOrWhiteSpace(spectrum.Units) ? string.Empty : $" {spectrum.Units}"; - rows[index] = new PreparedSpectrumRow( - bins, - axisMaximum, - signalBrush, - secondarySignalBrush, - $"{spectrum.SignalName}{unitSuffix}", - $"THD {spectrum.ThdPercent:G4}%", - $"{FormatEngineering(axisMaximum)}{axisUnit}", - spectrum.EstimatedSampleRateHz, - spectrum.EstimatedSampleRateHz > 0 ? $"{spectrum.EstimatedSampleRateHz:G6} Hz" : string.Empty); + var row = new PreparedSpectrumRow( + spectrum.SignalName, + spectrum.Units, + new PlotBin[maximumOrder + 1], + new double[maximumOrder + 1], + new double[maximumOrder + 1], + new string[maximumOrder + 1], + new string[maximumOrder + 1], + FreezeBrush(signalColor), + FreezeBrush(WithAlpha(signalColor, 190)), + $"{spectrum.SignalName}{unitSuffix}"); + rows[index] = row; + UpdatePreparedTarget(row, spectrum, maximumOrder); + SnapPreparedRowToTarget(row); } + return rows; } - private static PlotBin[] BuildPlotBins( + private static void UpdatePreparedTargets( + IReadOnlyList rows, + IReadOnlyList target, + int maximumOrder) + { + var count = Math.Min(rows.Count, target.Count); + for (var index = 0; index < count; index++) + UpdatePreparedTarget(rows[index], target[index], maximumOrder); + } + + private static void UpdatePreparedTarget( + PreparedSpectrumRow row, + ComtradeHarmonicOverviewSpectrum target, + int maximumOrder) + { + var targetFundamental = DisplayMagnitude(target.FundamentalRms); + var maximumMagnitude = 0.0; + + for (var order = 0; order <= maximumOrder; order++) + { + ResolveTargetBin(target, order, targetFundamental, out var magnitude, out var angleDegrees); + var percent = order == 1 && targetFundamental > 0.0 + ? 100.0 + : ComtradeHarmonicsOverviewMath.PercentOfFundamental(magnitude, targetFundamental); + + row.TargetMagnitudes[order] = magnitude; + row.TargetAngles[order] = angleDegrees; + row.TargetPercentLabels[order] = $"{percent:0.#}%"; + row.TargetMagnitudeLabels[order] = FormatEngineering(magnitude); + maximumMagnitude = Math.Max(maximumMagnitude, magnitude); + } + + row.AxisMaximum = ComtradeHarmonicsOverviewMath.NiceMagnitudeAxisMaximum(maximumMagnitude); + var axisUnit = string.IsNullOrWhiteSpace(target.Units) ? string.Empty : $" {target.Units}"; + row.TargetAxisTopLabel = $"{FormatEngineering(row.AxisMaximum)}{axisUnit}"; + row.TargetThdLabel = $"THD {DisplayScalar(target.ThdPercent):G4}%"; + row.EstimatedSampleRateHz = target.EstimatedSampleRateHz; + row.SampleRateLabel = target.EstimatedSampleRateHz > 0.0 && double.IsFinite(target.EstimatedSampleRateHz) + ? $"{target.EstimatedSampleRateHz:G6} Hz" + : string.Empty; + } + + private static void ResolveTargetBin( ComtradeHarmonicOverviewSpectrum spectrum, - int maximumOrder, - out double maximumMagnitude) - { - var result = new PlotBin[maximumOrder + 1]; - maximumMagnitude = 0.0; - - var dcMagnitude = Math.Abs(double.IsFinite(spectrum.DcComponent) ? spectrum.DcComponent : 0.0); - result[0] = CreatePlotBin( - 0, - dcMagnitude, - ComtradeHarmonicsOverviewMath.PercentOfFundamental(dcMagnitude, spectrum.FundamentalRms), - 0.0); - maximumMagnitude = dcMagnitude; - - // MaximumDisplayedOrder is 10, so an integer bit mask is a cheaper first-bin-wins index - // than allocating GroupBy/Dictionary structures on every redraw. - var populatedMask = 1u; + int order, + double fundamentalRms, + out double magnitude, + out double angleDegrees) + { + if (order == 0) + { + magnitude = Math.Abs(DisplayScalar(spectrum.DcComponent)); + angleDegrees = 0.0; + return; + } + + magnitude = order == 1 ? fundamentalRms : 0.0; + angleDegrees = 0.0; for (var index = 0; index < spectrum.Bins.Count; index++) { var source = spectrum.Bins[index]; - var order = source.Order; - if (order < 1 || order > maximumOrder) - continue; - var bit = 1u << order; - if ((populatedMask & bit) != 0) + if (source.Order != order) continue; - populatedMask |= bit; - - var magnitude = order == 1 && spectrum.FundamentalRms > 0 - ? spectrum.FundamentalRms - : Math.Max(0.0, double.IsFinite(source.MagnitudeRms) ? source.MagnitudeRms : 0.0); - var percent = order == 1 - ? 100.0 - : ComtradeHarmonicsOverviewMath.PercentOfFundamental(magnitude, spectrum.FundamentalRms); - result[order] = CreatePlotBin(order, magnitude, percent, source.AngleDegrees); - maximumMagnitude = Math.Max(maximumMagnitude, magnitude); + if (order != 1) + magnitude = DisplayMagnitude(source.MagnitudeRms); + angleDegrees = double.IsFinite(source.AngleDegrees) + ? PresentationEasingMath.NormalizeAngleDegrees(source.AngleDegrees) + : 0.0; + return; } + } - for (var order = 1; order <= maximumOrder; order++) + private static void AdvancePreparedRows( + IReadOnlyList rows, + double elapsedMilliseconds) + { + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) { - var bit = 1u << order; - if ((populatedMask & bit) != 0) - continue; - var percent = order == 1 && spectrum.FundamentalRms > 0 ? 100.0 : 0.0; - var magnitude = order == 1 && spectrum.FundamentalRms > 0 ? spectrum.FundamentalRms : 0.0; - result[order] = CreatePlotBin(order, magnitude, percent, 0.0); - maximumMagnitude = Math.Max(maximumMagnitude, magnitude); + var row = rows[rowIndex]; + for (var order = 0; order < row.Bins.Length; order++) + { + var before = row.Bins[order]; + row.Bins[order] = new PlotBin( + order, + PresentationEasingMath.Smooth( + before.MagnitudeRms, + row.TargetMagnitudes[order], + elapsedMilliseconds, + PresentationTimeConstantMs), + PresentationEasingMath.SmoothAngleDegrees( + before.AngleDegrees, + row.TargetAngles[order], + elapsedMilliseconds, + PresentationTimeConstantMs)); + } } + } - return result; + private static void SnapPreparedRowsToTarget(IReadOnlyList rows) + { + for (var index = 0; index < rows.Count; index++) + SnapPreparedRowToTarget(rows[index]); } - private static PlotBin CreatePlotBin(int order, double magnitude, double percent, double angleDegrees) - => new( - order, - magnitude, - percent, - angleDegrees, - $"{percent:0.#}%", - FormatEngineering(magnitude)); + private static void SnapPreparedRowToTarget(PreparedSpectrumRow row) + { + for (var order = 0; order < row.Bins.Length; order++) + row.Bins[order] = new PlotBin(order, row.TargetMagnitudes[order], row.TargetAngles[order]); + } + + private static bool PreparedRowsDifferFromTarget(IReadOnlyList rows) + { + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + var row = rows[rowIndex]; + for (var order = 0; order < row.Bins.Length; order++) + { + var bin = row.Bins[order]; + if (!bin.MagnitudeRms.Equals(row.TargetMagnitudes[order]) || + !PresentationEasingMath.NormalizeAngleDegrees(bin.AngleDegrees) + .Equals(PresentationEasingMath.NormalizeAngleDegrees(row.TargetAngles[order]))) + return true; + } + } + return false; + } + + private static double DisplayMagnitude(double value) + => double.IsFinite(value) ? Math.Max(0.0, value) : 0.0; + + private static double DisplayScalar(double value) + => double.IsFinite(value) ? value : 0.0; private static int ResolveMaximumDisplayedOrder(IReadOnlyList spectra) { @@ -695,21 +690,50 @@ private static void DrawRightAlignedText( private readonly record struct PlotBin( int Order, double MagnitudeRms, - double PercentOfFundamental, - double AngleDegrees, - string PercentLabel, - string MagnitudeLabel); - - private readonly record struct PreparedSpectrumRow( - PlotBin[] Bins, - double AxisMaximum, - Brush SignalBrush, - Brush SecondarySignalBrush, - string SignalLabel, - string ThdLabel, - string AxisTopLabel, - double EstimatedSampleRateHz, - string SampleRateLabel); + double AngleDegrees); + + private sealed class PreparedSpectrumRow + { + internal PreparedSpectrumRow( + string signalName, + string units, + PlotBin[] bins, + double[] targetMagnitudes, + double[] targetAngles, + string[] targetPercentLabels, + string[] targetMagnitudeLabels, + Brush signalBrush, + Brush secondarySignalBrush, + string signalLabel) + { + SignalName = signalName ?? string.Empty; + Units = units ?? string.Empty; + Bins = bins; + TargetMagnitudes = targetMagnitudes; + TargetAngles = targetAngles; + TargetPercentLabels = targetPercentLabels; + TargetMagnitudeLabels = targetMagnitudeLabels; + SignalBrush = signalBrush; + SecondarySignalBrush = secondarySignalBrush; + SignalLabel = signalLabel; + } + + internal string SignalName { get; } + internal string Units { get; } + internal PlotBin[] Bins { get; } + internal double[] TargetMagnitudes { get; } + internal double[] TargetAngles { get; } + internal string[] TargetPercentLabels { get; } + internal string[] TargetMagnitudeLabels { get; } + internal Brush SignalBrush { get; } + internal Brush SecondarySignalBrush { get; } + internal string SignalLabel { get; } + internal double AxisMaximum { get; set; } = 1.0; + internal string TargetAxisTopLabel { get; set; } = string.Empty; + internal string TargetThdLabel { get; set; } = string.Empty; + internal double EstimatedSampleRateHz { get; set; } + internal string SampleRateLabel { get; set; } = string.Empty; + } private readonly record struct RowHitTarget(Rect Plot, int MaximumOrder); } diff --git a/Controls/ComtradePhasorView.cs b/Controls/ComtradePhasorView.cs index 5436f51fb..8208d6ed4 100644 --- a/Controls/ComtradePhasorView.cs +++ b/Controls/ComtradePhasorView.cs @@ -145,14 +145,14 @@ protected override void OnRender(DrawingContext dc) } private void StartPresentationAnimation() -{ - var now = Stopwatch.GetTimestamp(); - _lastPresentationTimestamp = now; - _presentationAnimationStartedTimestamp = now; - if (_presentationRenderingHooked) return; - CompositionTarget.Rendering += PresentationCompositionFrame; - _presentationRenderingHooked = true; -} + { + var now = Stopwatch.GetTimestamp(); + _presentationAnimationStartedTimestamp = now; + if (_presentationRenderingHooked) return; + _lastPresentationTimestamp = now; + CompositionTarget.Rendering += PresentationCompositionFrame; + _presentationRenderingHooked = true; + } private void StopPresentationAnimation() { diff --git a/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs b/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs index 289da6fe2..56ec44310 100644 --- a/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs +++ b/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs @@ -3,17 +3,22 @@ namespace ARSAS.Tests; public sealed class ComtradePresentationAnimationRegressionTests { [Fact] - public void PresentationViews_AnimateAtCompositionCadence_ThenSettleExactly() + public void PresentationViews_PreserveFrameClock_AndReuseHarmonicBuffers() { var phasor = File.ReadAllText(FindRepoFile("Controls/ComtradePhasorView.cs")); var harmonic = File.ReadAllText(FindRepoFile("Controls/ComtradeHarmonicsWorkstationView.cs")); - // Cursor/native analysis remains exact; only the derived presentation plane is eased. Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", phasor, StringComparison.Ordinal); - Assert.Contains("_smoothedVoltageVectors = CloneVectors(_targetVoltageVectors)", phasor, StringComparison.Ordinal); - Assert.Contains("_smoothedCurrentVectors = CloneVectors(_targetCurrentVectors)", phasor, StringComparison.Ordinal); + Assert.Contains("_presentationAnimationStartedTimestamp = now;", phasor, StringComparison.Ordinal); + Assert.Contains("if (_presentationRenderingHooked) return;", phasor, StringComparison.Ordinal); + Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", harmonic, StringComparison.Ordinal); - Assert.Contains("_smoothedSpectra = CloneSpectra(_targetSpectra)", harmonic, StringComparison.Ordinal); + Assert.Contains("AdvancePreparedRows(_preparedRows, elapsedMilliseconds)", harmonic, StringComparison.Ordinal); + Assert.Contains("TargetMagnitudes", harmonic, StringComparison.Ordinal); + Assert.Contains("TargetMagnitudeLabels", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("RefreshPreparedSpectra", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("SmoothSpectra", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("_smoothedSpectra", harmonic, StringComparison.Ordinal); Assert.DoesNotContain("DispatcherTimer", phasor, StringComparison.Ordinal); Assert.DoesNotContain("DispatcherTimer", harmonic, StringComparison.Ordinal); } @@ -29,12 +34,12 @@ public void NativeScrub_RemainsLatestWinsSingleWorker() private static string FindRepoFile(string relativePath) { - DirectoryInfo? dir = new(AppContext.BaseDirectory); - while (dir != null) + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) { - var candidate = Path.Combine(dir.FullName, relativePath); + var candidate = Path.Combine(directory.FullName, relativePath); if (File.Exists(candidate)) return candidate; - dir = dir.Parent; + directory = directory.Parent; } throw new FileNotFoundException(relativePath); } From 21323e1a6fdb859e07a4561768e14fc75615b47c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:00:52 +0700 Subject: [PATCH 07/12] test(comtrade): document review-fixed presentation invariants --- .../ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs b/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs index 56ec44310..83e3e9eea 100644 --- a/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs +++ b/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs @@ -8,10 +8,12 @@ public void PresentationViews_PreserveFrameClock_AndReuseHarmonicBuffers() var phasor = File.ReadAllText(FindRepoFile("Controls/ComtradePhasorView.cs")); var harmonic = File.ReadAllText(FindRepoFile("Controls/ComtradeHarmonicsWorkstationView.cs")); + // Retargeting restarts the settle deadline without resetting an already-running frame clock. Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", phasor, StringComparison.Ordinal); Assert.Contains("_presentationAnimationStartedTimestamp = now;", phasor, StringComparison.Ordinal); Assert.Contains("if (_presentationRenderingHooked) return;", phasor, StringComparison.Ordinal); + // Harmonic composition frames mutate reusable numeric buffers; static assets stay cached. Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", harmonic, StringComparison.Ordinal); Assert.Contains("AdvancePreparedRows(_preparedRows, elapsedMilliseconds)", harmonic, StringComparison.Ordinal); Assert.Contains("TargetMagnitudes", harmonic, StringComparison.Ordinal); From bf1505c508dd13fa157d9c84774815fe440484d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:57:21 +0700 Subject: [PATCH 08/12] fix(rcb): simplify client occupancy status indicators --- Models/RcbExportModels.cs | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/Models/RcbExportModels.cs b/Models/RcbExportModels.cs index 2d4417a28..4acd26de0 100644 --- a/Models/RcbExportModels.cs +++ b/Models/RcbExportModels.cs @@ -8,6 +8,9 @@ namespace ArIED61850Tester.Models; public sealed class RcbExportRow : ObservableObject { + private static readonly Brush ReadyIndicatorBrush = BrushFrom(22, 163, 74); + private static readonly Brush OccupiedIndicatorBrush = BrushFrom(234, 179, 8); + private bool _isSelected; private int _memberCount; private MmsRcbOperationalAvailability _availability = MmsRcbOperationalAvailability.Unknown; @@ -51,6 +54,7 @@ public MmsRcbOperationalAvailability Availability if (!Set(ref _availability, value)) return; Raise(nameof(IsSelectable)); Raise(nameof(RequiresConfirmation)); + Raise(nameof(IsClientOccupied)); Raise(nameof(StatusGlyph)); Raise(nameof(StatusBrush)); } @@ -61,30 +65,16 @@ public MmsRcbOperationalAvailability Availability public string Reason { get => _reason; set => Set(ref _reason, value?.Trim() ?? string.Empty); } public string Owner { get => _owner; set => Set(ref _owner, value?.Trim() ?? string.Empty); } - // Availability/ownership is evidence for the operator, not an export lock. - // Every discovered RCB remains selectable so the exported engineering model - // can truthfully represent what the IED exposes, including InUse/NoDataSet. + // Availability/ownership remains engineering evidence and still drives confirmation. + // The table intentionally presents only a calm client-occupancy indicator: yellow means + // an active client owns/uses the RCB; every other state is green and details stay in tooltip/evidence. public bool IsSelectable => true; - + public bool IsClientOccupied => Availability is MmsRcbOperationalAvailability.InUse or MmsRcbOperationalAvailability.UsedByCaller; public bool RequiresConfirmation => HasEvidenceConflict || Availability is not MmsRcbOperationalAvailability.Available; public string MemberCountText => MemberCount > 0 ? $"{MemberCount:N0} FCDA" : "0 FCDA"; - public string StatusGlyph => Availability switch - { - MmsRcbOperationalAvailability.Available => "✅", - MmsRcbOperationalAvailability.UsedByCaller => "●", - MmsRcbOperationalAvailability.Unknown => "⚠", - _ => "❌" - }; - public Brush StatusBrush => HasEvidenceConflict - ? BrushFrom(201, 42, 50) - : Availability switch - { - MmsRcbOperationalAvailability.Available => BrushFrom(22, 163, 74), - MmsRcbOperationalAvailability.UsedByCaller => BrushFrom(37, 99, 235), - MmsRcbOperationalAvailability.Unknown => BrushFrom(202, 138, 4), - _ => BrushFrom(201, 42, 50) - }; + public string StatusGlyph => "■"; + public Brush StatusBrush => IsClientOccupied ? OccupiedIndicatorBrush : ReadyIndicatorBrush; public string SelectionIdentity => string.IsNullOrWhiteSpace(Reference) ? Name : Reference; public static string ToStatusText(MmsRcbOperationalAvailability availability) From 52b80258442a1440eaab7af4f1555f1e60a7a3cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:57:54 +0700 Subject: [PATCH 09/12] fix(rcb): use calm square occupancy indicators --- RcbExportFilterWindow.xaml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/RcbExportFilterWindow.xaml b/RcbExportFilterWindow.xaml index 64c9c95bf..3c2bff68e 100644 --- a/RcbExportFilterWindow.xaml +++ b/RcbExportFilterWindow.xaml @@ -183,15 +183,13 @@ - + - - - - + + + From 2a802ebce32d1b059471b1edd49443e95b13fe15 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:58:51 +0700 Subject: [PATCH 10/12] fix(rcb): keep live instance UX and canonical source export --- MainWindow.RcbExport.cs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/MainWindow.RcbExport.cs b/MainWindow.RcbExport.cs index 44c95f25d..7d7c89f64 100644 --- a/MainWindow.RcbExport.cs +++ b/MainWindow.RcbExport.cs @@ -82,18 +82,30 @@ private static IReadOnlyList BuildRcbExportRows( if (sourceInventory == null) return liveRows; - // Never let an older/source SCL hide RCBs that the connected IED actually - // exposes. Source-backed rows are preferred for exact export identity, then - // unmatched live-discovery rows are appended as first-class export choices. + // Present concrete live instances when the IED exposes them, but never append the + // corresponding logical SCL ReportControl as a duplicate row. For example, a source + // `Buffer` with RptEnabled max=2 may appear online as Buffer01 + Buffer02. The source + // logical identity remains export authority; the concrete instance names are UX/runtime evidence. var rows = BuildSourceBackedRcbRows(device, sourceInventory, availability).ToList(); var seen = rows .Select(row => NormalizeRcbReference(row.Reference)) .Where(reference => !string.IsNullOrWhiteSpace(reference)) .ToHashSet(StringComparer.OrdinalIgnoreCase); + var sourceBackedSelectionKeys = rows + .Where(row => row.IsSourceBacked && !string.IsNullOrWhiteSpace(row.SourceSelectionKey)) + .Select(row => row.SourceSelectionKey) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var logicalSourceReferences = sourceInventory.ReportControls + .Where(descriptor => sourceBackedSelectionKeys.Contains(descriptor.SelectionKey)) + .Select(descriptor => NormalizeRcbReference(descriptor.DisplayReference)) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var liveRow in liveRows) { var key = NormalizeRcbReference(liveRow.Reference); + if (logicalSourceReferences.Contains(key)) + continue; if (!seen.Add(key)) continue; rows.Add(liveRow); @@ -361,6 +373,7 @@ private async Task ExportLegacySasRcbAsync( SchemaProfile = schema, SelectedReportControl = new SclReportControlSelection(row.SourceSelectionKey, row.ExportName), RemoveUnreferencedDataSets = false, + PreserveSourceReportControlIdentity = true, ToolId = "ARIEC61850" }), cancellationToken); @@ -459,4 +472,4 @@ private static string LastReferenceSegment(string? reference) private static string NormalizeRcbReference(string? reference) => (reference ?? string.Empty).Trim().Replace('$', '.').ToLowerInvariant(); -} +} \ No newline at end of file From 97d1a81ef64af32f7dcd451b2f2d2abf591eedaa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 16:59:09 +0700 Subject: [PATCH 11/12] test(rcb): lock IEDScout projection and status UX --- .../RcbIedScoutParityRegressionTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/ARSAS.Tests/RcbIedScoutParityRegressionTests.cs diff --git a/tests/ARSAS.Tests/RcbIedScoutParityRegressionTests.cs b/tests/ARSAS.Tests/RcbIedScoutParityRegressionTests.cs new file mode 100644 index 000000000..11e70c409 --- /dev/null +++ b/tests/ARSAS.Tests/RcbIedScoutParityRegressionTests.cs @@ -0,0 +1,51 @@ +namespace ARSAS.Tests; + +public sealed class RcbIedScoutParityRegressionTests +{ + [Fact] + public void SourceBackedRuntimeInstances_Suppress_Logical_Scl_Duplicate() + { + var source = ReadRepoFile("MainWindow.RcbExport.cs"); + + Assert.Contains("logicalSourceReferences", source, StringComparison.Ordinal); + Assert.Contains("logicalSourceReferences.Contains(key)", source, StringComparison.Ordinal); + Assert.Contains("sourceBackedSelectionKeys", source, StringComparison.Ordinal); + } + + [Fact] + public void SourceBackedExport_Preserves_Canonical_Scl_ReportControl_Identity() + { + var source = ReadRepoFile("MainWindow.RcbExport.cs"); + + Assert.Contains("PreserveSourceReportControlIdentity = true", source, StringComparison.Ordinal); + Assert.Contains("new SclReportControlSelection(row.SourceSelectionKey, row.ExportName)", source, StringComparison.Ordinal); + } + + [Fact] + public void RcbStatus_Is_SquareOnly_YellowForClientUse_AndGreenOtherwise() + { + var model = ReadRepoFile("Models/RcbExportModels.cs"); + var xaml = ReadRepoFile("RcbExportFilterWindow.xaml"); + + Assert.Contains("Availability is MmsRcbOperationalAvailability.InUse or MmsRcbOperationalAvailability.UsedByCaller", model, StringComparison.Ordinal); + Assert.Contains("IsClientOccupied ? OccupiedIndicatorBrush : ReadyIndicatorBrush", model, StringComparison.Ordinal); + Assert.Contains("BrushFrom(234, 179, 8)", model, StringComparison.Ordinal); + Assert.Contains("BrushFrom(22, 163, 74)", model, StringComparison.Ordinal); + Assert.Contains(" Date: Wed, 16 Sep 2026 17:04:06 +0700 Subject: [PATCH 12/12] chore(engine): pin source-backed RCB export identity fix --- engines/ARIEC61850.lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index bc0491d2e..e4e2a9548 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,13 +2,13 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "0023ef9a4373855497464ed3979e359c4041c95d", - "sourcePullRequest": 132, - "purpose": "Temporary ARSAS 1.6.36 combined-workstream qualification pin for the exact green ARIEC convergence head. ARIEC .NET CI #603 passed on this SHA, including provenance/source/license verification, restore, build, tests, and diagnostics. The convergence preserves the trusted-SCL golden-wire contracts used by ARSAS: SCL-authoritative DataSet/RCB identity, LDevice ldName and ReportControl indexed semantics, quoted Edition-1/vendor OSI-AP-Title compatibility, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, receiver-before-write report registration, URCB Resv -> RptEna, BRCB direct RptEna with ResvTms retry-only, two whole-RCB verification reads, one-shot GI after routing is registered, GI fail-closed cleanup, no cyclic process polling, no network DataSet-directory browse, and no dynamic DataSet mutation on the trusted-SCL path. The same convergence also locks buffered BRCB latest-state semantics through the canonical runtime value plane and preserves SCL RptEnabled@max only as diagnostics metadata; it is never authority to synthesize concrete runtime RCB names.", + "commit": "3afc924c97627fe86adbe784c905e2f35dff0b1a", + "sourcePullRequest": 133, + "purpose": "Pins merged ARIEC61850 PR #133 on top of the exact PR #132 golden-wire tree. ARIEC .NET CI #605 passed source/provenance verification, restore, build, and tests. This adds only an opt-in source-backed Legacy SAS export boundary: ARSAS may display concrete live runtime RCB slots such as Buffer01/Buffer02 while CID/IID export retains the logical source ReportControl identity and RptEnabled indexing metadata. The option is default-off, so existing exact-runtime live-model export remains unchanged. All trusted-SCL acquisition, reporting and control contracts from PR #132 remain unchanged: SCL-authoritative DataSet/RCB identity, LDevice ldName and ReportControl indexed semantics, quoted Edition-1/vendor OSI-AP-Title compatibility, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, receiver-before-write report registration, URCB Resv -> RptEna, BRCB direct RptEna with ResvTms retry-only, two whole-RCB verification reads, one-shot GI after routing is registered, GI fail-closed cleanup, no cyclic process polling, no network DataSet-directory browse, and no dynamic DataSet mutation on the trusted-SCL path. SCL RptEnabled@max remains declarative design metadata and is never authority to synthesize concrete runtime RCB names.", "previousTrialPin": { - "commit": "d50e5bcb9fd428fe3d80ac72f8d4015a575cfda5", - "sourcePullRequest": 125, - "purpose": "Previous ARSAS 1.6.36 trusted-SCL golden-wire trial pin retained for explicit ancestry." + "commit": "0023ef9a4373855497464ed3979e359c4041c95d", + "sourcePullRequest": 132, + "purpose": "Previous ARSAS 1.6.36 combined golden-wire convergence pin retained for explicit ancestry." }, "fieldProvenBaseline": { "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9",