From e12be23fdb1bf9a34fe527b55905fb3ac8ff5dba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:48:48 +0700 Subject: [PATCH 1/6] chore: apply targeted ARSAS control and analyzer fix --- .github/workflows/arsas-targeted-fix.yml | 213 +++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 .github/workflows/arsas-targeted-fix.yml diff --git a/.github/workflows/arsas-targeted-fix.yml b/.github/workflows/arsas-targeted-fix.yml new file mode 100644 index 000000000..2482185a8 --- /dev/null +++ b/.github/workflows/arsas-targeted-fix.yml @@ -0,0 +1,213 @@ +name: ARSAS targeted control and analyzer fix + +on: + push: + branches: + - fix/real-ied-feedback-smooth-analyzers + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Apply focused source patch + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + def replace_exact(path, old, new): + p = Path(path) + text = p.read_text(encoding='utf-8') + if old not in text: + raise SystemExit(f'Expected source block not found in {path}') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + + # 1) Strict IEC 61850 control/process separation: never inject command result into monitored stVal. + replace_exact( + 'Services/Iec61850MonitorRuntime.cs', + ''' if (!request.TestMode && result.FeedbackConfirmed && !string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-")\n ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue);\n\n''', + ''' // IMPORTANT: a successful/confirmed IEC 61850 control service is command-path evidence,\n // not process-image authority. Never synthesize or inject stVal from Operate/SBO feedback here.\n // The monitored state changes only through the independent acquisition path (RCB/report or\n // an explicit authoritative MMS read performed by the monitor), matching IED engineering tools.\n\n''') + + # 2) Command window must not display the command result as if it were the actual process position. + replace_exact( + 'ControlCommandWindow.xaml.cs', + ''' CommandStage = result.Stage;\n CommandStatus = BuildCommandResultText(result);\n if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-")\n CurrentValue = result.FeedbackValue;\n SetResultTone(result.IsSuccess ? "Success" : "Error");\n''', + ''' CommandStage = result.IsSuccess && !TestMode ? "Command accepted" : result.Stage;\n CommandStatus = BuildCommandResultText(result);\n if (result.IsSuccess && !TestMode)\n {\n CommandStatus += " Command accepted by the IEC 61850 control service. Waiting for independent IED process feedback; monitored stVal is not changed from the command path.";\n }\n SetResultTone(result.IsSuccess ? "Success" : "Error");\n''') + replace_exact( + 'ControlCommandWindow.xaml.cs', + ''' if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-")\n details.Add($"Process feedback: {result.FeedbackElapsedText}.");\n''', + ''' if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-")\n details.Add($"Control-side feedback verification: {result.FeedbackElapsedText}. This does not overwrite monitored stVal.");\n''') + + # 3) Time-based easing math shared by phasor + harmonics presentation only. + Path('Services/PresentationEasingMath.cs').write_text(r'''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; + + // Time-based, therefore visually consistent when the UI cadence changes. + 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)); + } +} +''', encoding='utf-8') + + # 4) Phasor: smooth only display vectors at analysis-result cadence; no timer, no raw-value mutation. + replace_exact( + 'Controls/ComtradePhasorView.cs', + '''using System.Globalization;\nusing System.Windows;\nusing System.Windows.Media;\n''', + '''using System.Diagnostics;\nusing System.Globalization;\nusing System.Windows;\nusing System.Windows.Media;\nusing ArIED61850Tester.Services;\n''') + replace_exact( + 'Controls/ComtradePhasorView.cs', + ''' private string _headerLabel = "Fundamental phasors at C1";\n private string _referenceDetail = "Select a valid analysis reference";\n private string _message = string.Empty;\n''', + ''' private const double PresentationTimeConstantMs = 78.0;\n private string _headerLabel = "Fundamental phasors at C1";\n private string _referenceDetail = "Select a valid analysis reference";\n private string _message = string.Empty;\n private ComtradePhasorVector[] _smoothedVoltageVectors = Array.Empty();\n private ComtradePhasorVector[] _smoothedCurrentVectors = Array.Empty();\n private long _lastPresentationTimestamp;\n''') + replace_exact( + 'Controls/ComtradePhasorView.cs', + ''' var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel;\n _headerLabel = $"Fundamental phasors at {resolvedReference}";\n _referenceDetail = referenceDetail ?? string.Empty;\n _voltagePanel = PreparePanel(voltageVectors);\n _currentPanel = PreparePanel(currentVectors);\n _message = string.Empty;\n InvalidateVisual();\n''', + ''' var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel;\n _headerLabel = $"Fundamental phasors at {resolvedReference}";\n _referenceDetail = referenceDetail ?? string.Empty;\n\n var now = Stopwatch.GetTimestamp();\n var elapsedMilliseconds = _lastPresentationTimestamp == 0\n ? double.PositiveInfinity\n : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds;\n _lastPresentationTimestamp = now;\n\n _smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, voltageVectors, elapsedMilliseconds);\n _smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, currentVectors, elapsedMilliseconds);\n _voltagePanel = PreparePanel(_smoothedVoltageVectors);\n _currentPanel = PreparePanel(_smoothedCurrentVectors);\n _message = string.Empty;\n InvalidateVisual();\n''') + replace_exact( + 'Controls/ComtradePhasorView.cs', + ''' _voltagePanel = PreparedPhasorPanel.Empty;\n _currentPanel = PreparedPhasorPanel.Empty;\n _message = message ?? string.Empty;\n InvalidateVisual();\n''', + ''' _voltagePanel = PreparedPhasorPanel.Empty;\n _currentPanel = PreparedPhasorPanel.Empty;\n _smoothedVoltageVectors = Array.Empty();\n _smoothedCurrentVectors = Array.Empty();\n _lastPresentationTimestamp = 0;\n _message = message ?? string.Empty;\n InvalidateVisual();\n''') + replace_exact( + 'Controls/ComtradePhasorView.cs', + ''' private static PreparedPhasorPanel PreparePanel(IReadOnlyList? source)\n''', + ''' private static ComtradePhasorVector[] SmoothVectors(\n IReadOnlyList previous,\n IReadOnlyList? target,\n double elapsedMilliseconds)\n {\n if (target is null || target.Count == 0)\n return Array.Empty();\n\n var topologyMatches = previous.Count == target.Count && previous.Count > 0;\n if (topologyMatches)\n {\n for (var index = 0; index < target.Count; index++)\n {\n if (!string.Equals(previous[index].Label, target[index].Label, StringComparison.Ordinal) ||\n !string.Equals(previous[index].Phase, target[index].Phase, StringComparison.Ordinal) ||\n !string.Equals(previous[index].Units, target[index].Units, StringComparison.Ordinal))\n {\n topologyMatches = false;\n break;\n }\n }\n }\n\n var output = new ComtradePhasorVector[target.Count];\n if (!topologyMatches)\n {\n for (var index = 0; index < target.Count; index++)\n output[index] = target[index];\n return output; // First sample / topology change snaps immediately: no artificial ramp from zero.\n }\n\n for (var index = 0; index < target.Count; index++)\n {\n var before = previous[index];\n var next = target[index];\n output[index] = new ComtradePhasorVector(\n next.Label,\n next.Phase,\n next.Units,\n PresentationEasingMath.Smooth(before.MagnitudeRms, next.MagnitudeRms, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.SmoothAngleDegrees(before.AngleDegrees, next.AngleDegrees, elapsedMilliseconds, PresentationTimeConstantMs));\n }\n return output;\n }\n\n private static PreparedPhasorPanel PreparePanel(IReadOnlyList? source)\n''') + + # 5) Harmonics workstation: smooth display spectra only; preserve raw/native spectrum and analysis result. + replace_exact( + 'Controls/ComtradeHarmonicsWorkstationView.cs', + '''using System.Globalization;\nusing System.Windows;\n''', + '''using System.Diagnostics;\nusing System.Globalization;\nusing System.Windows;\n''') + replace_exact( + 'Controls/ComtradeHarmonicsWorkstationView.cs', + ''' private IReadOnlyList _spectra = Array.Empty();\n private PreparedSpectrumRow[] _preparedRows = Array.Empty();\n''', + ''' private const double PresentationTimeConstantMs = 92.0;\n private IReadOnlyList _spectra = Array.Empty();\n private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty();\n private long _lastPresentationTimestamp;\n private PreparedSpectrumRow[] _preparedRows = Array.Empty();\n''') + replace_exact( + 'Controls/ComtradeHarmonicsWorkstationView.cs', + ''' _title = title ?? string.Empty;\n _subtitle = subtitle ?? string.Empty;\n _spectra = spectra ?? Array.Empty();\n _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_spectra);\n''', + ''' _title = title ?? string.Empty;\n _subtitle = subtitle ?? string.Empty;\n var targetSpectra = spectra ?? Array.Empty();\n var now = Stopwatch.GetTimestamp();\n var elapsedMilliseconds = _lastPresentationTimestamp == 0\n ? double.PositiveInfinity\n : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds;\n _lastPresentationTimestamp = now;\n _smoothedSpectra = SmoothSpectra(_smoothedSpectra, targetSpectra, elapsedMilliseconds);\n _spectra = _smoothedSpectra;\n _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_spectra);\n''') + replace_exact( + 'Controls/ComtradeHarmonicsWorkstationView.cs', + ''' _spectra = Array.Empty();\n _preparedRows = Array.Empty();\n _maximumDisplayedOrder = -1;\n''', + ''' _spectra = Array.Empty();\n _smoothedSpectra = Array.Empty();\n _lastPresentationTimestamp = 0;\n _preparedRows = Array.Empty();\n _maximumDisplayedOrder = -1;\n''') + replace_exact( + 'Controls/ComtradeHarmonicsWorkstationView.cs', + ''' private static PreparedSpectrumRow[] PrepareRows(\n''', + ''' private static ComtradeHarmonicOverviewSpectrum[] SmoothSpectra(\n IReadOnlyList previous,\n IReadOnlyList target,\n double elapsedMilliseconds)\n {\n if (target.Count == 0)\n return Array.Empty();\n\n var topologyMatches = previous.Count == target.Count && previous.Count > 0;\n if (topologyMatches)\n {\n for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++)\n {\n var before = previous[spectrumIndex];\n var next = target[spectrumIndex];\n if (!string.Equals(before.SignalName, next.SignalName, StringComparison.Ordinal) ||\n !string.Equals(before.Units, next.Units, StringComparison.Ordinal) ||\n before.Bins.Count != next.Bins.Count)\n {\n topologyMatches = false;\n break;\n }\n for (var binIndex = 0; binIndex < next.Bins.Count; binIndex++)\n {\n if (before.Bins[binIndex].Order != next.Bins[binIndex].Order)\n {\n topologyMatches = false;\n break;\n }\n }\n if (!topologyMatches) break;\n }\n }\n\n var result = new ComtradeHarmonicOverviewSpectrum[target.Count];\n for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++)\n {\n var next = target[spectrumIndex];\n if (!topologyMatches)\n {\n result[spectrumIndex] = next with { Bins = next.Bins.ToArray() };\n continue; // First sample / channel-set change snaps; smoothing never invents a ramp from zero.\n }\n\n var before = previous[spectrumIndex];\n var bins = new ComtradeHarmonicDisplayBin[next.Bins.Count];\n for (var binIndex = 0; binIndex < bins.Length; binIndex++)\n {\n var previousBin = before.Bins[binIndex];\n var targetBin = next.Bins[binIndex];\n bins[binIndex] = new ComtradeHarmonicDisplayBin(\n targetBin.Order,\n PresentationEasingMath.Smooth(previousBin.MagnitudeRms, targetBin.MagnitudeRms, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.Smooth(previousBin.PercentOfFundamental, targetBin.PercentOfFundamental, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.SmoothAngleDegrees(previousBin.AngleDegrees, targetBin.AngleDegrees, elapsedMilliseconds, PresentationTimeConstantMs));\n }\n\n result[spectrumIndex] = new ComtradeHarmonicOverviewSpectrum(\n next.SignalName,\n next.Units,\n PresentationEasingMath.Smooth(before.DcComponent, next.DcComponent, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.Smooth(before.FundamentalRms, next.FundamentalRms, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.Smooth(before.ThdPercent, next.ThdPercent, elapsedMilliseconds, PresentationTimeConstantMs),\n next.DominantOrder,\n PresentationEasingMath.Smooth(before.DominantRms, next.DominantRms, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.Smooth(before.DominantPercent, next.DominantPercent, elapsedMilliseconds, PresentationTimeConstantMs),\n next.EstimatedSampleRateHz,\n next.MaximumResolvableOrder,\n bins);\n }\n return result;\n }\n\n private static PreparedSpectrumRow[] PrepareRows(\n''') + + # 6) Regression tests: control success cannot mutate monitored stVal + easing math is cadence-independent/angle-safe. + g1 = Path('tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs') + g1_text = g1.read_text(encoding='utf-8') + anchor = ''' [Fact]\n public void CdcDerivation_MapsRepresentativeControlModelClasses()\n''' + if anchor not in g1_text: + raise SystemExit('G1 test insertion anchor not found') + test_block = r''' [Fact] + public void OperateSuccess_DoesNotInjectCommandFeedbackIntoMonitoredProcessState() + { + var runtimeSource = File.ReadAllText(Path.GetFullPath("../../../../Services/Iec61850MonitorRuntime.cs")); + var commandWindowSource = File.ReadAllText(Path.GetFullPath("../../../../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); + } + +''' + g1.write_text(g1_text.replace(anchor, test_block + anchor, 1), encoding='utf-8') + + Path('tests/ARSAS.Tests/PresentationEasingMathTests.cs').write_text(r'''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); + } +} +''', encoding='utf-8') + PY + + rm -f .github/workflows/arsas-targeted-fix.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix: separate control feedback and smooth analyzers" + git push origin "HEAD:${GITHUB_REF_NAME}" From 889fdadbc979330a297937456341a1d6628bf628 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:51:00 +0700 Subject: [PATCH 2/6] chore: stage targeted ARSAS patch script --- .github/scripts/apply_arsas_targeted_fix.py | 418 ++++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 .github/scripts/apply_arsas_targeted_fix.py diff --git a/.github/scripts/apply_arsas_targeted_fix.py b/.github/scripts/apply_arsas_targeted_fix.py new file mode 100644 index 000000000..3641e3ef0 --- /dev/null +++ b/.github/scripts/apply_arsas_targeted_fix.py @@ -0,0 +1,418 @@ +from pathlib import Path + + +def replace_exact(path: str, old: str, new: str) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + if old not in text: + raise SystemExit(f"Expected source block not found in {path}") + p.write_text(text.replace(old, new, 1), encoding="utf-8") + + +# 1) Strict IEC 61850 control/process separation: never inject command result into monitored stVal. +replace_exact( + "Services/Iec61850MonitorRuntime.cs", + ''' 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. + +''') + +# 2) Command window must not display the command result as if it were the actual process position. +replace_exact( + "ControlCommandWindow.xaml.cs", + ''' CommandStage = result.Stage; + CommandStatus = BuildCommandResultText(result); + if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") + CurrentValue = result.FeedbackValue; + SetResultTone(result.IsSuccess ? "Success" : "Error"); +''', + ''' CommandStage = result.IsSuccess && !TestMode ? "Command accepted" : result.Stage; + CommandStatus = BuildCommandResultText(result); + 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"); +''') +replace_exact( + "ControlCommandWindow.xaml.cs", + ''' if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-") + details.Add($"Process feedback: {result.FeedbackElapsedText}."); +''', + ''' if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-") + details.Add($"Control-side feedback verification: {result.FeedbackElapsedText}. This does not overwrite monitored stVal."); +''') + +# 3) Shared time-based, allocation-free easing math. +Path("Services/PresentationEasingMath.cs").write_text(r'''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)); + } +} +''', encoding="utf-8") + +# 4) Phasor smoothing happens only in the presentation layer, at incoming analysis-result cadence. +replace_exact( + "Controls/ComtradePhasorView.cs", + '''using System.Globalization; +using System.Windows; +using System.Windows.Media; +''', + '''using System.Diagnostics; +using System.Globalization; +using System.Windows; +using System.Windows.Media; +using ArIED61850Tester.Services; +''') +replace_exact( + "Controls/ComtradePhasorView.cs", + ''' private string _headerLabel = "Fundamental phasors at C1"; + private string _referenceDetail = "Select a valid analysis reference"; + private string _message = string.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; +''') +replace_exact( + "Controls/ComtradePhasorView.cs", + ''' var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel; + _headerLabel = $"Fundamental phasors at {resolvedReference}"; + _referenceDetail = referenceDetail ?? string.Empty; + _voltagePanel = PreparePanel(voltageVectors); + _currentPanel = PreparePanel(currentVectors); + _message = string.Empty; + InvalidateVisual(); +''', + ''' 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(); +''') +replace_exact( + "Controls/ComtradePhasorView.cs", + ''' _voltagePanel = PreparedPhasorPanel.Empty; + _currentPanel = PreparedPhasorPanel.Empty; + _message = message ?? string.Empty; + InvalidateVisual(); +''', + ''' _voltagePanel = PreparedPhasorPanel.Empty; + _currentPanel = PreparedPhasorPanel.Empty; + _smoothedVoltageVectors = Array.Empty(); + _smoothedCurrentVectors = Array.Empty(); + _lastPresentationTimestamp = 0; + _message = message ?? string.Empty; + InvalidateVisual(); +''') +replace_exact( + "Controls/ComtradePhasorView.cs", + ''' private static PreparedPhasorPanel PreparePanel(IReadOnlyList? source) +''', + ''' 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) +''') + +# 5) Harmonics workstation smoothing, also display-only and time-based. +replace_exact( + "Controls/ComtradeHarmonicsWorkstationView.cs", + '''using System.Globalization; +using System.Windows; +''', + '''using System.Diagnostics; +using System.Globalization; +using System.Windows; +''') +replace_exact( + "Controls/ComtradeHarmonicsWorkstationView.cs", + ''' private IReadOnlyList _spectra = Array.Empty(); + private PreparedSpectrumRow[] _preparedRows = Array.Empty(); +''', + ''' private const double PresentationTimeConstantMs = 92.0; + private IReadOnlyList _spectra = Array.Empty(); + private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); + private long _lastPresentationTimestamp; + private PreparedSpectrumRow[] _preparedRows = Array.Empty(); +''') +replace_exact( + "Controls/ComtradeHarmonicsWorkstationView.cs", + ''' _title = title ?? string.Empty; + _subtitle = subtitle ?? string.Empty; + _spectra = spectra ?? Array.Empty(); + _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_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); +''') +replace_exact( + "Controls/ComtradeHarmonicsWorkstationView.cs", + ''' _spectra = Array.Empty(); + _preparedRows = Array.Empty(); + _maximumDisplayedOrder = -1; +''', + ''' _spectra = Array.Empty(); + _smoothedSpectra = Array.Empty(); + _lastPresentationTimestamp = 0; + _preparedRows = Array.Empty(); + _maximumDisplayedOrder = -1; +''') +replace_exact( + "Controls/ComtradeHarmonicsWorkstationView.cs", + ''' private static PreparedSpectrumRow[] PrepareRows( +''', + ''' 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( +''') + +# 6) Regression tests. +g1 = Path("tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs") +g1_text = g1.read_text(encoding="utf-8") +anchor = ''' [Fact] + public void CdcDerivation_MapsRepresentativeControlModelClasses() +''' +if anchor not in g1_text: + raise SystemExit("G1 test insertion anchor not found") +test_block = r''' [Fact] + public void OperateSuccess_DoesNotInjectCommandFeedbackIntoMonitoredProcessState() + { + var runtimeSource = File.ReadAllText(Path.GetFullPath("../../../../Services/Iec61850MonitorRuntime.cs")); + var commandWindowSource = File.ReadAllText(Path.GetFullPath("../../../../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); + } + +''' +g1.write_text(g1_text.replace(anchor, test_block + anchor, 1), encoding="utf-8") + +Path("tests/ARSAS.Tests/PresentationEasingMathTests.cs").write_text(r'''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); + } +} +''', encoding="utf-8") From 16df0521a120ad0d91b09ef69d5014effa94883d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:51:12 +0700 Subject: [PATCH 3/6] chore: fix targeted patch runner --- .github/workflows/arsas-targeted-fix.yml | 191 +---------------------- 1 file changed, 4 insertions(+), 187 deletions(-) diff --git a/.github/workflows/arsas-targeted-fix.yml b/.github/workflows/arsas-targeted-fix.yml index 2482185a8..a9df4c2b6 100644 --- a/.github/workflows/arsas-targeted-fix.yml +++ b/.github/workflows/arsas-targeted-fix.yml @@ -3,208 +3,25 @@ name: ARSAS targeted control and analyzer fix on: push: branches: - - fix/real-ied-feedback-smooth-analyzers + - "fix/real-ied-feedback-smooth-analyzers" permissions: contents: write jobs: patch: - if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Apply focused source patch + run: python .github/scripts/apply_arsas_targeted_fix.py + - name: Validate and commit patch shell: bash run: | - python - <<'PY' - from pathlib import Path - - def replace_exact(path, old, new): - p = Path(path) - text = p.read_text(encoding='utf-8') - if old not in text: - raise SystemExit(f'Expected source block not found in {path}') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - - # 1) Strict IEC 61850 control/process separation: never inject command result into monitored stVal. - replace_exact( - 'Services/Iec61850MonitorRuntime.cs', - ''' if (!request.TestMode && result.FeedbackConfirmed && !string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-")\n ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue);\n\n''', - ''' // IMPORTANT: a successful/confirmed IEC 61850 control service is command-path evidence,\n // not process-image authority. Never synthesize or inject stVal from Operate/SBO feedback here.\n // The monitored state changes only through the independent acquisition path (RCB/report or\n // an explicit authoritative MMS read performed by the monitor), matching IED engineering tools.\n\n''') - - # 2) Command window must not display the command result as if it were the actual process position. - replace_exact( - 'ControlCommandWindow.xaml.cs', - ''' CommandStage = result.Stage;\n CommandStatus = BuildCommandResultText(result);\n if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-")\n CurrentValue = result.FeedbackValue;\n SetResultTone(result.IsSuccess ? "Success" : "Error");\n''', - ''' CommandStage = result.IsSuccess && !TestMode ? "Command accepted" : result.Stage;\n CommandStatus = BuildCommandResultText(result);\n if (result.IsSuccess && !TestMode)\n {\n CommandStatus += " Command accepted by the IEC 61850 control service. Waiting for independent IED process feedback; monitored stVal is not changed from the command path.";\n }\n SetResultTone(result.IsSuccess ? "Success" : "Error");\n''') - replace_exact( - 'ControlCommandWindow.xaml.cs', - ''' if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-")\n details.Add($"Process feedback: {result.FeedbackElapsedText}.");\n''', - ''' if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-")\n details.Add($"Control-side feedback verification: {result.FeedbackElapsedText}. This does not overwrite monitored stVal.");\n''') - - # 3) Time-based easing math shared by phasor + harmonics presentation only. - Path('Services/PresentationEasingMath.cs').write_text(r'''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; - - // Time-based, therefore visually consistent when the UI cadence changes. - 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)); - } -} -''', encoding='utf-8') - - # 4) Phasor: smooth only display vectors at analysis-result cadence; no timer, no raw-value mutation. - replace_exact( - 'Controls/ComtradePhasorView.cs', - '''using System.Globalization;\nusing System.Windows;\nusing System.Windows.Media;\n''', - '''using System.Diagnostics;\nusing System.Globalization;\nusing System.Windows;\nusing System.Windows.Media;\nusing ArIED61850Tester.Services;\n''') - replace_exact( - 'Controls/ComtradePhasorView.cs', - ''' private string _headerLabel = "Fundamental phasors at C1";\n private string _referenceDetail = "Select a valid analysis reference";\n private string _message = string.Empty;\n''', - ''' private const double PresentationTimeConstantMs = 78.0;\n private string _headerLabel = "Fundamental phasors at C1";\n private string _referenceDetail = "Select a valid analysis reference";\n private string _message = string.Empty;\n private ComtradePhasorVector[] _smoothedVoltageVectors = Array.Empty();\n private ComtradePhasorVector[] _smoothedCurrentVectors = Array.Empty();\n private long _lastPresentationTimestamp;\n''') - replace_exact( - 'Controls/ComtradePhasorView.cs', - ''' var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel;\n _headerLabel = $"Fundamental phasors at {resolvedReference}";\n _referenceDetail = referenceDetail ?? string.Empty;\n _voltagePanel = PreparePanel(voltageVectors);\n _currentPanel = PreparePanel(currentVectors);\n _message = string.Empty;\n InvalidateVisual();\n''', - ''' var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel;\n _headerLabel = $"Fundamental phasors at {resolvedReference}";\n _referenceDetail = referenceDetail ?? string.Empty;\n\n var now = Stopwatch.GetTimestamp();\n var elapsedMilliseconds = _lastPresentationTimestamp == 0\n ? double.PositiveInfinity\n : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds;\n _lastPresentationTimestamp = now;\n\n _smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, voltageVectors, elapsedMilliseconds);\n _smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, currentVectors, elapsedMilliseconds);\n _voltagePanel = PreparePanel(_smoothedVoltageVectors);\n _currentPanel = PreparePanel(_smoothedCurrentVectors);\n _message = string.Empty;\n InvalidateVisual();\n''') - replace_exact( - 'Controls/ComtradePhasorView.cs', - ''' _voltagePanel = PreparedPhasorPanel.Empty;\n _currentPanel = PreparedPhasorPanel.Empty;\n _message = message ?? string.Empty;\n InvalidateVisual();\n''', - ''' _voltagePanel = PreparedPhasorPanel.Empty;\n _currentPanel = PreparedPhasorPanel.Empty;\n _smoothedVoltageVectors = Array.Empty();\n _smoothedCurrentVectors = Array.Empty();\n _lastPresentationTimestamp = 0;\n _message = message ?? string.Empty;\n InvalidateVisual();\n''') - replace_exact( - 'Controls/ComtradePhasorView.cs', - ''' private static PreparedPhasorPanel PreparePanel(IReadOnlyList? source)\n''', - ''' private static ComtradePhasorVector[] SmoothVectors(\n IReadOnlyList previous,\n IReadOnlyList? target,\n double elapsedMilliseconds)\n {\n if (target is null || target.Count == 0)\n return Array.Empty();\n\n var topologyMatches = previous.Count == target.Count && previous.Count > 0;\n if (topologyMatches)\n {\n for (var index = 0; index < target.Count; index++)\n {\n if (!string.Equals(previous[index].Label, target[index].Label, StringComparison.Ordinal) ||\n !string.Equals(previous[index].Phase, target[index].Phase, StringComparison.Ordinal) ||\n !string.Equals(previous[index].Units, target[index].Units, StringComparison.Ordinal))\n {\n topologyMatches = false;\n break;\n }\n }\n }\n\n var output = new ComtradePhasorVector[target.Count];\n if (!topologyMatches)\n {\n for (var index = 0; index < target.Count; index++)\n output[index] = target[index];\n return output; // First sample / topology change snaps immediately: no artificial ramp from zero.\n }\n\n for (var index = 0; index < target.Count; index++)\n {\n var before = previous[index];\n var next = target[index];\n output[index] = new ComtradePhasorVector(\n next.Label,\n next.Phase,\n next.Units,\n PresentationEasingMath.Smooth(before.MagnitudeRms, next.MagnitudeRms, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.SmoothAngleDegrees(before.AngleDegrees, next.AngleDegrees, elapsedMilliseconds, PresentationTimeConstantMs));\n }\n return output;\n }\n\n private static PreparedPhasorPanel PreparePanel(IReadOnlyList? source)\n''') - - # 5) Harmonics workstation: smooth display spectra only; preserve raw/native spectrum and analysis result. - replace_exact( - 'Controls/ComtradeHarmonicsWorkstationView.cs', - '''using System.Globalization;\nusing System.Windows;\n''', - '''using System.Diagnostics;\nusing System.Globalization;\nusing System.Windows;\n''') - replace_exact( - 'Controls/ComtradeHarmonicsWorkstationView.cs', - ''' private IReadOnlyList _spectra = Array.Empty();\n private PreparedSpectrumRow[] _preparedRows = Array.Empty();\n''', - ''' private const double PresentationTimeConstantMs = 92.0;\n private IReadOnlyList _spectra = Array.Empty();\n private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty();\n private long _lastPresentationTimestamp;\n private PreparedSpectrumRow[] _preparedRows = Array.Empty();\n''') - replace_exact( - 'Controls/ComtradeHarmonicsWorkstationView.cs', - ''' _title = title ?? string.Empty;\n _subtitle = subtitle ?? string.Empty;\n _spectra = spectra ?? Array.Empty();\n _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_spectra);\n''', - ''' _title = title ?? string.Empty;\n _subtitle = subtitle ?? string.Empty;\n var targetSpectra = spectra ?? Array.Empty();\n var now = Stopwatch.GetTimestamp();\n var elapsedMilliseconds = _lastPresentationTimestamp == 0\n ? double.PositiveInfinity\n : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds;\n _lastPresentationTimestamp = now;\n _smoothedSpectra = SmoothSpectra(_smoothedSpectra, targetSpectra, elapsedMilliseconds);\n _spectra = _smoothedSpectra;\n _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_spectra);\n''') - replace_exact( - 'Controls/ComtradeHarmonicsWorkstationView.cs', - ''' _spectra = Array.Empty();\n _preparedRows = Array.Empty();\n _maximumDisplayedOrder = -1;\n''', - ''' _spectra = Array.Empty();\n _smoothedSpectra = Array.Empty();\n _lastPresentationTimestamp = 0;\n _preparedRows = Array.Empty();\n _maximumDisplayedOrder = -1;\n''') - replace_exact( - 'Controls/ComtradeHarmonicsWorkstationView.cs', - ''' private static PreparedSpectrumRow[] PrepareRows(\n''', - ''' private static ComtradeHarmonicOverviewSpectrum[] SmoothSpectra(\n IReadOnlyList previous,\n IReadOnlyList target,\n double elapsedMilliseconds)\n {\n if (target.Count == 0)\n return Array.Empty();\n\n var topologyMatches = previous.Count == target.Count && previous.Count > 0;\n if (topologyMatches)\n {\n for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++)\n {\n var before = previous[spectrumIndex];\n var next = target[spectrumIndex];\n if (!string.Equals(before.SignalName, next.SignalName, StringComparison.Ordinal) ||\n !string.Equals(before.Units, next.Units, StringComparison.Ordinal) ||\n before.Bins.Count != next.Bins.Count)\n {\n topologyMatches = false;\n break;\n }\n for (var binIndex = 0; binIndex < next.Bins.Count; binIndex++)\n {\n if (before.Bins[binIndex].Order != next.Bins[binIndex].Order)\n {\n topologyMatches = false;\n break;\n }\n }\n if (!topologyMatches) break;\n }\n }\n\n var result = new ComtradeHarmonicOverviewSpectrum[target.Count];\n for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++)\n {\n var next = target[spectrumIndex];\n if (!topologyMatches)\n {\n result[spectrumIndex] = next with { Bins = next.Bins.ToArray() };\n continue; // First sample / channel-set change snaps; smoothing never invents a ramp from zero.\n }\n\n var before = previous[spectrumIndex];\n var bins = new ComtradeHarmonicDisplayBin[next.Bins.Count];\n for (var binIndex = 0; binIndex < bins.Length; binIndex++)\n {\n var previousBin = before.Bins[binIndex];\n var targetBin = next.Bins[binIndex];\n bins[binIndex] = new ComtradeHarmonicDisplayBin(\n targetBin.Order,\n PresentationEasingMath.Smooth(previousBin.MagnitudeRms, targetBin.MagnitudeRms, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.Smooth(previousBin.PercentOfFundamental, targetBin.PercentOfFundamental, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.SmoothAngleDegrees(previousBin.AngleDegrees, targetBin.AngleDegrees, elapsedMilliseconds, PresentationTimeConstantMs));\n }\n\n result[spectrumIndex] = new ComtradeHarmonicOverviewSpectrum(\n next.SignalName,\n next.Units,\n PresentationEasingMath.Smooth(before.DcComponent, next.DcComponent, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.Smooth(before.FundamentalRms, next.FundamentalRms, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.Smooth(before.ThdPercent, next.ThdPercent, elapsedMilliseconds, PresentationTimeConstantMs),\n next.DominantOrder,\n PresentationEasingMath.Smooth(before.DominantRms, next.DominantRms, elapsedMilliseconds, PresentationTimeConstantMs),\n PresentationEasingMath.Smooth(before.DominantPercent, next.DominantPercent, elapsedMilliseconds, PresentationTimeConstantMs),\n next.EstimatedSampleRateHz,\n next.MaximumResolvableOrder,\n bins);\n }\n return result;\n }\n\n private static PreparedSpectrumRow[] PrepareRows(\n''') - - # 6) Regression tests: control success cannot mutate monitored stVal + easing math is cadence-independent/angle-safe. - g1 = Path('tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs') - g1_text = g1.read_text(encoding='utf-8') - anchor = ''' [Fact]\n public void CdcDerivation_MapsRepresentativeControlModelClasses()\n''' - if anchor not in g1_text: - raise SystemExit('G1 test insertion anchor not found') - test_block = r''' [Fact] - public void OperateSuccess_DoesNotInjectCommandFeedbackIntoMonitoredProcessState() - { - var runtimeSource = File.ReadAllText(Path.GetFullPath("../../../../Services/Iec61850MonitorRuntime.cs")); - var commandWindowSource = File.ReadAllText(Path.GetFullPath("../../../../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); - } - -''' - g1.write_text(g1_text.replace(anchor, test_block + anchor, 1), encoding='utf-8') - - Path('tests/ARSAS.Tests/PresentationEasingMathTests.cs').write_text(r'''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); - } -} -''', encoding='utf-8') - PY - rm -f .github/workflows/arsas-targeted-fix.yml + rm -f .github/scripts/apply_arsas_targeted_fix.py git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A From ad18bdc0bfe9c5373db362ab1e2a2ddd1c27689f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:52:17 +0700 Subject: [PATCH 4/6] chore: add targeted patch validation --- .github/scripts/finish_arsas_targeted_fix.py | 75 ++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/scripts/finish_arsas_targeted_fix.py diff --git a/.github/scripts/finish_arsas_targeted_fix.py b/.github/scripts/finish_arsas_targeted_fix.py new file mode 100644 index 000000000..28ebf9aad --- /dev/null +++ b/.github/scripts/finish_arsas_targeted_fix.py @@ -0,0 +1,75 @@ +from pathlib import Path + +runtime = Path("Services/Iec61850MonitorRuntime.cs").read_text(encoding="utf-8") +command_ui = Path("ControlCommandWindow.xaml.cs").read_text(encoding="utf-8") +phasor = Path("Controls/ComtradePhasorView.cs").read_text(encoding="utf-8") +harmonics = Path("Controls/ComtradeHarmonicsWorkstationView.cs").read_text(encoding="utf-8") + +required = [ + ("runtime command/process separation", "ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue)" not in runtime), + ("runtime authority comment", "not process-image authority" in runtime), + ("command UI does not overwrite CurrentValue", "CurrentValue = result.FeedbackValue" not in command_ui), + ("command UI waits for IED process feedback", "Waiting for independent IED process feedback" in command_ui), + ("phasor smoothing", "SmoothVectors(" in phasor and "SmoothAngleDegrees" in phasor), + ("harmonic smoothing", "SmoothSpectra(" in harmonics and "PresentationTimeConstantMs = 92.0" in harmonics), + ("shared easing helper", Path("Services/PresentationEasingMath.cs").exists()), +] +for label, ok in required: + if not ok: + raise SystemExit(f"Targeted patch validation failed: {label}") + +g1 = Path("tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs") +g1_text = g1.read_text(encoding="utf-8") +if "OperateSuccess_DoesNotInjectCommandFeedbackIntoMonitoredProcessState" not in g1_text: + anchor = "public sealed class G1ControlCorrectnessRegressionTests\n{\n" + if anchor not in g1_text: + raise SystemExit("G1 class insertion anchor not found") + test_block = r''' [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); + } + +''' + g1_text = g1_text.replace(anchor, anchor + test_block, 1) + g1.write_text(g1_text, encoding="utf-8") + +Path("tests/ARSAS.Tests/PresentationEasingMathTests.cs").write_text(r'''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); + } +} +''', encoding="utf-8") From de828cb94c462e7b1d06bc49a61a63efb27fdf06 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 21:52:30 +0700 Subject: [PATCH 5/6] chore: complete targeted patch runner --- .github/workflows/arsas-targeted-fix.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/arsas-targeted-fix.yml b/.github/workflows/arsas-targeted-fix.yml index a9df4c2b6..f4251ea36 100644 --- a/.github/workflows/arsas-targeted-fix.yml +++ b/.github/workflows/arsas-targeted-fix.yml @@ -16,12 +16,16 @@ jobs: with: fetch-depth: 0 - name: Apply focused source patch - run: python .github/scripts/apply_arsas_targeted_fix.py + shell: bash + run: | + python .github/scripts/apply_arsas_targeted_fix.py || true + python .github/scripts/finish_arsas_targeted_fix.py - name: Validate and commit patch shell: bash run: | rm -f .github/workflows/arsas-targeted-fix.yml rm -f .github/scripts/apply_arsas_targeted_fix.py + rm -f .github/scripts/finish_arsas_targeted_fix.py git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A From 063f05c796013c41657b5970001d63daeac8cf5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:58:57 +0000 Subject: [PATCH 6/6] fix: separate control feedback and smooth analyzers --- .github/scripts/apply_arsas_targeted_fix.py | 418 ------------------ .github/scripts/finish_arsas_targeted_fix.py | 75 ---- .github/workflows/arsas-targeted-fix.yml | 34 -- 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 ++ 10 files changed, 262 insertions(+), 536 deletions(-) delete mode 100644 .github/scripts/apply_arsas_targeted_fix.py delete mode 100644 .github/scripts/finish_arsas_targeted_fix.py delete mode 100644 .github/workflows/arsas-targeted-fix.yml create mode 100644 Services/PresentationEasingMath.cs create mode 100644 tests/ARSAS.Tests/PresentationEasingMathTests.cs diff --git a/.github/scripts/apply_arsas_targeted_fix.py b/.github/scripts/apply_arsas_targeted_fix.py deleted file mode 100644 index 3641e3ef0..000000000 --- a/.github/scripts/apply_arsas_targeted_fix.py +++ /dev/null @@ -1,418 +0,0 @@ -from pathlib import Path - - -def replace_exact(path: str, old: str, new: str) -> None: - p = Path(path) - text = p.read_text(encoding="utf-8") - if old not in text: - raise SystemExit(f"Expected source block not found in {path}") - p.write_text(text.replace(old, new, 1), encoding="utf-8") - - -# 1) Strict IEC 61850 control/process separation: never inject command result into monitored stVal. -replace_exact( - "Services/Iec61850MonitorRuntime.cs", - ''' 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. - -''') - -# 2) Command window must not display the command result as if it were the actual process position. -replace_exact( - "ControlCommandWindow.xaml.cs", - ''' CommandStage = result.Stage; - CommandStatus = BuildCommandResultText(result); - if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") - CurrentValue = result.FeedbackValue; - SetResultTone(result.IsSuccess ? "Success" : "Error"); -''', - ''' CommandStage = result.IsSuccess && !TestMode ? "Command accepted" : result.Stage; - CommandStatus = BuildCommandResultText(result); - 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"); -''') -replace_exact( - "ControlCommandWindow.xaml.cs", - ''' if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-") - details.Add($"Process feedback: {result.FeedbackElapsedText}."); -''', - ''' if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-") - details.Add($"Control-side feedback verification: {result.FeedbackElapsedText}. This does not overwrite monitored stVal."); -''') - -# 3) Shared time-based, allocation-free easing math. -Path("Services/PresentationEasingMath.cs").write_text(r'''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)); - } -} -''', encoding="utf-8") - -# 4) Phasor smoothing happens only in the presentation layer, at incoming analysis-result cadence. -replace_exact( - "Controls/ComtradePhasorView.cs", - '''using System.Globalization; -using System.Windows; -using System.Windows.Media; -''', - '''using System.Diagnostics; -using System.Globalization; -using System.Windows; -using System.Windows.Media; -using ArIED61850Tester.Services; -''') -replace_exact( - "Controls/ComtradePhasorView.cs", - ''' private string _headerLabel = "Fundamental phasors at C1"; - private string _referenceDetail = "Select a valid analysis reference"; - private string _message = string.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; -''') -replace_exact( - "Controls/ComtradePhasorView.cs", - ''' var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel; - _headerLabel = $"Fundamental phasors at {resolvedReference}"; - _referenceDetail = referenceDetail ?? string.Empty; - _voltagePanel = PreparePanel(voltageVectors); - _currentPanel = PreparePanel(currentVectors); - _message = string.Empty; - InvalidateVisual(); -''', - ''' 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(); -''') -replace_exact( - "Controls/ComtradePhasorView.cs", - ''' _voltagePanel = PreparedPhasorPanel.Empty; - _currentPanel = PreparedPhasorPanel.Empty; - _message = message ?? string.Empty; - InvalidateVisual(); -''', - ''' _voltagePanel = PreparedPhasorPanel.Empty; - _currentPanel = PreparedPhasorPanel.Empty; - _smoothedVoltageVectors = Array.Empty(); - _smoothedCurrentVectors = Array.Empty(); - _lastPresentationTimestamp = 0; - _message = message ?? string.Empty; - InvalidateVisual(); -''') -replace_exact( - "Controls/ComtradePhasorView.cs", - ''' private static PreparedPhasorPanel PreparePanel(IReadOnlyList? source) -''', - ''' 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) -''') - -# 5) Harmonics workstation smoothing, also display-only and time-based. -replace_exact( - "Controls/ComtradeHarmonicsWorkstationView.cs", - '''using System.Globalization; -using System.Windows; -''', - '''using System.Diagnostics; -using System.Globalization; -using System.Windows; -''') -replace_exact( - "Controls/ComtradeHarmonicsWorkstationView.cs", - ''' private IReadOnlyList _spectra = Array.Empty(); - private PreparedSpectrumRow[] _preparedRows = Array.Empty(); -''', - ''' private const double PresentationTimeConstantMs = 92.0; - private IReadOnlyList _spectra = Array.Empty(); - private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); - private long _lastPresentationTimestamp; - private PreparedSpectrumRow[] _preparedRows = Array.Empty(); -''') -replace_exact( - "Controls/ComtradeHarmonicsWorkstationView.cs", - ''' _title = title ?? string.Empty; - _subtitle = subtitle ?? string.Empty; - _spectra = spectra ?? Array.Empty(); - _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_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); -''') -replace_exact( - "Controls/ComtradeHarmonicsWorkstationView.cs", - ''' _spectra = Array.Empty(); - _preparedRows = Array.Empty(); - _maximumDisplayedOrder = -1; -''', - ''' _spectra = Array.Empty(); - _smoothedSpectra = Array.Empty(); - _lastPresentationTimestamp = 0; - _preparedRows = Array.Empty(); - _maximumDisplayedOrder = -1; -''') -replace_exact( - "Controls/ComtradeHarmonicsWorkstationView.cs", - ''' private static PreparedSpectrumRow[] PrepareRows( -''', - ''' 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( -''') - -# 6) Regression tests. -g1 = Path("tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs") -g1_text = g1.read_text(encoding="utf-8") -anchor = ''' [Fact] - public void CdcDerivation_MapsRepresentativeControlModelClasses() -''' -if anchor not in g1_text: - raise SystemExit("G1 test insertion anchor not found") -test_block = r''' [Fact] - public void OperateSuccess_DoesNotInjectCommandFeedbackIntoMonitoredProcessState() - { - var runtimeSource = File.ReadAllText(Path.GetFullPath("../../../../Services/Iec61850MonitorRuntime.cs")); - var commandWindowSource = File.ReadAllText(Path.GetFullPath("../../../../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); - } - -''' -g1.write_text(g1_text.replace(anchor, test_block + anchor, 1), encoding="utf-8") - -Path("tests/ARSAS.Tests/PresentationEasingMathTests.cs").write_text(r'''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); - } -} -''', encoding="utf-8") diff --git a/.github/scripts/finish_arsas_targeted_fix.py b/.github/scripts/finish_arsas_targeted_fix.py deleted file mode 100644 index 28ebf9aad..000000000 --- a/.github/scripts/finish_arsas_targeted_fix.py +++ /dev/null @@ -1,75 +0,0 @@ -from pathlib import Path - -runtime = Path("Services/Iec61850MonitorRuntime.cs").read_text(encoding="utf-8") -command_ui = Path("ControlCommandWindow.xaml.cs").read_text(encoding="utf-8") -phasor = Path("Controls/ComtradePhasorView.cs").read_text(encoding="utf-8") -harmonics = Path("Controls/ComtradeHarmonicsWorkstationView.cs").read_text(encoding="utf-8") - -required = [ - ("runtime command/process separation", "ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue)" not in runtime), - ("runtime authority comment", "not process-image authority" in runtime), - ("command UI does not overwrite CurrentValue", "CurrentValue = result.FeedbackValue" not in command_ui), - ("command UI waits for IED process feedback", "Waiting for independent IED process feedback" in command_ui), - ("phasor smoothing", "SmoothVectors(" in phasor and "SmoothAngleDegrees" in phasor), - ("harmonic smoothing", "SmoothSpectra(" in harmonics and "PresentationTimeConstantMs = 92.0" in harmonics), - ("shared easing helper", Path("Services/PresentationEasingMath.cs").exists()), -] -for label, ok in required: - if not ok: - raise SystemExit(f"Targeted patch validation failed: {label}") - -g1 = Path("tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs") -g1_text = g1.read_text(encoding="utf-8") -if "OperateSuccess_DoesNotInjectCommandFeedbackIntoMonitoredProcessState" not in g1_text: - anchor = "public sealed class G1ControlCorrectnessRegressionTests\n{\n" - if anchor not in g1_text: - raise SystemExit("G1 class insertion anchor not found") - test_block = r''' [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); - } - -''' - g1_text = g1_text.replace(anchor, anchor + test_block, 1) - g1.write_text(g1_text, encoding="utf-8") - -Path("tests/ARSAS.Tests/PresentationEasingMathTests.cs").write_text(r'''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); - } -} -''', encoding="utf-8") diff --git a/.github/workflows/arsas-targeted-fix.yml b/.github/workflows/arsas-targeted-fix.yml deleted file mode 100644 index f4251ea36..000000000 --- a/.github/workflows/arsas-targeted-fix.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: ARSAS targeted control and analyzer fix - -on: - push: - branches: - - "fix/real-ied-feedback-smooth-analyzers" - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Apply focused source patch - shell: bash - run: | - python .github/scripts/apply_arsas_targeted_fix.py || true - python .github/scripts/finish_arsas_targeted_fix.py - - name: Validate and commit patch - shell: bash - run: | - rm -f .github/workflows/arsas-targeted-fix.yml - rm -f .github/scripts/apply_arsas_targeted_fix.py - rm -f .github/scripts/finish_arsas_targeted_fix.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix: separate control feedback and smooth analyzers" - git push origin "HEAD:${GITHUB_REF_NAME}" 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 0b939ec05..49c6eb846 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -536,8 +536,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 a8662812b..982fb8f65 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); + } +}