From a6628a43c87a634fd7a8031b7e660df4b089361e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 07:59:57 +0700 Subject: [PATCH 1/7] chore: transplant targeted fix onto v1.6.36 field-proven branch --- .github/workflows/arsas-v1636-transplant.yml | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/arsas-v1636-transplant.yml diff --git a/.github/workflows/arsas-v1636-transplant.yml b/.github/workflows/arsas-v1636-transplant.yml new file mode 100644 index 000000000..9a5575da3 --- /dev/null +++ b/.github/workflows/arsas-v1636-transplant.yml @@ -0,0 +1,51 @@ +name: ARSAS v1.6.36 targeted transplant + +on: + push: + branches: + - fix/v1636-real-ied-feedback-smooth-analyzers + +permissions: + contents: write + +jobs: + transplant: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Apply only the validated source hunks onto v1.6.36 field-proven branch + shell: bash + run: | + set -euo pipefail + git fetch origin fix/real-ied-feedback-smooth-analyzers + git show --binary 063f05c796013c41657b5970001d63daeac8cf5c -- \ + ControlCommandWindow.xaml.cs \ + Controls/ComtradeHarmonicsWorkstationView.cs \ + Controls/ComtradePhasorView.cs \ + Services/Iec61850MonitorRuntime.cs \ + Services/PresentationEasingMath.cs \ + tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs \ + tests/ARSAS.Tests/PresentationEasingMathTests.cs \ + > /tmp/arsas-targeted.patch + git apply --3way --index /tmp/arsas-targeted.patch + git diff --cached --check + if grep -n 'ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue)' Services/Iec61850MonitorRuntime.cs; then + echo 'Synthetic monitored stVal injection still present.' >&2 + exit 1 + fi + grep -q 'Waiting for independent IED process feedback' ControlCommandWindow.xaml.cs + grep -q 'ShortestAngleDeltaDegrees' Services/PresentationEasingMath.cs + - name: Commit field-proven transplant + shell: bash + run: | + set -euo pipefail + git rm .github/workflows/arsas-v1636-transplant.yml + git add -A + 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(v1636): real IED feedback and smooth analyzers' + git push origin HEAD:fix/v1636-real-ied-feedback-smooth-analyzers From cd1e172ff59160d7b7b1875008b515817fe180ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:00:08 +0000 Subject: [PATCH 2/7] fix(v1636): real IED feedback and smooth analyzers --- .github/workflows/arsas-v1636-transplant.yml | 51 ----------- ControlCommandWindow.xaml.cs | 10 ++- Controls/ComtradeHarmonicsWorkstationView.cs | 88 ++++++++++++++++++- Controls/ComtradePhasorView.cs | 67 +++++++++++++- Services/Iec61850MonitorRuntime.cs | 6 +- Services/PresentationEasingMath.cs | 56 ++++++++++++ .../G1ControlCorrectnessRegressionTests.cs | 13 +++ .../PresentationEasingMathTests.cs | 31 +++++++ 8 files changed, 262 insertions(+), 60 deletions(-) delete mode 100644 .github/workflows/arsas-v1636-transplant.yml create mode 100644 Services/PresentationEasingMath.cs create mode 100644 tests/ARSAS.Tests/PresentationEasingMathTests.cs diff --git a/.github/workflows/arsas-v1636-transplant.yml b/.github/workflows/arsas-v1636-transplant.yml deleted file mode 100644 index 9a5575da3..000000000 --- a/.github/workflows/arsas-v1636-transplant.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: ARSAS v1.6.36 targeted transplant - -on: - push: - branches: - - fix/v1636-real-ied-feedback-smooth-analyzers - -permissions: - contents: write - -jobs: - transplant: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Apply only the validated source hunks onto v1.6.36 field-proven branch - shell: bash - run: | - set -euo pipefail - git fetch origin fix/real-ied-feedback-smooth-analyzers - git show --binary 063f05c796013c41657b5970001d63daeac8cf5c -- \ - ControlCommandWindow.xaml.cs \ - Controls/ComtradeHarmonicsWorkstationView.cs \ - Controls/ComtradePhasorView.cs \ - Services/Iec61850MonitorRuntime.cs \ - Services/PresentationEasingMath.cs \ - tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs \ - tests/ARSAS.Tests/PresentationEasingMathTests.cs \ - > /tmp/arsas-targeted.patch - git apply --3way --index /tmp/arsas-targeted.patch - git diff --cached --check - if grep -n 'ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue)' Services/Iec61850MonitorRuntime.cs; then - echo 'Synthetic monitored stVal injection still present.' >&2 - exit 1 - fi - grep -q 'Waiting for independent IED process feedback' ControlCommandWindow.xaml.cs - grep -q 'ShortestAngleDeltaDegrees' Services/PresentationEasingMath.cs - - name: Commit field-proven transplant - shell: bash - run: | - set -euo pipefail - git rm .github/workflows/arsas-v1636-transplant.yml - git add -A - 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(v1636): real IED feedback and smooth analyzers' - git push origin HEAD:fix/v1636-real-ied-feedback-smooth-analyzers diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs index 23d9014ee..d9769afa5 100644 --- a/ControlCommandWindow.xaml.cs +++ b/ControlCommandWindow.xaml.cs @@ -187,10 +187,12 @@ private async void SendCommand_Click(object sender, RoutedEventArgs e) }, _cancellation.Token); - CommandStage = result.Stage; + CommandStage = result.IsSuccess && !TestMode ? "Command accepted" : result.Stage; CommandStatus = BuildCommandResultText(result); - if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") - CurrentValue = result.FeedbackValue; + if (result.IsSuccess && !TestMode) + { + CommandStatus += " Command accepted by the IEC 61850 control service. Waiting for independent IED process feedback; monitored stVal is not changed from the command path."; + } SetResultTone(result.IsSuccess ? "Success" : "Error"); } catch (OperationCanceledException) @@ -312,7 +314,7 @@ private static string BuildCommandResultText(Iec61850ControlCommandResult result if (!string.IsNullOrWhiteSpace(result.ElapsedText) && result.ElapsedText != "-") details.Add($"Control service: {result.ElapsedText}."); if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-") - details.Add($"Process feedback: {result.FeedbackElapsedText}."); + details.Add($"Control-side feedback verification: {result.FeedbackElapsedText}. This does not overwrite monitored stVal."); if (!string.IsNullOrWhiteSpace(result.TotalElapsedText) && result.TotalElapsedText != "-") details.Add($"Total: {result.TotalElapsedText}."); return string.Join(" ", details.Where(text => !string.IsNullOrWhiteSpace(text))); diff --git a/Controls/ComtradeHarmonicsWorkstationView.cs b/Controls/ComtradeHarmonicsWorkstationView.cs index 17fe7bfa4..73580609a 100644 --- a/Controls/ComtradeHarmonicsWorkstationView.cs +++ b/Controls/ComtradeHarmonicsWorkstationView.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Globalization; using System.Windows; using System.Windows.Input; @@ -65,7 +66,10 @@ public sealed class ComtradeHarmonicsWorkstationView : FrameworkElement private static readonly Pen FooterDividerPen = FreezePen(Color.FromRgb(233, 237, 243), 1); private static readonly string[] OrderLabels = CreateOrderLabels(); + private const double PresentationTimeConstantMs = 92.0; private IReadOnlyList _spectra = Array.Empty(); + private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); + private long _lastPresentationTimestamp; private PreparedSpectrumRow[] _preparedRows = Array.Empty(); private int _maximumDisplayedOrder = -1; private string _title = "Harmonics"; @@ -105,7 +109,14 @@ internal void ShowSpectra( { _title = title ?? string.Empty; _subtitle = subtitle ?? string.Empty; - _spectra = spectra ?? Array.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); @@ -118,6 +129,8 @@ 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(); @@ -296,6 +309,79 @@ private void DrawFooter(DrawingContext dc, Rect bounds, double dpi) FooterRateBrush, new Point(bounds.Right - 16, y), dpi); } + private static ComtradeHarmonicOverviewSpectrum[] SmoothSpectra( + IReadOnlyList previous, + IReadOnlyList target, + double elapsedMilliseconds) + { + if (target.Count == 0) + return Array.Empty(); + + var topologyMatches = previous.Count == target.Count && previous.Count > 0; + if (topologyMatches) + { + 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 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; + } + private static PreparedSpectrumRow[] PrepareRows( IReadOnlyList spectra, int maximumOrder) diff --git a/Controls/ComtradePhasorView.cs b/Controls/ComtradePhasorView.cs index 369d105dd..9564c3081 100644 --- a/Controls/ComtradePhasorView.cs +++ b/Controls/ComtradePhasorView.cs @@ -1,6 +1,8 @@ +using System.Diagnostics; using System.Globalization; using System.Windows; using System.Windows.Media; +using ArIED61850Tester.Services; namespace ArIED61850Tester.Controls; @@ -34,9 +36,13 @@ public sealed class ComtradePhasorView : FrameworkElement private PreparedPhasorPanel _voltagePanel = PreparedPhasorPanel.Empty; private PreparedPhasorPanel _currentPanel = PreparedPhasorPanel.Empty; + private const double PresentationTimeConstantMs = 78.0; private string _headerLabel = "Fundamental phasors at C1"; private string _referenceDetail = "Select a valid analysis reference"; private string _message = string.Empty; + private ComtradePhasorVector[] _smoothedVoltageVectors = Array.Empty(); + private ComtradePhasorVector[] _smoothedCurrentVectors = Array.Empty(); + private long _lastPresentationTimestamp; internal void ShowPhasors( string referenceLabel, @@ -47,8 +53,17 @@ internal void ShowPhasors( var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel; _headerLabel = $"Fundamental phasors at {resolvedReference}"; _referenceDetail = referenceDetail ?? string.Empty; - _voltagePanel = PreparePanel(voltageVectors); - _currentPanel = PreparePanel(currentVectors); + + 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(); } @@ -60,6 +75,9 @@ internal void ShowMessage(string title, string message) _referenceDetail = message ?? string.Empty; _voltagePanel = PreparedPhasorPanel.Empty; _currentPanel = PreparedPhasorPanel.Empty; + _smoothedVoltageVectors = Array.Empty(); + _smoothedCurrentVectors = Array.Empty(); + _lastPresentationTimestamp = 0; _message = message ?? string.Empty; InvalidateVisual(); } @@ -111,6 +129,51 @@ protected override void OnRender(DrawingContext dc) } } + private static ComtradePhasorVector[] SmoothVectors( + IReadOnlyList previous, + IReadOnlyList? target, + double elapsedMilliseconds) + { + if (target is null || target.Count == 0) + return Array.Empty(); + + var topologyMatches = previous.Count == target.Count && previous.Count > 0; + if (topologyMatches) + { + for (var index = 0; index < target.Count; index++) + { + if (!string.Equals(previous[index].Label, target[index].Label, StringComparison.Ordinal) || + !string.Equals(previous[index].Phase, target[index].Phase, StringComparison.Ordinal) || + !string.Equals(previous[index].Units, target[index].Units, StringComparison.Ordinal)) + { + topologyMatches = false; + break; + } + } + } + + var output = new ComtradePhasorVector[target.Count]; + if (!topologyMatches) + { + for (var index = 0; index < target.Count; index++) + output[index] = target[index]; + return output; // First sample/topology change snaps: no artificial ramp from zero. + } + + for (var index = 0; index < target.Count; index++) + { + var before = previous[index]; + var next = target[index]; + output[index] = new ComtradePhasorVector( + next.Label, + next.Phase, + next.Units, + PresentationEasingMath.Smooth(before.MagnitudeRms, next.MagnitudeRms, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.SmoothAngleDegrees(before.AngleDegrees, next.AngleDegrees, elapsedMilliseconds, PresentationTimeConstantMs)); + } + return output; + } + private static PreparedPhasorPanel PreparePanel(IReadOnlyList? source) { if (source is null || source.Count == 0) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 6299739c3..a6c1993f4 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -609,8 +609,10 @@ public async Task ExecuteControlAsync( if (result.ServiceAccepted || result.FeedbackConfirmed || result.IsSuccess) RecordSuccessfulIo(session); - if (!request.TestMode && result.FeedbackConfirmed && !string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") - ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue); + // IMPORTANT: a successful/confirmed IEC 61850 control service is command-path evidence, + // not process-image authority. Never synthesize or inject stVal from Operate/SBO feedback here. + // The monitored state changes only through the independent acquisition path (RCB/report or + // an explicit authoritative MMS read performed by the monitor), matching IED engineering tools. var wireState = result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase) ? "NOT SENT TO IED" diff --git a/Services/PresentationEasingMath.cs b/Services/PresentationEasingMath.cs new file mode 100644 index 000000000..3930e6856 --- /dev/null +++ b/Services/PresentationEasingMath.cs @@ -0,0 +1,56 @@ +namespace ArIED61850Tester.Services; + +/// +/// Allocation-free presentation easing helpers. These functions are intentionally presentation-only: +/// raw COMTRADE/IEC 61850 engineering values remain untouched and authoritative. +/// +public static class PresentationEasingMath +{ + public static double ExponentialAlpha(double elapsedMilliseconds, double timeConstantMilliseconds) + { + if (!double.IsFinite(timeConstantMilliseconds) || timeConstantMilliseconds <= 0.0) + return 1.0; + if (double.IsPositiveInfinity(elapsedMilliseconds)) + return 1.0; + if (!double.IsFinite(elapsedMilliseconds) || elapsedMilliseconds <= 0.0) + return 0.0; + + var alpha = 1.0 - Math.Exp(-elapsedMilliseconds / timeConstantMilliseconds); + return Math.Clamp(alpha, 0.0, 1.0); + } + + public static double Smooth(double current, double target, double elapsedMilliseconds, double timeConstantMilliseconds) + { + if (!double.IsFinite(target)) return current; + if (!double.IsFinite(current)) return target; + var alpha = ExponentialAlpha(elapsedMilliseconds, timeConstantMilliseconds); + return current + ((target - current) * alpha); + } + + public static double ShortestAngleDeltaDegrees(double currentDegrees, double targetDegrees) + { + if (!double.IsFinite(currentDegrees) || !double.IsFinite(targetDegrees)) + return 0.0; + var delta = (targetDegrees - currentDegrees) % 360.0; + if (delta >= 180.0) delta -= 360.0; + if (delta < -180.0) delta += 360.0; + return delta; + } + + public static double NormalizeAngleDegrees(double degrees) + { + if (!double.IsFinite(degrees)) return 0.0; + var normalized = degrees % 360.0; + if (normalized >= 180.0) normalized -= 360.0; + if (normalized < -180.0) normalized += 360.0; + return normalized; + } + + public static double SmoothAngleDegrees(double currentDegrees, double targetDegrees, double elapsedMilliseconds, double timeConstantMilliseconds) + { + if (!double.IsFinite(targetDegrees)) return NormalizeAngleDegrees(currentDegrees); + if (!double.IsFinite(currentDegrees)) return NormalizeAngleDegrees(targetDegrees); + var alpha = ExponentialAlpha(elapsedMilliseconds, timeConstantMilliseconds); + return NormalizeAngleDegrees(currentDegrees + (ShortestAngleDeltaDegrees(currentDegrees, targetDegrees) * alpha)); + } +} diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index ab82b5bfe..98d08cfe4 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -4,6 +4,19 @@ namespace ARSAS.Tests; public sealed class G1ControlCorrectnessRegressionTests { + [Fact] + public void OperateSuccess_DoesNotInjectCommandFeedbackIntoMonitoredProcessState() + { + var root = RepoRoot(); + var runtimeSource = File.ReadAllText(Path.Combine(root, "Services", "Iec61850MonitorRuntime.cs")); + var commandWindowSource = File.ReadAllText(Path.Combine(root, "ControlCommandWindow.xaml.cs")); + + Assert.DoesNotContain("ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue)", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("CurrentValue = result.FeedbackValue", commandWindowSource, StringComparison.Ordinal); + Assert.Contains("Waiting for independent IED process feedback", commandWindowSource, StringComparison.Ordinal); + Assert.Contains("not process-image authority", runtimeSource, StringComparison.Ordinal); + } + [Fact] public void EngineLock_PreservesExactG1FieldProvenAncestryAcrossReviewedPinAdvances() { diff --git a/tests/ARSAS.Tests/PresentationEasingMathTests.cs b/tests/ARSAS.Tests/PresentationEasingMathTests.cs new file mode 100644 index 000000000..7622fd189 --- /dev/null +++ b/tests/ARSAS.Tests/PresentationEasingMathTests.cs @@ -0,0 +1,31 @@ +using ArIED61850Tester.Services; +using Xunit; + +namespace ARSAS.Tests; + +public sealed class PresentationEasingMathTests +{ + [Fact] + public void ExponentialSmoothing_IsTimeBased_NotFrameCountBased() + { + const double tau = 80.0; + var halfStep = PresentationEasingMath.Smooth(0.0, 1.0, 16.67, tau); + var twoSteps = PresentationEasingMath.Smooth(halfStep, 1.0, 16.67, tau); + var oneStep = PresentationEasingMath.Smooth(0.0, 1.0, 33.34, tau); + Assert.Equal(oneStep, twoSteps, 12); + } + + [Fact] + public void AngleSmoothing_UsesShortestPathAcrossPlusMinus180() + { + Assert.Equal(2.0, PresentationEasingMath.ShortestAngleDeltaDegrees(179.0, -179.0), 10); + Assert.Equal(-2.0, PresentationEasingMath.ShortestAngleDeltaDegrees(-179.0, 179.0), 10); + } + + [Fact] + public void InfiniteElapsedTime_SnapsToTarget() + { + Assert.Equal(1.0, PresentationEasingMath.ExponentialAlpha(double.PositiveInfinity, 80.0)); + Assert.Equal(42.0, PresentationEasingMath.Smooth(10.0, 42.0, double.PositiveInfinity, 80.0), 10); + } +} From 7d813227b757781b3a1a591facbb1929a36fd49f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 11:14:13 +0700 Subject: [PATCH 3/7] Repin combined v1.6.36 candidate to green ARIEC convergence --- engines/ARIEC61850.lock.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 07268d069..bc0491d2e 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,14 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "d50e5bcb9fd428fe3d80ac72f8d4015a575cfda5", - "sourcePullRequest": 125, - "purpose": "Canonical protocol-only golden-wire SCL-assisted physical-trial pin. This exact SHA is built from the immutable ARSAS field-proven engine baseline recorded below and passed the dedicated convergence CI. It adds SCL-derived association identity, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, trusted static-report activation without hidden full discovery or network DataSet-directory browsing, accepts quoted Edition-1/vendor OSI-AP-Title lexical forms such as \"1,1,1,999,1\" while preserving the raw SCL parameter for diagnostics, and preserves scoped DataSet/RCB authority including ReportControl indexed semantics for trusted-SCL live monitoring. For normal Play, ARSAS explicitly requests one startup GI only after the InformationReport receiver is registered, RCB activation succeeds, and two whole-RCB readbacks complete; BRCB direct RptEna remains primary with ResvTms retry-only, while URCB Resv precedes RptEna when exposed. Explicit GI acceptance is a startup success gate: rejection unregisters the monitor, disables RptEna, releases any touched reservation, and returns failure instead of a misleading active monitor with Unknown initial values. Safe-trial keeps GI disabled and performs no RCB writes. PR #125 is an evidence/trial lane and is not a merge authority for ARIEC main; ARSAS checks out this immutable SHA directly.", + "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.", + "previousTrialPin": { + "commit": "d50e5bcb9fd428fe3d80ac72f8d4015a575cfda5", + "sourcePullRequest": 125, + "purpose": "Previous ARSAS 1.6.36 trusted-SCL golden-wire trial pin retained for explicit ancestry." + }, "fieldProvenBaseline": { "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", "sourcePullRequest": 111, From 579b781f3580c3b4b50eebe9112b0b5fe4964de2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 11:14:33 +0700 Subject: [PATCH 4/7] Align combined field trial with ARIEC convergence and PR 312 authority --- docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md | 71 +++++++++++++++++++++----- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md b/docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md index 73c8bc511..38e9ee086 100644 --- a/docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md +++ b/docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md @@ -1,16 +1,21 @@ # Trusted SCL golden-wire physical trial -This document freezes the first field-test contract for the protocol-only ARSAS 1.6.36 trial lane. +This document freezes the physical qualification contract for the combined ARSAS 1.6.36 candidate after integrating the field-proven process-feedback/analyzer workstream with ARIEC reporting/bootstrap convergence. -## Immutable engine authority +## Immutable candidate authority -- ARSAS branch: `trial/scl-golden-wire-v1636` -- ARIEC61850 engine: `e41def0a2676efb8a143905798155f6bccc6f047` -- Field-proven reporting/control baseline preserved by the engine lock: `11ab2304482600c19ba979f4fc9021ddb46b9af9` +- Combined ARSAS base: PR `#312` exact head `cd1e172ff59160d7b7b1875008b515817fe180ab` +- Qualification branch: `integration/v1636-312-ariec-convergence-0023ef9` +- ARIEC61850 convergence engine: `0023ef9a4373855497464ed3979e359c4041c95d` +- ARIEC source PR: `#132` +- ARIEC exact-head .NET CI: `#603` PASS +- Field-proven reporting/control baseline retained by the engine lock: `11ab2304482600c19ba979f4fc9021ddb46b9af9` + +The engine lock and exact combined ARSAS commit are the build-time authorities. Do not substitute another ARIEC checkout or an older ARSAS artifact while collecting qualification evidence. ## Gate 1 — read-only safe trial -Run the portable application with Wireshark capturing TCP port 102. +Run the exact portable candidate with Wireshark capturing TCP port 102. ```powershell ARSAS-1.6.36-win-x64-portable.exe --scl-safe-trial "C:\path\IED.cid" "IEDNAME" "AP1" "192.168.x.x" 102 @@ -32,13 +37,53 @@ Only after Gate 1 association/read behavior is understood, open the same verifie Trusted-SCL Play verifies the imported source SHA-256 before socket activity, keeps the SCL IED/AccessPoint association identity, performs Domain/VMD validation and bounded initial Reads, and does not silently fall back to cached association or full discovery. -For Static DataSet report-only mode, ordered DataSet membership and RCB identity remain SCL-authoritative in memory. The trusted path does not perform a network DataSet-directory browse or create/delete a dynamic DataSet. +For Static DataSet report-only mode, ordered DataSet membership and RCB identity remain SCL-authoritative in memory. The trusted path does not perform a network DataSet-directory browse and does not create/delete a dynamic DataSet. + +The InformationReport receiver must be registered before any report-control write. The expected startup sequence is: + +- BRCB: whole-RCB Read -> `RptEna=true` -> whole-RCB Read -> whole-RCB Read -> one explicit `GI=true`. +- URCB: whole-RCB Read -> `Resv=true` when exposed -> `RptEna=true` -> whole-RCB Read -> whole-RCB Read -> one explicit `GI=true`. +- BRCB `ResvTms` is retry-only after a real direct-`RptEna` rejection; it is not the primary startup path. +- `GI=true` is a one-shot startup bootstrap only and is sent only after report routing is registered and activation/readback succeeds. +- GI rejection is a startup failure: unregister the monitor, disable `RptEna`, release any reservation touched by this client, and report failure instead of presenting an active monitor with unknown initial values. +- No network DataSet-directory browse, dynamic DataSet mutation, cyclic GI, or cyclic MMS process polling is allowed on the trusted-SCL report path. + +For the AA1E1F06R4 qualification target used by the golden comparison, expected evidence is: + +- BRCB family `Buffer`: a concrete live indexed instance is enabled without pre-reserving it; startup GI is accepted; Digital report data arrives. +- URCB family `Unbuffer`: a concrete live indexed instance is reserved when `Resv` is exposed, enabled, startup GI is accepted; Analog report data arrives. +- All 58 selected static DataSet members receive an initial value without waiting for a process change. +- Structured members such as total power factor remain schema/semantic projected rather than silently falling back to an unrelated scalar. + +## Gate 3 — preserve the PR #312 process-state boundary + +The combined candidate must also retain the field-accepted behavior from PR #312: + +- successful Open/Close control must not inject command feedback into monitored process `stVal`; +- Live Monitor process state changes only from real IEC 61850 acquisition/report authority; +- the control dialog must not overwrite Current Value from command result alone; +- phasor and harmonic easing remains presentation-only and must not change engineering values or report authority. + +## Gate 4 — steady state and cleanup + +After the startup initial image: + +- values must continue from InformationReport traffic/event updates; +- no periodic MMS process polling or repeated GI may be introduced; +- buffered backlog is applied in receive order so the canonical current-state plane retains the latest supplied value per signal while quality/timestamp-only updates do not erase the previous primary value; +- Stop/Close must disable every report enabled by this client; +- URCB reservation must be released when this client touched it; +- BRCB reservation must be released only when the compatibility fallback actually touched it; +- association disposal must happen after best-effort report cleanup, not instead of cleanup. + +## Evidence required for PASS -Primary activation expectation: +Physical success is not claimed by CI alone. Preserve the exact candidate SHA/artifact identity and collect: -- BRCB: whole-RCB Read -> `RptEna=true` -> whole-RCB Read -> whole-RCB Read. -- URCB: whole-RCB Read -> `Resv=true` when exposed -> `RptEna=true` -> two whole-RCB readbacks. -- BRCB `ResvTms` is retry-only after a real direct-`RptEna` rejection. -- GI is not sent implicitly. +1. ARSAS Diagnostic Export covering trusted-SCL association, RCB selection/activation, explicit startup GI, InformationReport reception and cleanup. +2. Matching Wireshark PCAP/PCAPNG for TCP port 102. +3. Screenshot or exported monitor evidence showing complete initial state and later event-driven updates. +4. Stop/Close evidence showing deterministic RCB release. +5. One control operation showing that monitored process state follows independent IED feedback rather than synthetic command feedback. -Physical success is not claimed by CI. JSON evidence plus Wireshark capture from the real IED are the acceptance evidence. +A PASS requires the software gates and the physical evidence to agree. If the wire capture contradicts UI/status text, the wire evidence is authoritative and the candidate remains blocked. From cd24a3b81f4ff6ede88234eaeab47c0793ea8715 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:27:24 +0700 Subject: [PATCH 5/7] chore(ci): apply v1.6.36 real IED authority root fix --- .../v1636-real-ied-authority-root-fix.yml | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 .github/workflows/v1636-real-ied-authority-root-fix.yml diff --git a/.github/workflows/v1636-real-ied-authority-root-fix.yml b/.github/workflows/v1636-real-ied-authority-root-fix.yml new file mode 100644 index 000000000..d42b5a812 --- /dev/null +++ b/.github/workflows/v1636-real-ied-authority-root-fix.yml @@ -0,0 +1,173 @@ +name: v1.6.36 real IED authority root fix + +on: + push: + branches: + - fix/v1636-real-ied-feedback-smooth-analyzers + paths: + - .github/workflows/v1636-real-ied-authority-root-fix.yml + +permissions: + contents: write + +jobs: + patch: + runs-on: windows-latest + steps: + - name: Checkout exact branch head + uses: actions/checkout@v4 + with: + ref: fix/v1636-real-ied-feedback-smooth-analyzers + fetch-depth: 0 + + - name: Remove command-derived process authority at the root + shell: pwsh + run: | + @' + from pathlib import Path + + runtime_path = Path("Services/Iec61850MonitorRuntime.cs") + source = runtime_path.read_text(encoding="utf-8") + + field_block = ''' public string CommandFeedbackValue { get; set; } = string.Empty; + public DateTime LastCommandFeedbackUtc { get; set; } = DateTime.MinValue; + public DateTime CommandFeedbackGuardUntilUtc { get; set; } = DateTime.MinValue; + public DateTime CommandReportDeadlineUtc { get; set; } = DateTime.MinValue; + public bool AwaitingCommandReportEdge { get; set; } + public bool CommandReportMissLogged { get; set; } + public bool StaleReportSuppressedLogged { get; set; } + ''' + if field_block not in source: + raise SystemExit("command-feedback state block not found exactly") + source = source.replace(field_block, "", 1) + + def cut_between(text: str, start_marker: str, end_marker: str, label: str) -> str: + start = text.find(start_marker) + if start < 0: + raise SystemExit(f"{label}: start marker not found") + end = text.find(end_marker, start) + if end < 0: + raise SystemExit(f"{label}: end marker not found") + return text[:start] + text[end:] + + source = cut_between( + source, + " private void ApplyControlFeedbackToMonitor(DeviceSession session, SignalDefinition signal, string feedbackValue)\n", + " public HybridReportPhysicalValidationSnapshot CaptureHybridReportPhysicalValidation(string deviceId)\n", + "synthetic control feedback injector") + + source = cut_between( + source, + " var hasSourceTimestamp = TryParseReportTimestampUtc(update.ReportTimestamp, out var reportSourceUtc);\n", + " var hadValueBeforeReport = state.HasValue;\n", + "command/report stale suppression prelude") + + correlation_start = " if (commandValueMatches &&\n" + correlation_end = " var reportSource = string.IsNullOrWhiteSpace(state.AcquisitionLabel)\n" + start = source.find(correlation_start) + if start < 0: + raise SystemExit("command/report correlation block start not found") + end = source.find(correlation_end, start) + if end < 0: + raise SystemExit("command/report correlation block end not found") + replacement = ''' // IEC 61850 process truth comes from independent acquisition, never from + // a prior control result. A real IED report is therefore never suppressed, + // rewritten, or correlated against command-side feedback before publication. + if (valueChangedByReport || IsChangeReportReason(update.Reason)) + { + state.ReportChangeVerified = true; + state.ReportMissLogged = false; + } + + ''' + source = source[:start] + replacement + source[end:] + + source = cut_between( + source, + " if (reportAssigned && state.AwaitingCommandReportEdge &&\n", + " var pollDetectedChange = state.HasValue && !string.Equals(state.Value, display, StringComparison.Ordinal);\n", + "command report deadline fallback") + + source = cut_between( + source, + " private static IReadOnlyList BuildControlFeedbackReferences(SignalDefinition signal)\n", + " private static Iec61850MonitorPoint? FindPointForReportReference(DeviceSession session, string reference)\n", + "command feedback reference helpers") + + forbidden = [ + "ApplyControlFeedbackToMonitor", + "CommandFeedbackValue", + "LastCommandFeedbackUtc", + "CommandFeedbackGuardUntilUtc", + "CommandReportDeadlineUtc", + "AwaitingCommandReportEdge", + "CommandReportMissLogged", + "StaleReportSuppressedLogged", + "BuildControlFeedbackReferences", + "FindPointForControlFeedback", + "stale report value {display} was suppressed after command-confirmed", + ] + leftovers = [token for token in forbidden if token in source] + if leftovers: + raise SystemExit("synthetic/correlation state remains: " + ", ".join(leftovers)) + if "not process-image authority" not in source: + raise SystemExit("process-authority invariant comment unexpectedly missing") + + runtime_path.write_text(source, encoding="utf-8", newline="\n") + + test_path = Path("tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs") + tests = test_path.read_text(encoding="utf-8") + anchor = ' Assert.DoesNotContain("ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue)", runtimeSource, StringComparison.Ordinal);\n' + if anchor not in tests: + raise SystemExit("G1 regression anchor not found") + extra = ''' Assert.DoesNotContain("ApplyControlFeedbackToMonitor(", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("CommandFeedbackValue", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("CommandFeedbackGuardUntilUtc", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("AwaitingCommandReportEdge", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("BuildControlFeedbackReferences", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("FindPointForControlFeedback", runtimeSource, StringComparison.Ordinal); + Assert.Contains("A real IED report is therefore never suppressed", runtimeSource, StringComparison.Ordinal); + ''' + if 'Assert.DoesNotContain("CommandFeedbackGuardUntilUtc"' not in tests: + tests = tests.replace(anchor, anchor + extra, 1) + test_path.write_text(tests, encoding="utf-8", newline="\n") + '@ | python - + + - name: Source-level regression gate + shell: pwsh + run: | + $runtime = Get-Content .\Services\Iec61850MonitorRuntime.cs -Raw + $tests = Get-Content .\tests\ARSAS.Tests\G1ControlCorrectnessRegressionTests.cs -Raw + $forbidden = @( + 'ApplyControlFeedbackToMonitor', + 'CommandFeedbackValue', + 'LastCommandFeedbackUtc', + 'CommandFeedbackGuardUntilUtc', + 'CommandReportDeadlineUtc', + 'AwaitingCommandReportEdge', + 'CommandReportMissLogged', + 'StaleReportSuppressedLogged', + 'BuildControlFeedbackReferences', + 'FindPointForControlFeedback' + ) + foreach ($token in $forbidden) { + if ($runtime.Contains($token)) { throw "Forbidden synthetic process-authority token remains: $token" } + } + if (-not $runtime.Contains('A real IED report is therefore never suppressed')) { + throw 'Independent IED report authority invariant is missing.' + } + if (-not $tests.Contains('CommandFeedbackGuardUntilUtc')) { + throw 'Regression test does not protect against command-derived report suppression.' + } + git diff --check + + - name: Commit root-cause fix and remove temporary workflow + shell: pwsh + run: | + Remove-Item .github/workflows/v1636-real-ied-authority-root-fix.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Services/Iec61850MonitorRuntime.cs tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs .github/workflows/v1636-real-ied-authority-root-fix.yml + git diff --cached --check + git commit -m "fix(control): keep process state authoritative to IED reports" + git push origin HEAD:fix/v1636-real-ied-feedback-smooth-analyzers From 7f6a63a753a045da7af6ca5a7b86e41a248b9e4a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:37:27 +0700 Subject: [PATCH 6/7] fix(control): keep process state authoritative to IED reports --- .../v1636-real-ied-authority-root-fix.yml | 173 ------------------ Services/Iec61850MonitorRuntime.cs | 171 +---------------- .../G1ControlCorrectnessRegressionTests.cs | 7 + 3 files changed, 11 insertions(+), 340 deletions(-) delete mode 100644 .github/workflows/v1636-real-ied-authority-root-fix.yml diff --git a/.github/workflows/v1636-real-ied-authority-root-fix.yml b/.github/workflows/v1636-real-ied-authority-root-fix.yml deleted file mode 100644 index d42b5a812..000000000 --- a/.github/workflows/v1636-real-ied-authority-root-fix.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: v1.6.36 real IED authority root fix - -on: - push: - branches: - - fix/v1636-real-ied-feedback-smooth-analyzers - paths: - - .github/workflows/v1636-real-ied-authority-root-fix.yml - -permissions: - contents: write - -jobs: - patch: - runs-on: windows-latest - steps: - - name: Checkout exact branch head - uses: actions/checkout@v4 - with: - ref: fix/v1636-real-ied-feedback-smooth-analyzers - fetch-depth: 0 - - - name: Remove command-derived process authority at the root - shell: pwsh - run: | - @' - from pathlib import Path - - runtime_path = Path("Services/Iec61850MonitorRuntime.cs") - source = runtime_path.read_text(encoding="utf-8") - - field_block = ''' public string CommandFeedbackValue { get; set; } = string.Empty; - public DateTime LastCommandFeedbackUtc { get; set; } = DateTime.MinValue; - public DateTime CommandFeedbackGuardUntilUtc { get; set; } = DateTime.MinValue; - public DateTime CommandReportDeadlineUtc { get; set; } = DateTime.MinValue; - public bool AwaitingCommandReportEdge { get; set; } - public bool CommandReportMissLogged { get; set; } - public bool StaleReportSuppressedLogged { get; set; } - ''' - if field_block not in source: - raise SystemExit("command-feedback state block not found exactly") - source = source.replace(field_block, "", 1) - - def cut_between(text: str, start_marker: str, end_marker: str, label: str) -> str: - start = text.find(start_marker) - if start < 0: - raise SystemExit(f"{label}: start marker not found") - end = text.find(end_marker, start) - if end < 0: - raise SystemExit(f"{label}: end marker not found") - return text[:start] + text[end:] - - source = cut_between( - source, - " private void ApplyControlFeedbackToMonitor(DeviceSession session, SignalDefinition signal, string feedbackValue)\n", - " public HybridReportPhysicalValidationSnapshot CaptureHybridReportPhysicalValidation(string deviceId)\n", - "synthetic control feedback injector") - - source = cut_between( - source, - " var hasSourceTimestamp = TryParseReportTimestampUtc(update.ReportTimestamp, out var reportSourceUtc);\n", - " var hadValueBeforeReport = state.HasValue;\n", - "command/report stale suppression prelude") - - correlation_start = " if (commandValueMatches &&\n" - correlation_end = " var reportSource = string.IsNullOrWhiteSpace(state.AcquisitionLabel)\n" - start = source.find(correlation_start) - if start < 0: - raise SystemExit("command/report correlation block start not found") - end = source.find(correlation_end, start) - if end < 0: - raise SystemExit("command/report correlation block end not found") - replacement = ''' // IEC 61850 process truth comes from independent acquisition, never from - // a prior control result. A real IED report is therefore never suppressed, - // rewritten, or correlated against command-side feedback before publication. - if (valueChangedByReport || IsChangeReportReason(update.Reason)) - { - state.ReportChangeVerified = true; - state.ReportMissLogged = false; - } - - ''' - source = source[:start] + replacement + source[end:] - - source = cut_between( - source, - " if (reportAssigned && state.AwaitingCommandReportEdge &&\n", - " var pollDetectedChange = state.HasValue && !string.Equals(state.Value, display, StringComparison.Ordinal);\n", - "command report deadline fallback") - - source = cut_between( - source, - " private static IReadOnlyList BuildControlFeedbackReferences(SignalDefinition signal)\n", - " private static Iec61850MonitorPoint? FindPointForReportReference(DeviceSession session, string reference)\n", - "command feedback reference helpers") - - forbidden = [ - "ApplyControlFeedbackToMonitor", - "CommandFeedbackValue", - "LastCommandFeedbackUtc", - "CommandFeedbackGuardUntilUtc", - "CommandReportDeadlineUtc", - "AwaitingCommandReportEdge", - "CommandReportMissLogged", - "StaleReportSuppressedLogged", - "BuildControlFeedbackReferences", - "FindPointForControlFeedback", - "stale report value {display} was suppressed after command-confirmed", - ] - leftovers = [token for token in forbidden if token in source] - if leftovers: - raise SystemExit("synthetic/correlation state remains: " + ", ".join(leftovers)) - if "not process-image authority" not in source: - raise SystemExit("process-authority invariant comment unexpectedly missing") - - runtime_path.write_text(source, encoding="utf-8", newline="\n") - - test_path = Path("tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs") - tests = test_path.read_text(encoding="utf-8") - anchor = ' Assert.DoesNotContain("ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue)", runtimeSource, StringComparison.Ordinal);\n' - if anchor not in tests: - raise SystemExit("G1 regression anchor not found") - extra = ''' Assert.DoesNotContain("ApplyControlFeedbackToMonitor(", runtimeSource, StringComparison.Ordinal); - Assert.DoesNotContain("CommandFeedbackValue", runtimeSource, StringComparison.Ordinal); - Assert.DoesNotContain("CommandFeedbackGuardUntilUtc", runtimeSource, StringComparison.Ordinal); - Assert.DoesNotContain("AwaitingCommandReportEdge", runtimeSource, StringComparison.Ordinal); - Assert.DoesNotContain("BuildControlFeedbackReferences", runtimeSource, StringComparison.Ordinal); - Assert.DoesNotContain("FindPointForControlFeedback", runtimeSource, StringComparison.Ordinal); - Assert.Contains("A real IED report is therefore never suppressed", runtimeSource, StringComparison.Ordinal); - ''' - if 'Assert.DoesNotContain("CommandFeedbackGuardUntilUtc"' not in tests: - tests = tests.replace(anchor, anchor + extra, 1) - test_path.write_text(tests, encoding="utf-8", newline="\n") - '@ | python - - - - name: Source-level regression gate - shell: pwsh - run: | - $runtime = Get-Content .\Services\Iec61850MonitorRuntime.cs -Raw - $tests = Get-Content .\tests\ARSAS.Tests\G1ControlCorrectnessRegressionTests.cs -Raw - $forbidden = @( - 'ApplyControlFeedbackToMonitor', - 'CommandFeedbackValue', - 'LastCommandFeedbackUtc', - 'CommandFeedbackGuardUntilUtc', - 'CommandReportDeadlineUtc', - 'AwaitingCommandReportEdge', - 'CommandReportMissLogged', - 'StaleReportSuppressedLogged', - 'BuildControlFeedbackReferences', - 'FindPointForControlFeedback' - ) - foreach ($token in $forbidden) { - if ($runtime.Contains($token)) { throw "Forbidden synthetic process-authority token remains: $token" } - } - if (-not $runtime.Contains('A real IED report is therefore never suppressed')) { - throw 'Independent IED report authority invariant is missing.' - } - if (-not $tests.Contains('CommandFeedbackGuardUntilUtc')) { - throw 'Regression test does not protect against command-derived report suppression.' - } - git diff --check - - - name: Commit root-cause fix and remove temporary workflow - shell: pwsh - run: | - Remove-Item .github/workflows/v1636-real-ied-authority-root-fix.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Services/Iec61850MonitorRuntime.cs tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs .github/workflows/v1636-real-ied-authority-root-fix.yml - git diff --cached --check - git commit -m "fix(control): keep process state authoritative to IED reports" - git push origin HEAD:fix/v1636-real-ied-feedback-smooth-analyzers diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index a6c1993f4..d9ed9af87 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -31,13 +31,6 @@ private sealed class RuntimePointState public bool ReportChangeVerified { get; set; } public DateTime LastReportUtc { get; set; } = DateTime.MinValue; public bool ReportMissLogged { get; set; } - public string CommandFeedbackValue { get; set; } = string.Empty; - public DateTime LastCommandFeedbackUtc { get; set; } = DateTime.MinValue; - public DateTime CommandFeedbackGuardUntilUtc { get; set; } = DateTime.MinValue; - public DateTime CommandReportDeadlineUtc { get; set; } = DateTime.MinValue; - public bool AwaitingCommandReportEdge { get; set; } - public bool CommandReportMissLogged { get; set; } - public bool StaleReportSuppressedLogged { get; set; } public bool ReportValueRejectedLogged { get; set; } public string LastLoggedDegradedQuality { get; set; } = string.Empty; public int ConsecutiveErrors { get; set; } @@ -686,50 +679,6 @@ public async Task ExecuteControlAsync( return result; } - private void ApplyControlFeedbackToMonitor(DeviceSession session, SignalDefinition signal, string feedbackValue) - { - var references = BuildControlFeedbackReferences(signal); - var point = FindPointForControlFeedback(session, references); - if (point == null || !session.States.TryGetValue(point.PointKey, out var state)) - { - Log("WARN", session.Device.Name, - $"Control feedback {feedbackValue} was confirmed by the IED, but no monitored status point matched {string.Join(", ", references)}. Live Monitor was not updated directly."); - return; - } - - var nowUtc = DateTime.UtcNow; - var display = Iec61850ValueFormatter.Format(feedbackValue, point.IecDataType, point.Unit); - state.CommandFeedbackValue = display; - state.LastCommandFeedbackUtc = nowUtc; - state.CommandFeedbackGuardUntilUtc = nowUtc.AddSeconds(2); - state.CommandReportDeadlineUtc = nowUtc.AddSeconds(2); - state.AwaitingCommandReportEdge = session.PointPlanIds.ContainsKey(point.PointKey); - state.CommandReportMissLogged = false; - state.StaleReportSuppressedLogged = false; - if (state.AwaitingCommandReportEdge) - state.ReportChangeVerified = false; - - var sourceMode = string.IsNullOrWhiteSpace(state.AcquisitionLabel) - ? "Control feedback" - : state.AcquisitionLabel; - ApplyValueUpdate( - session, - point, - display, - state.Quality, - state.DeviceTimestamp, - sourceMode, - "confirmed command feedback / awaiting matching dchg", - nowUtc, - state.AwaitingCommandReportEdge - ? "Live / command feedback immediate, awaiting report edge" - : "Live / control feedback confirmed", - trustReportEdge: false); - - Log("INFO", session.Device.Name, - $"Live Monitor feedback injected immediately: {point.IecReference}={display}; reportCorrelation={(state.AwaitingCommandReportEdge ? "awaiting dchg" : "not report-assigned")}."); - } - public HybridReportPhysicalValidationSnapshot CaptureHybridReportPhysicalValidation(string deviceId) { ArgumentException.ThrowIfNullOrWhiteSpace(deviceId); @@ -1233,45 +1182,14 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT state.ReportValueRejectedLogged = false; var receivedUtc = update.UpdatedAt == default ? DateTime.UtcNow : update.UpdatedAt.UtcDateTime; - var hasSourceTimestamp = TryParseReportTimestampUtc(update.ReportTimestamp, out var reportSourceUtc); - var commandValueMatches = update.HasValue && state.AwaitingCommandReportEdge && - AreSemanticallyEquivalent(point, state.CommandFeedbackValue, display); - var contradictsCommandFeedback = update.HasValue && state.AwaitingCommandReportEdge && - !commandValueMatches; - var provablyOlderThanCommand = contradictsCommandFeedback && hasSourceTimestamp && - reportSourceUtc < state.LastCommandFeedbackUtc.AddMilliseconds(-5); - var unlabelledSnapshotInsideGuard = contradictsCommandFeedback && !hasSourceTimestamp && - receivedUtc <= state.CommandFeedbackGuardUntilUtc && - !IsChangeReportReason(update.Reason); - if (provablyOlderThanCommand || unlabelledSnapshotInsideGuard) - { - state.ReportTrafficSeen = true; - state.LastReportUtc = DateTime.UtcNow; - if (!state.StaleReportSuppressedLogged) - { - state.StaleReportSuppressedLogged = true; - Log("WARN", session.Device.Name, - $"{point.SignalName}: stale report value {display} was suppressed after command-confirmed {state.CommandFeedbackValue}; reportTime={(hasSourceTimestamp ? reportSourceUtc.ToString("O", CultureInfo.InvariantCulture) : "not supplied")}."); - } - continue; - } - var hadValueBeforeReport = state.HasValue; var valueChangedByReport = update.HasValue && hadValueBeforeReport && HasExactSemanticEdge(point, state.Value, display); state.ReportTrafficSeen = true; state.LastReportUtc = DateTime.UtcNow; - if (commandValueMatches && - (!hasSourceTimestamp || reportSourceUtc >= state.LastCommandFeedbackUtc.AddMilliseconds(-5))) - { - state.AwaitingCommandReportEdge = false; - state.CommandReportMissLogged = false; - state.StaleReportSuppressedLogged = false; - state.ReportChangeVerified = true; - state.ReportMissLogged = false; - Log("INFO", session.Device.Name, - $"{point.SignalName}: event-driven report confirmed command feedback {display}; reason={update.Reason}; reportTime={(hasSourceTimestamp ? reportSourceUtc.ToString("O", CultureInfo.InvariantCulture) : "not supplied")}."); - } - else if (valueChangedByReport || IsChangeReportReason(update.Reason)) + // IEC 61850 process truth comes from independent acquisition, never from + // a prior control result. A real IED report is therefore never suppressed, + // rewritten, or correlated against command-side feedback before publication. + if (valueChangedByReport || IsChangeReportReason(update.Reason)) { state.ReportChangeVerified = true; state.ReportMissLogged = false; @@ -1542,15 +1460,6 @@ private async Task PollDuePointsAsync(DeviceSession session, CancellationToken c var normalizedQuality = NormalizeQuality(quality); var normalizedTimestamp = string.IsNullOrWhiteSpace(deviceTimestamp) ? "-" : deviceTimestamp; - if (reportAssigned && state.AwaitingCommandReportEdge && - nowUtc >= state.CommandReportDeadlineUtc && !state.CommandReportMissLogged) - { - state.CommandReportMissLogged = true; - state.ReportChangeVerified = false; - Log("WARN", session.Device.Name, - $"{point.SignalName}: command feedback reached {state.CommandFeedbackValue}, but no matching dchg report arrived within 2 seconds. MMS validation remains active until event delivery is proven."); - } - var pollDetectedChange = state.HasValue && !string.Equals(state.Value, display, StringComparison.Ordinal); var sourceMode = "MMS polling"; var reason = "cyclic"; @@ -1988,13 +1897,6 @@ private static void ResetAssociationReportEvidence(DeviceSession session) state.ReportChangeVerified = false; state.LastReportUtc = DateTime.MinValue; state.ReportMissLogged = false; - state.AwaitingCommandReportEdge = false; - state.CommandReportMissLogged = false; - state.StaleReportSuppressedLogged = false; - state.CommandFeedbackValue = string.Empty; - state.LastCommandFeedbackUtc = DateTime.MinValue; - state.CommandFeedbackGuardUntilUtc = DateTime.MinValue; - state.CommandReportDeadlineUtc = DateTime.MinValue; state.AcquisitionLabel = session.StaticDataSetReportOnly ? "Static DataSet report rearming" : "MMS polling"; state.SourceMode = session.StaticDataSetReportOnly ? "Static DataSet report rearming" @@ -2530,71 +2432,6 @@ private static void IndexPointReference(DeviceSession session, Iec61850MonitorPo session.ReportReferenceIndex.TryAdd(key, point); } - private static IReadOnlyList BuildControlFeedbackReferences(SignalDefinition signal) - { - var references = new List(3); - void Add(string? reference) - { - if (string.IsNullOrWhiteSpace(reference)) - return; - var trimmed = reference.Trim(); - if (!references.Contains(trimmed, StringComparer.OrdinalIgnoreCase)) - references.Add(trimmed); - } - - Add(signal.ControlStatusReference); - Add(signal.ObjectReference); - if (!string.IsNullOrWhiteSpace(signal.ObjectReference) && - !NormalizeReference(signal.ObjectReference).EndsWith(".stval", StringComparison.OrdinalIgnoreCase)) - { - Add(signal.ObjectReference.TrimEnd('.') + ".stVal"); - } - return references; - } - - private static Iec61850MonitorPoint? FindPointForControlFeedback( - DeviceSession session, - IReadOnlyList references) - { - foreach (var reference in references) - { - var exact = FindPointForReportReference(session, reference); - if (exact != null) - return exact; - } - - // Some IEDs expose control objects with the IED name prepended to the MMS - // domain (for example OLSF501CB1) while the live point uses CB1. Match the - // member path plus a unique domain suffix, never the member path alone when - // more than one monitored logical device could match. - foreach (var reference in references) - { - var source = CanonicalDataReference(reference); - var sourceSlash = source.IndexOf('/'); - if (sourceSlash <= 0 || sourceSlash >= source.Length - 1) - continue; - var sourceDomain = source[..sourceSlash]; - var sourceMember = source[(sourceSlash + 1)..]; - var candidates = session.Points.Values.Where(candidate => - { - var target = CanonicalDataReference(candidate.IecReference); - var targetSlash = target.IndexOf('/'); - if (targetSlash <= 0 || targetSlash >= target.Length - 1) - return false; - var targetDomain = target[..targetSlash]; - var targetMember = target[(targetSlash + 1)..]; - return sourceMember.Equals(targetMember, StringComparison.OrdinalIgnoreCase) && - (sourceDomain.EndsWith(targetDomain, StringComparison.OrdinalIgnoreCase) || - targetDomain.EndsWith(sourceDomain, StringComparison.OrdinalIgnoreCase)); - }).Distinct().ToArray(); - - if (candidates.Length == 1) - return candidates[0]; - } - - return null; - } - private static Iec61850MonitorPoint? FindPointForReportReference(DeviceSession session, string reference) { foreach (var key in GetReferenceKeys(reference)) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index 98d08cfe4..9351dab79 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -12,6 +12,13 @@ public void OperateSuccess_DoesNotInjectCommandFeedbackIntoMonitoredProcessState var commandWindowSource = File.ReadAllText(Path.Combine(root, "ControlCommandWindow.xaml.cs")); Assert.DoesNotContain("ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue)", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("ApplyControlFeedbackToMonitor(", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("CommandFeedbackValue", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("CommandFeedbackGuardUntilUtc", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("AwaitingCommandReportEdge", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("BuildControlFeedbackReferences", runtimeSource, StringComparison.Ordinal); + Assert.DoesNotContain("FindPointForControlFeedback", runtimeSource, StringComparison.Ordinal); + Assert.Contains("A real IED report is therefore never suppressed", runtimeSource, StringComparison.Ordinal); Assert.DoesNotContain("CurrentValue = result.FeedbackValue", commandWindowSource, StringComparison.Ordinal); Assert.Contains("Waiting for independent IED process feedback", commandWindowSource, StringComparison.Ordinal); Assert.Contains("not process-image authority", runtimeSource, StringComparison.Ordinal); From f1700ccc3ccf41895993ab827cb4c9e5fa8143e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 14:42:34 +0700 Subject: [PATCH 7/7] test: align reconnect regression with independent IED report authority --- tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs index 30e3e304e..fcc73011e 100644 --- a/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs +++ b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs @@ -197,6 +197,8 @@ public void ConcurrentMonitorUpdatesAndSnapshots_RemainAtomicAndConsistent() var planning = new NativeHybridReportPlanningResult { IsAuthoritative = true, + Authority = "ARIEC61850 MmsHybridReportAcquisitionPlanner", + Status = "FullReportCoverage", ReportPlans = [plan], StaticUrcbSignalCount = 1, Warnings = ["stable warning"] @@ -272,7 +274,7 @@ public void Reconnect_RecoversMmsFirst_AndDefersReportRearmToBackgroundPipeline( var reset = source[resetStart..resetEnd]; Assert.Contains("state.ReportTrafficSeen = false;", reset, StringComparison.Ordinal); Assert.Contains("state.ReportChangeVerified = false;", reset, StringComparison.Ordinal); - Assert.Contains("state.AwaitingCommandReportEdge = false;", reset, StringComparison.Ordinal); + Assert.DoesNotContain("AwaitingCommandReportEdge", reset, StringComparison.Ordinal); Assert.Contains("session.StaticDataSetReportOnly ? \"Static DataSet report rearming\" : \"MMS polling\"", reset, StringComparison.Ordinal); Assert.Contains("\"Report rearming / MMS polling fallback\"", reset, StringComparison.Ordinal); Assert.Contains("\"new MMS association / configured RCB evidence reset; MMS process fallback disabled\"", reset, StringComparison.Ordinal);