From 75e41fbd3bc2f407b780a3b249a77771f5b71257 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:00:38 +0700 Subject: [PATCH 01/20] feat: add operator timing semantics helpers --- .../MainWindow.ClosedLoopTimingSemantics.cs | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs diff --git a/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs b/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs new file mode 100644 index 00000000..67991ec9 --- /dev/null +++ b/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs @@ -0,0 +1,113 @@ +using Arvrel.Application.Laboratory; +using Arvrel.Protection; + +namespace Arvrel.App; + +public partial class MainWindow +{ + private long _firstAnyPickupSourceRunId = -1; + private string? _firstAnyPickupSource; + + private void ObserveFirstAnyPickupSource( + VirtualTestSetTimingSnapshot testSet, + ProtectionSnapshot protection) + { + if (testSet.TestRunId <= 0 || + _firstAnyPickupSourceRunId == testSet.TestRunId || + testSet.RelayPickupDetectedMicroseconds is not { } relayPickupUs || + testSet.ObservedMicroseconds is not { } observedUs) + { + return; + } + + // ClosedLoopTimer_Tick advances one relay quantum at a time. The first + // snapshot in which RelayPickupDetectedMicroseconds becomes available is + // therefore the same protection frame that requested the generic BO2 pickup. + // Do not infer this later from the trip snapshot because other elements can + // pick up during BO2 operate/bounce/BI-deglitch time. + if (observedUs != relayPickupUs) + return; + + _firstAnyPickupSourceRunId = testSet.TestRunId; + _firstAnyPickupSource = DescribeActivePickupSources(protection); + } + + private string FirstAnyPickupSourceFor(VirtualTestSetTimingSnapshot testSet) + => _firstAnyPickupSourceRunId == testSet.TestRunId && + !string.IsNullOrWhiteSpace(_firstAnyPickupSource) + ? _firstAnyPickupSource! + : "unresolved"; + + private static string DescribeActivePickupSources(ProtectionSnapshot snapshot) + { + var sources = new List(); + if (snapshot.Phase50.Pickup) sources.Add("50P-1"); + if (snapshot.Phase51.Pickup) sources.Add("51P"); + if (snapshot.Earth50.Pickup) sources.Add("50N"); + if (snapshot.Earth51.Pickup) sources.Add("51N"); + + var feeder = snapshot.Feeder; + if (feeder.DirectionalPhase67.Pickup) sources.Add("67P"); + if (feeder.DirectionalEarth67N.Pickup) sources.Add("67N"); + if (feeder.Undervoltage27.Pickup) sources.Add("27"); + if (feeder.Overvoltage59.Pickup) sources.Add("59"); + if (feeder.ResidualOvervoltage59N.Pickup) sources.Add("59N"); + + return sources.Count == 0 ? "unknown pickup" : string.Join(" + ", sources); + } + + private string BuildClosedLoopTimingRail( + VirtualTestSetTimingSnapshot testSet, + ProtectionSnapshot protection) + { + var parts = new List { "T0" }; + if (testSet.PickupTime is { } bi2) + parts.Add($"BI2 ANY [{FirstAnyPickupSourceFor(testSet)}] {bi2.TotalMilliseconds:0.000}"); + + var operation = RelayOperationTimingCorrelator.Correlate(testSet, protection); + if (operation is { } timing) + { + parts.Add($"{timing.Element} PU {timing.ElementPickupFromStart.TotalMilliseconds:0.000}"); + var trip = timing.LiveTripRequestFromStart ?? timing.OperationRecordTripFromStart; + parts.Add($"{timing.Element} TRIP {trip.TotalMilliseconds:0.000}"); + } + + if (testSet.TripTime is { } bi1) + parts.Add($"BI1 {bi1.TotalMilliseconds:0.000} ms"); + + return string.Join(" → ", parts); + } + + private string BuildClosedLoopTimingDetail( + VirtualTestSetTimingSnapshot testSet, + ProtectionSnapshot protection) + { + var items = new List(); + var operation = RelayOperationTimingCorrelator.Correlate(testSet, protection); + if (operation is { } timing) + items.Add($"{timing.Element} P→T {timing.ElementPickupToTrip.TotalMilliseconds:0.000} ms"); + if (testSet.RelayTripToBi1 is { } external) + items.Add($"relay TRIP→BI1 {external.TotalMilliseconds:0.000} ms"); + + if (TryGetTripCaptureFrameOffsetMicroseconds(_closedLoopBench?.TripCapture, out var offsetUs)) + items.Add($"capture frame +{offsetUs} µs after BI1"); + + items.Add(testSet.OutputRunning ? "OUTPUT ON" : "OUTPUT OFF"); + return string.Join(" · ", items); + } + + private static bool TryGetTripCaptureFrameOffsetMicroseconds( + ClosedLoopTripCapture? capture, + out long offsetMicroseconds) + { + offsetMicroseconds = 0; + if (capture?.TestSet.TripDetectedMicroseconds is not { } bi1Us || + capture.TestSet.ObservedMicroseconds is not { } captureFrameUs) + { + return false; + } + + offsetMicroseconds = Math.Max(0, captureFrameUs - bi1Us); + return true; + } +} From 97673d5446e6b4d8384c71e7681dba8ce0398b27 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:01:22 +0700 Subject: [PATCH 02/20] fix: separate BI timing from capture frame semantics --- .../MainWindow.ClosedLoopTestBench.cs | 98 ++++++++++++------- 1 file changed, 65 insertions(+), 33 deletions(-) diff --git a/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs b/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs index 39692a41..9c953ea3 100644 --- a/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs +++ b/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs @@ -76,10 +76,31 @@ private void ClosedLoopTimer_Tick(object? sender, EventArgs e) if (_internalRunning) { - var result = _closedLoopBench.Advance(TimeSpan.FromMilliseconds(40)); - _snapshot = result.Protection; - ObserveTransitions(_snapshot); - ReportClosedLoopTestSetTransitions(result.TestSet); + // Advance one 250 µs relay quantum at a time from the WPF presentation + // slice. The bench still owns all timing authority; this only lets the + // desktop observe the exact first frame where generic relay pickup is + // asserted instead of trying to infer its source 40 ms later. + var remaining = TimeSpan.FromMilliseconds(40); + ClosedLoopVirtualTestBenchStep? result = null; + while (remaining > TimeSpan.Zero && _scenario.IsRunning) + { + var quantum = remaining > ClosedLoopBench.SimulationQuantum + ? ClosedLoopBench.SimulationQuantum + : remaining; + result = _closedLoopBench.Advance(quantum); + remaining -= quantum; + + _snapshot = result.Protection; + ObserveFirstAnyPickupSource(result.TestSet, result.Protection); + ObserveTransitions(_snapshot); + ReportClosedLoopTestSetTransitions(result.TestSet); + + if (!result.TestSet.OutputRunning) + break; + } + + if (result is null) + return; _internalRunning = _scenario.IsRunning; var displayStep = _scenario.Project(result.Source, _pickupPosition, _tripPosition) with @@ -113,40 +134,25 @@ private void ReportClosedLoopTestSetTransitions(VirtualTestSetTimingSnapshot tes _lastReportedTestSetPickup = pickup; AddEvent( "TEST PICKUP", - $"BI2 ANY PICKUP ↑ · START→BI2 {testSet.PickupTime?.TotalMilliseconds:0.000} ms · resolution {testSet.TimingResolutionMicroseconds} µs"); + $"BI2 ANY PICKUP ↑ · first source {FirstAnyPickupSourceFor(testSet)} · START→BI2 {testSet.PickupTime?.TotalMilliseconds:0.000} ms · resolution {testSet.TimingResolutionMicroseconds} µs"); } if (testSet.TripDetectedAt is not { } trip || trip == _lastReportedTestSetTrip) return; _lastReportedTestSetTrip = trip; - var pickupText = testSet.PickupTime is { } pickupTime - ? $"BI2 ANY {pickupTime.TotalMilliseconds:0.000} ms" - : "BI2 ANY —"; - var tripText = testSet.TripTime is { } tripTime - ? $"BI1 {tripTime.TotalMilliseconds:0.000} ms" - : "BI1 —"; - - var operationTiming = RelayOperationTimingCorrelator.Correlate(testSet, _snapshot); - var relayText = operationTiming is { } timing - ? timing.LiveTripRequestFromStart is { } liveTrip - ? $"{timing.Element} pickup {timing.ElementPickupFromStart.TotalMilliseconds:0.000} ms · " + - $"P→T {timing.ElementPickupToTrip.TotalMilliseconds:0.000} ms · " + - $"relay START→TRIP {liveTrip.TotalMilliseconds:0.000} ms" - : $"{timing.Element} pickup {timing.ElementPickupFromStart.TotalMilliseconds:0.000} ms · " + - $"P→T {timing.ElementPickupToTrip.TotalMilliseconds:0.000} ms" - : testSet.RelayTripTime is { } relayTrip - ? $"relay START→TRIP {relayTrip.TotalMilliseconds:0.000} ms · operated-element pickup unavailable" - : "relay live timing unavailable"; - var outputPath = testSet.RelayTripToBi1 is { } external - ? $" · relay TRIP→BI1 {external.TotalMilliseconds:0.000} ms" - : string.Empty; + var rail = BuildClosedLoopTimingRail(testSet, _snapshot); + var detail = BuildClosedLoopTimingDetail(testSet, _snapshot); AddEvent( "TEST TRIP", - $"BI1 ↑ · START→BI1 {testSet.TripTime?.TotalMilliseconds:0.000} ms · {testSet.TimingResolutionMicroseconds} µs resolution · output stopped"); - StatusText.Text = - $"TESTSET measured trip · {tripText} · {pickupText} · {relayText}{outputPath} · output stopped · capture frozen at BI1 edge."; + $"BI1 ↑ · START→BI1 {testSet.TripTime?.TotalMilliseconds:0.000} ms · {testSet.TimingResolutionMicroseconds} µs resolution · {detail}"); + + StatusText.Text = rail; + StatusText.ToolTip = + $"{detail}\n" + + "TESTSET measurement authority: accepted wired BI1 edge.\n" + + "Frozen waveform/phasor: first relay processing frame after that accepted edge, not an interpolated frame at the exact BI sample instant."; } private void InstallClosedLoopEvidenceOverride() @@ -179,12 +185,30 @@ private void ExportClosedLoopEvidence_Click(object sender, RoutedEventArgs e) var current = _closedLoopBench.Advance(TimeSpan.Zero); var algorithmRuntime = AlgorithmRuntimeRegistry.Snapshot(); + var testSetSnapshot = _closedLoopBench.TestSetSnapshot; var operatingElementTiming = RelayOperationTimingCorrelator.Correlate( - _closedLoopBench.TestSetSnapshot, + testSetSnapshot, _snapshot); + var tripCapture = _closedLoopBench.TripCapture; + long? captureFrameOffsetUs = TryGetTripCaptureFrameOffsetMicroseconds(tripCapture, out var offsetUs) + ? offsetUs + : null; + object? tripCaptureTiming = tripCapture is null + ? null + : new + { + measurementAuthority = "TESTSET.BI1 accepted wired rising edge", + bi1AcceptedAt = tripCapture.TestSet.TripDetectedAt, + bi1AcceptedMicroseconds = tripCapture.TestSet.TripDetectedMicroseconds, + captureFrameAt = tripCapture.CapturedAt, + captureFrameMicroseconds = tripCapture.TestSet.ObservedMicroseconds, + captureFrameOffsetMicroseconds = captureFrameOffsetUs, + displayFreezeSemantics = "Frozen waveform/phasor are the first relay processing frame in which the accepted BI1 edge is observable; they are not claimed to be an interpolated state at the exact 10 kHz BI sampling instant." + }; + var evidence = new { - schemaVersion = 8, + schemaVersion = 9, exportedAt = DateTimeOffset.Now, application = "ARVREL", operatingMode = OperatingModeCombo.SelectedIndex == 1 ? "Research" : "Practitioner", @@ -218,9 +242,17 @@ private void ExportClosedLoopEvidence_Click(object sender, RoutedEventArgs e) legacyRelayFrontEnd = _closedLoopBench.FrontEndSnapshot, causalRelayFrontEnd = _closedLoopBench.CausalFrontEndSnapshot, relayContactProfile = _closedLoopBench.ContactProfile, - testSet = _closedLoopBench.TestSetSnapshot, + testSet = testSetSnapshot, + firstAnyPickup = new + { + source = FirstAnyPickupSourceFor(testSetSnapshot), + relayAssertFromStart = testSetSnapshot.RelayPickupTime, + testSetBi2FromStart = testSetSnapshot.PickupTime, + semantics = "Source is captured on the first 4 kHz relay frame that asserts generic ANY-PICKUP/BO2; BI2 is the later wired test-set acceptance." + }, operatingElementTiming, - tripCapture = _closedLoopBench.TripCapture, + tripCapture, + tripCaptureTiming, relayMeasurement = current.RelayMeasurement, protection = _snapshot, protectionSettings = _settings, From 238292b2b979c0deb13041fbec2ecf597e2b83e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:02:09 +0700 Subject: [PATCH 03/20] feat: make frozen capture and timing rail explicit --- ...ainWindow.TestSetTimingAndTripCaptureUx.cs | 64 +++++++++++++++++-- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/src/Arvrel.App/MainWindow.TestSetTimingAndTripCaptureUx.cs b/src/Arvrel.App/MainWindow.TestSetTimingAndTripCaptureUx.cs index f400a6f9..68c66348 100644 --- a/src/Arvrel.App/MainWindow.TestSetTimingAndTripCaptureUx.cs +++ b/src/Arvrel.App/MainWindow.TestSetTimingAndTripCaptureUx.cs @@ -22,6 +22,7 @@ public partial class MainWindow private TextBlock? _testSetBi2Edge; private TextBlock? _testSetBi1Edge; private TextBlock? _testSetTimerValue; + private TextBlock? _testSetTimingRail; private long _tripCaptureDisplayRunId = -1; private WaveformFrame? _tripCaptureWaveform; private PhasorDisplayFrame? _tripCapturePhasor; @@ -55,7 +56,7 @@ _virtualInjectionView is null || _testSetTimingUxInitialized = true; if (_virtualInjectionView.RowDefinitions.Count >= 3) - _virtualInjectionView.RowDefinitions[2].Height = new GridLength(52); + _virtualInjectionView.RowDefinitions[2].Height = new GridLength(58); // The existing footer uses column 0 for provenance/CT observability and // column 1 for action buttons. Insert a dedicated middle timing column and @@ -114,7 +115,7 @@ private Border BuildTestSetTimingPanel() BorderBrush = TestSetBrush("#D7E1E8"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(4), - Padding = new Thickness(8, 5, 8, 5), + Padding = new Thickness(8, 4, 8, 4), Margin = new Thickness(8, 0, 8, 0), VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Stretch, @@ -122,6 +123,8 @@ private Border BuildTestSetTimingPanel() }; var root = new Grid(); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(12) }); root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); @@ -139,13 +142,16 @@ private Border BuildTestSetTimingPanel() VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0) }; + Grid.SetRow(label, 0); root.Children.Add(label); var bi2 = BuildBinaryInputCluster("BI2", "PICKUP", out _testSetBi2Lamp, out _testSetBi2State, out _testSetBi2Edge); + Grid.SetRow(bi2, 0); Grid.SetColumn(bi2, 2); root.Children.Add(bi2); var bi1 = BuildBinaryInputCluster("BI1", "TRIP", out _testSetBi1Lamp, out _testSetBi1State, out _testSetBi1Edge); + Grid.SetRow(bi1, 0); Grid.SetColumn(bi1, 4); root.Children.Add(bi1); @@ -161,9 +167,25 @@ private Border BuildTestSetTimingPanel() Margin = new Thickness(14, 0, 0, 0), TextTrimming = TextTrimming.CharacterEllipsis }; + Grid.SetRow(_testSetTimerValue, 0); Grid.SetColumn(_testSetTimerValue, 5); root.Children.Add(_testSetTimerValue); + _testSetTimingRail = new TextBlock + { + Text = "T0 · waiting for timed injection", + FontFamily = new FontFamily("Cascadia Mono, Consolas"), + FontSize = 8.3, + Foreground = TestSetBrush("#607383"), + Margin = new Thickness(0, 3, 0, 0), + TextTrimming = TextTrimming.CharacterEllipsis, + ToolTip = "Chronological timing rail. BI2 is generic ANY PICKUP; operated-element pickup and trip are shown separately." + }; + Grid.SetRow(_testSetTimingRail, 1); + Grid.SetColumn(_testSetTimingRail, 0); + Grid.SetColumnSpan(_testSetTimingRail, 6); + root.Children.Add(_testSetTimingRail); + return border; } @@ -237,7 +259,8 @@ _testSetBi2State is null || _testSetBi1State is null || _testSetBi2Edge is null || _testSetBi1Edge is null || - _testSetTimerValue is null) + _testSetTimerValue is null || + _testSetTimingRail is null) return; var testSet = _closedLoopBench?.TestSetSnapshot; @@ -248,6 +271,9 @@ _testSetBi1Edge is null || _testSetBi2Edge.Text = "↑ —"; _testSetBi1Edge.Text = "↑ —"; _testSetTimerValue.Text = "TIMER IDLE"; + _testSetTimingRail.Text = "T0 · waiting for timed injection"; + _testSetTimingPanel.Background = TestSetBrush("#F7FAFC"); + _testSetTimingPanel.BorderBrush = TestSetBrush("#D7E1E8"); return; } @@ -265,7 +291,7 @@ _testSetBi1Edge is null || VirtualTestSetTimerState.Armed when testSet.ElapsedTime is { } elapsed => $"TIMER {elapsed.TotalMilliseconds:0.000} ms · ARMED", VirtualTestSetTimerState.Completed when testSet.TripTime is { } measured - => $"MEASURED TRIP {measured.TotalMilliseconds:0.000} ms", + => $"OUTPUT OFF · FROZEN CAPTURE · BI1 {measured.TotalMilliseconds:0.000} ms", VirtualTestSetTimerState.Blocked => "NOT ARMED · BI ACTIVE", _ => "TIMER IDLE" @@ -273,16 +299,42 @@ VirtualTestSetTimerState.Completed when testSet.TripTime is { } measured _testSetTimerValue.Foreground = testSet.TimerState switch { - VirtualTestSetTimerState.Completed => TestSetBrush("#A73333"), + VirtualTestSetTimerState.Completed => TestSetBrush("#8B3A3A"), VirtualTestSetTimerState.Blocked => TestSetBrush("#A56B16"), VirtualTestSetTimerState.Armed => TestSetBrush("#1E6A45"), _ => TestSetBrush("#607383") }; + + _testSetTimingPanel.Background = testSet.TimerState switch + { + VirtualTestSetTimerState.Completed => TestSetBrush("#EEF4F7"), + VirtualTestSetTimerState.Blocked => TestSetBrush("#FFF8E8"), + _ => TestSetBrush("#F7FAFC") + }; + _testSetTimingPanel.BorderBrush = testSet.TimerState switch + { + VirtualTestSetTimerState.Completed => TestSetBrush("#9DB2C0"), + VirtualTestSetTimerState.Blocked => TestSetBrush("#D5B56D"), + _ => TestSetBrush("#D7E1E8") + }; + + _testSetTimingRail.Text = BuildClosedLoopTimingRail(testSet, _snapshot); + _testSetTimingRail.Foreground = testSet.TimerState == VirtualTestSetTimerState.Completed + ? TestSetBrush("#344F60") + : TestSetBrush("#607383"); + + var detail = BuildClosedLoopTimingDetail(testSet, _snapshot); + var captureSemantics = TryGetTripCaptureFrameOffsetMicroseconds(_closedLoopBench?.TripCapture, out var captureOffsetUs) + ? $"\nBI1 accepted edge is timer authority. Frozen display frame is +{captureOffsetUs} µs after BI1 on the relay processing grid." + : string.Empty; _testSetTimingPanel.ToolTip = $"Timer state: {testSet.TimerState}\n" + $"Run: {testSet.TestRunId}\n" + + $"First ANY-pickup source: {FirstAnyPickupSourceFor(testSet)}\n" + $"BI2 accepted: {(testSet.PickupInput ? "ON" : "OFF")} · raw BO2: {(testSet.PickupContactRaw ? "ON" : "OFF")}\n" + - $"BI1 accepted: {(testSet.TripInput ? "ON" : "OFF")} · raw BO1: {(testSet.TripContactRaw ? "ON" : "OFF")}" + + $"BI1 accepted: {(testSet.TripInput ? "ON" : "OFF")} · raw BO1: {(testSet.TripContactRaw ? "ON" : "OFF")}\n" + + detail + + captureSemantics + (string.IsNullOrWhiteSpace(testSet.ArmBlockReason) ? string.Empty : $"\n{testSet.ArmBlockReason}"); From ececea63460654cd3e653ebd87ebd47b7d301f25 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:07:58 +0700 Subject: [PATCH 04/20] ux: clarify relay LED live and latched semantics --- .../MainWindow.RelayLedPresentation.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/Arvrel.App/MainWindow.RelayLedPresentation.cs b/src/Arvrel.App/MainWindow.RelayLedPresentation.cs index e8fdbcc2..13bf6f06 100644 --- a/src/Arvrel.App/MainWindow.RelayLedPresentation.cs +++ b/src/Arvrel.App/MainWindow.RelayLedPresentation.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using System.Windows; +using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Effects; using System.Windows.Shapes; @@ -47,6 +48,7 @@ internal void InitializeRelayLedPresentation() ConfigureRelayLed(PhaseCLed); ConfigureRelayLed(EarthLed); ConfigureRelayLed(BlockLed); + ClarifyRelayLedSemantics(); // Keep the top application-health indicator compact while giving it the // same circular lens, bezel and state-matched glow geometry. @@ -55,6 +57,47 @@ internal void InitializeRelayLedPresentation() Closed += (_, _) => ReleaseRelayLedPresentation(); } + private void ClarifyRelayLedSemantics() + { + if (PickupLed.Parent is Grid statusGrid) + { + foreach (var label in statusGrid.Children.OfType()) + { + switch (Grid.GetRow(label)) + { + case 1: + label.Text = "PICKUP LIVE"; + label.ToolTip = "Live pickup state of the relay frame currently shown. It is not a latched target."; + break; + case 2: + label.Text = "TRIP LATCH"; + label.ToolTip = "Latched relay trip indication. Clear it with relay reset."; + break; + case 3: + case 4: + case 5: + label.ToolTip = "Phase indication from the relay frame currently shown. After auto-stop the display may be a frozen trip-capture frame."; + break; + case 6: + label.Text = "EARTH"; + label.ToolTip = "Earth/residual indication from the relay frame currently shown. After auto-stop the display may be a frozen trip-capture frame."; + break; + case 7: + label.ToolTip = "Live measurement-block state of the relay frame currently shown."; + break; + } + } + } + + PickupLed.ToolTip = "Live pickup state of the rendered relay frame; not latched."; + TripLed.ToolTip = "Latched trip indication."; + PhaseALed.ToolTip = "Phase A indication of the rendered relay frame."; + PhaseBLed.ToolTip = "Phase B indication of the rendered relay frame."; + PhaseCLed.ToolTip = "Phase C indication of the rendered relay frame."; + EarthLed.ToolTip = "Earth/residual indication of the rendered relay frame."; + BlockLed.ToolTip = "Live SMV/measurement block indication."; + } + private void ConfigureRelayLed(Ellipse led, bool compact = false) { ArgumentNullException.ThrowIfNull(led); From 7c09182f251739a7a7f30ac97775dc25902781b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:50:34 +0700 Subject: [PATCH 05/20] fix: order closed-loop timeline by event timestamp --- .../MainWindow.ClosedLoopTimingSemantics.cs | 160 ++++++++++++++++-- 1 file changed, 150 insertions(+), 10 deletions(-) diff --git a/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs b/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs index 67991ec9..b6608fce 100644 --- a/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs +++ b/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs @@ -7,6 +7,10 @@ public partial class MainWindow { private long _firstAnyPickupSourceRunId = -1; private string? _firstAnyPickupSource; + private long _closedLoopTransitionRunId = -1; + private bool _closedLoopLastAnyPickup; + private bool _closedLoopLastTrip; + private bool _closedLoopLastBlocked; private void ObserveFirstAnyPickupSource( VirtualTestSetTimingSnapshot testSet, @@ -22,7 +26,7 @@ testSet.RelayPickupDetectedMicroseconds is not { } relayPickupUs || // ClosedLoopTimer_Tick advances one relay quantum at a time. The first // snapshot in which RelayPickupDetectedMicroseconds becomes available is - // therefore the same protection frame that requested the generic BO2 pickup. + // therefore the same protection frame that requested generic BO2 pickup. // Do not infer this later from the trip snapshot because other elements can // pick up during BO2 operate/bounce/BI-deglitch time. if (observedUs != relayPickupUs) @@ -32,13 +36,68 @@ testSet.RelayPickupDetectedMicroseconds is not { } relayPickupUs || _firstAnyPickupSource = DescribeActivePickupSources(protection); } + private void ObserveClosedLoopProtectionTransitions( + VirtualTestSetTimingSnapshot testSet, + ProtectionSnapshot protection) + { + if (testSet.TestRunId != _closedLoopTransitionRunId) + { + _closedLoopTransitionRunId = testSet.TestRunId; + _closedLoopLastAnyPickup = false; + _closedLoopLastTrip = false; + _closedLoopLastBlocked = false; + } + + var anyPickup = HasAnyProtectionPickup(protection); + if (anyPickup && !_closedLoopLastAnyPickup) + { + _pickupPosition = 0.58; + var source = DescribeActivePickupSources(protection); + var timing = testSet.RelayPickupTime is { } relayPickup + ? $"{relayPickup.TotalMilliseconds:0.000} ms · " + : string.Empty; + AddEvent("RELAY PU", $"{timing}{source} · ANY BO2 REQUEST"); + } + + if (protection.TripLatched && !_closedLoopLastTrip) + { + _tripPosition = 0.76; + var timing = testSet.RelayTripTime is { } relayTrip + ? $"{relayTrip.TotalMilliseconds:0.000} ms · " + : string.Empty; + AddEvent("RELAY TRIP", $"{timing}{protection.ActiveElement} · BO1 REQUEST"); + } + + if (protection.Blocked && !_closedLoopLastBlocked) + AddEvent("BLOCK", protection.SmvTrust.Code); + + _closedLoopLastAnyPickup = anyPickup; + _closedLoopLastTrip = protection.TripLatched; + _closedLoopLastBlocked = protection.Blocked; + + // Keep legacy presentation state aligned so switching source/workspace does + // not replay a stale transition after closed-loop ownership ends. + _lastPickup = anyPickup; + _lastTrip = protection.TripLatched; + _lastBlocked = protection.Blocked; + } + private string FirstAnyPickupSourceFor(VirtualTestSetTimingSnapshot testSet) => _firstAnyPickupSourceRunId == testSet.TestRunId && !string.IsNullOrWhiteSpace(_firstAnyPickupSource) ? _firstAnyPickupSource! : "unresolved"; + private static bool HasAnyProtectionPickup(ProtectionSnapshot snapshot) + => ActivePickupSources(snapshot).Count > 0; + private static string DescribeActivePickupSources(ProtectionSnapshot snapshot) + { + var sources = ActivePickupSources(snapshot); + return sources.Count == 0 ? "unknown pickup" : string.Join(" + ", sources); + } + + private static List ActivePickupSources(ProtectionSnapshot snapshot) { var sources = new List(); if (snapshot.Phase50.Pickup) sources.Add("50P-1"); @@ -52,30 +111,69 @@ private static string DescribeActivePickupSources(ProtectionSnapshot snapshot) if (feeder.Undervoltage27.Pickup) sources.Add("27"); if (feeder.Overvoltage59.Pickup) sources.Add("59"); if (feeder.ResidualOvervoltage59N.Pickup) sources.Add("59N"); - - return sources.Count == 0 ? "unknown pickup" : string.Join(" + ", sources); + return sources; } private string BuildClosedLoopTimingRail( VirtualTestSetTimingSnapshot testSet, ProtectionSnapshot protection) { - var parts = new List { "T0" }; - if (testSet.PickupTime is { } bi2) - parts.Add($"BI2 ANY [{FirstAnyPickupSourceFor(testSet)}] {bi2.TotalMilliseconds:0.000}"); + var events = new List<(TimeSpan Time, int Order, string Label)>(); + var firstSource = FirstAnyPickupSourceFor(testSet); + + if (testSet.RelayPickupTime is { } relayAnyPickup) + { + events.Add(( + relayAnyPickup, + 10, + $"RELAY ANY PU [{firstSource}] {relayAnyPickup.TotalMilliseconds:0.000} ms")); + } var operation = RelayOperationTimingCorrelator.Correlate(testSet, protection); if (operation is { } timing) { - parts.Add($"{timing.Element} PU {timing.ElementPickupFromStart.TotalMilliseconds:0.000}"); + var duplicateOfAnyPickup = + testSet.RelayPickupTime is { } relayAny && + string.Equals(firstSource, timing.Element, StringComparison.Ordinal) && + Math.Abs((timing.ElementPickupFromStart - relayAny).TotalMilliseconds) <= 0.250001; + + if (!duplicateOfAnyPickup) + { + events.Add(( + timing.ElementPickupFromStart, + 20, + $"{timing.Element} PU {timing.ElementPickupFromStart.TotalMilliseconds:0.000} ms")); + } + var trip = timing.LiveTripRequestFromStart ?? timing.OperationRecordTripFromStart; - parts.Add($"{timing.Element} TRIP {trip.TotalMilliseconds:0.000}"); + events.Add(( + trip, + 40, + $"{timing.Element} TRIP {trip.TotalMilliseconds:0.000} ms")); + } + + if (testSet.PickupTime is { } bi2) + { + events.Add(( + bi2, + 30, + $"TESTSET BI2 ACCEPT {bi2.TotalMilliseconds:0.000} ms")); } if (testSet.TripTime is { } bi1) - parts.Add($"BI1 {bi1.TotalMilliseconds:0.000} ms"); + { + events.Add(( + bi1, + 50, + $"TESTSET BI1 ACCEPT {bi1.TotalMilliseconds:0.000} ms")); + } - return string.Join(" → ", parts); + return "T0 → " + string.Join( + " → ", + events + .OrderBy(item => item.Time) + .ThenBy(item => item.Order) + .Select(item => item.Label)); } private string BuildClosedLoopTimingDetail( @@ -83,6 +181,14 @@ private string BuildClosedLoopTimingDetail( ProtectionSnapshot protection) { var items = new List(); + + if (testSet.RelayPickupTime is { } relayPickup && + testSet.PickupTime is { } bi2 && + bi2 >= relayPickup) + { + items.Add($"relay ANY→BI2 {(bi2 - relayPickup).TotalMilliseconds:0.000} ms"); + } + var operation = RelayOperationTimingCorrelator.Correlate(testSet, protection); if (operation is { } timing) items.Add($"{timing.Element} P→T {timing.ElementPickupToTrip.TotalMilliseconds:0.000} ms"); @@ -96,6 +202,40 @@ private string BuildClosedLoopTimingDetail( return string.Join(" · ", items); } + private void RefreshClosedLoopOperatorState( + VirtualTestSetTimingSnapshot testSet, + ProtectionSnapshot protection) + { + // Generic PICKUP on the physical relay faceplate must mean the same ANY + // protection pickup that drives BO2. This includes feeder functions such as + // 67/27/59, not only the four legacy OCR elements. + PickupLed.Fill = HasAnyProtectionPickup(protection) ? WarningBrush : LedOffBrush; + + if (testSet.TimerState != VirtualTestSetTimerState.Completed || testSet.OutputRunning) + return; + + MarkFrozenElementState(Phase50StateText); + MarkFrozenElementState(Phase51StateText); + MarkFrozenElementState(Earth50StateText); + MarkFrozenElementState(Earth51StateText); + + if (testSet.TripTime is { } trip) + { + const string marker = "FROZEN @ TESTSET BI1"; + if (!ProtectionReasonText.Text.Contains(marker, StringComparison.Ordinal)) + { + ProtectionReasonText.Text = + $"{ProtectionReasonText.Text} · {marker} {trip.TotalMilliseconds:0.000} ms · OUTPUT OFF"; + } + } + } + + private static void MarkFrozenElementState(TextBlock stateText) + { + if (stateText.Text is "TIMING" or "PICKUP") + stateText.Text += " · FROZEN"; + } + private static bool TryGetTripCaptureFrameOffsetMicroseconds( ClosedLoopTripCapture? capture, out long offsetMicroseconds) From f4d39d16ce9f9b06ad977cb06a56aaf1150cb9e4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:51:13 +0700 Subject: [PATCH 06/20] fix: separate relay and test-set timing events --- src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs b/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs index 9c953ea3..4643f270 100644 --- a/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs +++ b/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs @@ -39,6 +39,7 @@ internal void InitializeClosedLoopVirtualTestBench() _timer.Tick += ClosedLoopTimer_Tick; InstallClosedLoopEvidenceOverride(); + InitializeClosedLoopOperatorClarity(); AddEvent("BACKPLANE", "Closed-loop active · causal relay ADC/DFT · 1 µs metrology clock · 10 kHz TESTSET BI"); EngineModeText.Text = SmvProcessBusController.IsAvailable ? "P0 METROLOGY · ARIEC61850 READY" @@ -92,7 +93,7 @@ private void ClosedLoopTimer_Tick(object? sender, EventArgs e) _snapshot = result.Protection; ObserveFirstAnyPickupSource(result.TestSet, result.Protection); - ObserveTransitions(_snapshot); + ObserveClosedLoopProtectionTransitions(result.TestSet, _snapshot); ReportClosedLoopTestSetTransitions(result.TestSet); if (!result.TestSet.OutputRunning) @@ -108,6 +109,7 @@ private void ClosedLoopTimer_Tick(object? sender, EventArgs e) Measurement = result.RelayMeasurement }; RenderInternal(displayStep, _snapshot); + RefreshClosedLoopOperatorState(result.TestSet, _snapshot); if (!_internalRunning) { @@ -133,8 +135,8 @@ private void ReportClosedLoopTestSetTransitions(VirtualTestSetTimingSnapshot tes { _lastReportedTestSetPickup = pickup; AddEvent( - "TEST PICKUP", - $"BI2 ANY PICKUP ↑ · first source {FirstAnyPickupSourceFor(testSet)} · START→BI2 {testSet.PickupTime?.TotalMilliseconds:0.000} ms · resolution {testSet.TimingResolutionMicroseconds} µs"); + "TESTSET BI2", + $"{testSet.PickupTime?.TotalMilliseconds:0.000} ms · ACCEPT · from RELAY ANY [{FirstAnyPickupSourceFor(testSet)}]"); } if (testSet.TripDetectedAt is not { } trip || trip == _lastReportedTestSetTrip) @@ -145,8 +147,8 @@ private void ReportClosedLoopTestSetTransitions(VirtualTestSetTimingSnapshot tes var detail = BuildClosedLoopTimingDetail(testSet, _snapshot); AddEvent( - "TEST TRIP", - $"BI1 ↑ · START→BI1 {testSet.TripTime?.TotalMilliseconds:0.000} ms · {testSet.TimingResolutionMicroseconds} µs resolution · {detail}"); + "TESTSET BI1", + $"{testSet.TripTime?.TotalMilliseconds:0.000} ms · ACCEPT · OUTPUT OFF · {testSet.TimingResolutionMicroseconds} µs resolution"); StatusText.Text = rail; StatusText.ToolTip = From 3bd09c48e7e8719e09d8a5ff7b16f9d0b03db323 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:51:39 +0700 Subject: [PATCH 07/20] feat: make closed-loop timing strip operator-visible --- .../MainWindow.ClosedLoopOperatorClarity.cs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/Arvrel.App/MainWindow.ClosedLoopOperatorClarity.cs diff --git a/src/Arvrel.App/MainWindow.ClosedLoopOperatorClarity.cs b/src/Arvrel.App/MainWindow.ClosedLoopOperatorClarity.cs new file mode 100644 index 00000000..d1adaafd --- /dev/null +++ b/src/Arvrel.App/MainWindow.ClosedLoopOperatorClarity.cs @@ -0,0 +1,104 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; + +namespace Arvrel.App; + +public partial class MainWindow +{ + private const int ClosedLoopOperatorClarityMaximumAttempts = 24; + private bool _closedLoopTimingPanelReflowed; + private bool _closedLoopSetpointHeadersClarified; + private int _closedLoopOperatorClarityAttempts; + + internal void InitializeClosedLoopOperatorClarity() + { + if (_closedLoopTimingPanelReflowed && _closedLoopSetpointHeadersClarified) + return; + + if (!IsLoaded || _virtualInjectionView is null) + { + RetryClosedLoopOperatorClarity(); + return; + } + + if (!_closedLoopTimingPanelReflowed) + _closedLoopTimingPanelReflowed = TryReflowClosedLoopTimingPanel(); + if (!_closedLoopSetpointHeadersClarified) + _closedLoopSetpointHeadersClarified = TryClarifyInjectionSetpointHeaders(); + + if (!_closedLoopTimingPanelReflowed || !_closedLoopSetpointHeadersClarified) + RetryClosedLoopOperatorClarity(); + } + + private void RetryClosedLoopOperatorClarity() + { + if (_closedLoopOperatorClarityAttempts++ >= ClosedLoopOperatorClarityMaximumAttempts) + return; + + Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(InitializeClosedLoopOperatorClarity)); + } + + private bool TryReflowClosedLoopTimingPanel() + { + if (_virtualInjectionView is null || _testSetTimingPanel is null) + return false; + + var footer = _virtualInjectionView.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 2); + if (footer is null) + return false; + + // The timing result is primary test-equipment information, not a narrow + // accessory beside CT controls. Give it one full row and keep source/CT + // actions on the lower row. + if (_virtualInjectionView.RowDefinitions.Count >= 3) + _virtualInjectionView.RowDefinitions[2].Height = new GridLength(92); + + footer.RowDefinitions.Clear(); + footer.RowDefinitions.Add(new RowDefinition { Height = new GridLength(51) }); + footer.RowDefinitions.Add(new RowDefinition { Height = new GridLength(36) }); + + foreach (UIElement child in footer.Children.Cast().ToArray()) + { + Grid.SetRowSpan(child, 1); + if (ReferenceEquals(child, _testSetTimingPanel)) + continue; + + Grid.SetRow(child, 1); + if (child is FrameworkElement element) + element.VerticalAlignment = VerticalAlignment.Center; + } + + Grid.SetRow(_testSetTimingPanel, 0); + Grid.SetColumn(_testSetTimingPanel, 0); + Grid.SetColumnSpan(_testSetTimingPanel, Math.Max(1, footer.ColumnDefinitions.Count)); + _testSetTimingPanel.Margin = new Thickness(0, 0, 0, 5); + _testSetTimingPanel.HorizontalAlignment = HorizontalAlignment.Stretch; + _testSetTimingPanel.VerticalAlignment = VerticalAlignment.Stretch; + + return true; + } + + private bool TryClarifyInjectionSetpointHeaders() + { + if (_virtualInjectionView is null) + return false; + + var table = _virtualInjectionView.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 1); + if (table is null || table.Columns.Count < 4) + return false; + + table.Columns[2].Header = "RMS SET"; + table.Columns[3].Header = "ANGLE SET"; + table.ToolTip = + "Configured source setpoints. After auto-stop these values are retained for repeatability; " + + "the OUTPUT OFF / FROZEN CAPTURE banner is the authority for actual output state."; + return true; + } +} From d8b5865ec907f1548573874cc42530e458726feb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:52:11 +0700 Subject: [PATCH 08/20] fix: clarify native relay pickup and trip indicators --- src/Arvrel.App/MainWindow.P6VirtualRelay.cs | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/Arvrel.App/MainWindow.P6VirtualRelay.cs b/src/Arvrel.App/MainWindow.P6VirtualRelay.cs index 6fd5570d..efc279ac 100644 --- a/src/Arvrel.App/MainWindow.P6VirtualRelay.cs +++ b/src/Arvrel.App/MainWindow.P6VirtualRelay.cs @@ -94,8 +94,10 @@ private void InstallP6VirtualRelay() _p6VirtualRelayInstalled = true; RebindRelayStatePresentersToP6(); + ClarifyP6RelayIndicatorSemantics(relay); InitializeRelayOperatorExperience(relay); InitializeRelayProtectionEvidence(); + InitializeClosedLoopOperatorClarity(); if (!EngineModeText.Text.Contains("P6", StringComparison.Ordinal)) EngineModeText.Text = $"{EngineModeText.Text} · P6"; @@ -104,6 +106,29 @@ private void InstallP6VirtualRelay() StatusText.Text = "P6 native virtual relay faceplate active."; } + private void ClarifyP6RelayIndicatorSemantics(VirtualRelayControl relay) + { + // The native P6 control replaces the legacy relay visual tree after Loaded, + // so legacy row-label rewrites cannot reach these labels. Apply the wording + // directly to the mounted hardware control. + relay.ApplyTextOverrides(new Dictionary(StringComparer.Ordinal) + { + ["PICKUP"] = "PICKUP LIVE", + ["TRIP"] = "TRIP LATCH" + }); + + relay.PickupLamp.ToolTip = "Live ANY protection pickup state that drives generic relay BO2; not a latched target."; + relay.TripLamp.ToolTip = "Latched relay trip indication. Reset the relay to clear it."; + relay.PhaseALamp.ToolTip = "Phase A indication of the rendered relay frame; after auto-stop this may be the frozen capture frame."; + relay.PhaseBLamp.ToolTip = "Phase B indication of the rendered relay frame; after auto-stop this may be the frozen capture frame."; + relay.PhaseCLamp.ToolTip = "Phase C indication of the rendered relay frame; after auto-stop this may be the frozen capture frame."; + relay.EarthLamp.ToolTip = "Earth/residual indication of the rendered relay frame; after auto-stop this may be the frozen capture frame."; + relay.BlockLamp.ToolTip = "Live measurement/trip-block indication of the rendered relay frame."; + + PickupLed.ToolTip = relay.PickupLamp.ToolTip; + TripLed.ToolTip = relay.TripLamp.ToolTip; + } + private static void CalmP6WorkspaceChrome(Border relayHost) { var workspaceFrame = P6VisualAncestors(relayHost).FirstOrDefault(); From 3cb25868053205435a144dc87a21e8f1e276f021 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:52:58 +0700 Subject: [PATCH 09/20] fix: import WPF controls for frozen timing labels --- src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs b/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs index b6608fce..2605e7e4 100644 --- a/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs +++ b/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs @@ -1,3 +1,4 @@ +using System.Windows.Controls; using Arvrel.Application.Laboratory; using Arvrel.Protection; From 932029e0f75a2d3f4e181ee1044bc98a902c5a88 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:56:37 +0700 Subject: [PATCH 10/20] perf: keep ANY pickup check allocation-free --- src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs b/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs index 2605e7e4..ccdf97fb 100644 --- a/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs +++ b/src/Arvrel.App/MainWindow.ClosedLoopTimingSemantics.cs @@ -90,7 +90,15 @@ private string FirstAnyPickupSourceFor(VirtualTestSetTimingSnapshot testSet) : "unresolved"; private static bool HasAnyProtectionPickup(ProtectionSnapshot snapshot) - => ActivePickupSources(snapshot).Count > 0; + => snapshot.Phase50.Pickup || + snapshot.Phase51.Pickup || + snapshot.Earth50.Pickup || + snapshot.Earth51.Pickup || + snapshot.Feeder.DirectionalPhase67.Pickup || + snapshot.Feeder.DirectionalEarth67N.Pickup || + snapshot.Feeder.Undervoltage27.Pickup || + snapshot.Feeder.Overvoltage59.Pickup || + snapshot.Feeder.ResidualOvervoltage59N.Pickup; private static string DescribeActivePickupSources(ProtectionSnapshot snapshot) { From 23c12197d33c53b6d01643df19f671e643a4de55 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 19:08:29 +0700 Subject: [PATCH 11/20] fix: scope timing tooltip to active closed-loop run --- src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs b/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs index 4643f270..923e27e5 100644 --- a/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs +++ b/src/Arvrel.App/MainWindow.ClosedLoopTestBench.cs @@ -65,6 +65,8 @@ internal void StopClosedLoopVirtualTestBench() _closedLoopEvidenceButton.Click += ExportVirtualInjectionEvidence_Click; _closedLoopEvidenceButton = null; } + + StatusText.ToolTip = null; _closedLoopBench = null; } @@ -75,6 +77,15 @@ private void ClosedLoopTimer_Tick(object? sender, EventArgs e) if (_closedLoopBench is null) return; + // Trip details belong to one completed test run only. As soon as a new + // run is armed/idle, discard the previous run's explanatory tooltip so + // unrelated status text can never inherit stale BI1/capture evidence. + if (_closedLoopBench.TestSetSnapshot.TimerState != VirtualTestSetTimerState.Completed && + StatusText.ToolTip is not null) + { + StatusText.ToolTip = null; + } + if (_internalRunning) { // Advance one 250 µs relay quantum at a time from the WPF presentation @@ -120,6 +131,9 @@ private void ClosedLoopTimer_Tick(object? sender, EventArgs e) return; } + if (StatusText.ToolTip is not null) + StatusText.ToolTip = null; + _streamRefreshDivider++; if (_streamRefreshDivider >= 6) { @@ -268,6 +282,7 @@ private void ExportClosedLoopEvidence_Click(object sender, RoutedEventArgs e) dialog.FileName, JsonSerializer.Serialize(evidence, new JsonSerializerOptions { WriteIndented = true })); AddEvent("EXPORT", System.IO.Path.GetFileName(dialog.FileName)); + StatusText.ToolTip = null; StatusText.Text = $"Closed-loop metrology evidence exported to {dialog.FileName}."; } } From b21b485bb82f9f5fadad29fe5b88c3752e29aac3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 19:09:08 +0700 Subject: [PATCH 12/20] fix: clear prior timing tooltip on relay reset --- src/Arvrel.App/MainWindow.P6VirtualRelay.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Arvrel.App/MainWindow.P6VirtualRelay.cs b/src/Arvrel.App/MainWindow.P6VirtualRelay.cs index efc279ac..3c250480 100644 --- a/src/Arvrel.App/MainWindow.P6VirtualRelay.cs +++ b/src/Arvrel.App/MainWindow.P6VirtualRelay.cs @@ -170,6 +170,7 @@ private void RebindRelayStatePresentersToP6() private void P6Relay_ResetRequested(object sender, RoutedEventArgs e) { + StatusText.ToolTip = null; Reset_Click(sender, e); NotifyRelayOperatorReset(); NotifyRelayEvidenceReset(); From 9e9bf9ccc68f4ed8cc5ae2cfee26302af88a84a4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:43:17 +0700 Subject: [PATCH 13/20] fix: make closed-loop relay reset a single settle transaction --- .../ClosedLoopRelayResetTransaction.cs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/Arvrel.Application/Laboratory/ClosedLoopRelayResetTransaction.cs diff --git a/src/Arvrel.Application/Laboratory/ClosedLoopRelayResetTransaction.cs b/src/Arvrel.Application/Laboratory/ClosedLoopRelayResetTransaction.cs new file mode 100644 index 00000000..f05c9d09 --- /dev/null +++ b/src/Arvrel.Application/Laboratory/ClosedLoopRelayResetTransaction.cs @@ -0,0 +1,140 @@ +using Arvrel.Protection; + +namespace Arvrel.Application.Laboratory; + +/// +/// Executes one relay RESET command as a deterministic equipment transaction. +/// +/// The virtual source and TESTSET are separate equipment. When the source is already +/// OFF, a human operator would normally press RESET only after relay acquisition has +/// continued to observe the de-energized secondary circuit. The desktop simulation +/// can otherwise be frozen at the exact BI1 auto-stop edge, leaving a causal DFT +/// window full of pre-stop fault samples. This transaction advances that source-off +/// acquisition state first, clears the relay latch/timers once, then advances the +/// modeled BO/contact/wire/BI release path until the feedback is physically idle. +/// +/// Trip capture and completed TESTSET timing evidence are intentionally preserved. +/// +public static class ClosedLoopRelayResetTransaction +{ + public static readonly TimeSpan DefaultSettleTimeout = TimeSpan.FromMilliseconds(100); + + public static ClosedLoopRelayResetResult Execute( + ClosedLoopVirtualTestBench bench, + ResearchProtectionEngine relay, + TimeSpan? settleTimeout = null) + { + ArgumentNullException.ThrowIfNull(bench); + ArgumentNullException.ThrowIfNull(relay); + + var timeout = settleTimeout ?? DefaultSettleTimeout; + if (timeout <= TimeSpan.Zero || timeout > TimeSpan.FromSeconds(2)) + throw new ArgumentOutOfRangeException(nameof(settleTimeout)); + + var elapsed = TimeSpan.Zero; + var step = bench.Advance(TimeSpan.Zero); + var sourceWasRunning = step.TestSet.OutputRunning; + + if (!sourceWasRunning) + { + // Let the relay acquisition/filter path observe the already de-energized + // secondary circuit before clearing the latch. This prevents a reset at a + // frozen trip frame from immediately seeing stale fault-window pickup. + while (HasAnyPickup(step.Protection) && elapsed < timeout) + step = AdvanceOneQuantum(bench, ref elapsed); + } + + relay.Reset(); + + // A running source is deliberately not stopped by a relay RESET command. + // Advance once so the reset is observable; if the applied quantities still + // demand pickup/trip, the result correctly remains not ready to re-arm. + if (sourceWasRunning) + { + if (elapsed < timeout) + step = AdvanceOneQuantum(bench, ref elapsed); + var runningReady = IsReadyToRearm(step); + return new ClosedLoopRelayResetResult( + ResetApplied: true, + ReadyToRearm: runningReady, + SourceWasRunning: true, + SimulatedSettleTime: elapsed, + FinalStep: step, + Detail: runningReady + ? "Relay reset applied while the virtual source remained energized; feedback is currently idle." + : "Relay reset applied, but the virtual source remains energized and protection/feedback may still be asserted."); + } + + while (!IsReadyToRearm(step) && elapsed < timeout) + step = AdvanceOneQuantum(bench, ref elapsed); + + var ready = IsReadyToRearm(step); + return new ClosedLoopRelayResetResult( + ResetApplied: true, + ReadyToRearm: ready, + SourceWasRunning: false, + SimulatedSettleTime: elapsed, + FinalStep: step, + Detail: ready + ? "Relay latch/timers cleared and BO1/BO2 plus TESTSET BI1/BI2 released through the modeled feedback path." + : BuildTimeoutDetail(step, timeout)); + } + + private static ClosedLoopVirtualTestBenchStep AdvanceOneQuantum( + ClosedLoopVirtualTestBench bench, + ref TimeSpan elapsed) + { + var step = bench.Advance(ClosedLoopVirtualTestBench.SimulationQuantum); + elapsed += ClosedLoopVirtualTestBench.SimulationQuantum; + return step; + } + + private static bool IsReadyToRearm(ClosedLoopVirtualTestBenchStep step) + { + var testSet = step.TestSet; + return !testSet.OutputRunning && + !step.Protection.TripLatched && + !HasAnyPickup(step.Protection) && + !testSet.PickupContactRaw && + !testSet.TripContactRaw && + !testSet.PickupInput && + !testSet.TripInput; + } + + private static bool HasAnyPickup(ProtectionSnapshot snapshot) + { + var feeder = snapshot.Feeder; + return snapshot.Phase50.Pickup || + snapshot.Phase51.Pickup || + snapshot.Earth50.Pickup || + snapshot.Earth51.Pickup || + feeder.DirectionalPhase67.Pickup || + feeder.DirectionalEarth67N.Pickup || + feeder.Undervoltage27.Pickup || + feeder.Overvoltage59.Pickup || + feeder.ResidualOvervoltage59N.Pickup; + } + + private static string BuildTimeoutDetail( + ClosedLoopVirtualTestBenchStep step, + TimeSpan timeout) + { + var testSet = step.TestSet; + return + $"Relay reset did not reach the re-arm postcondition within {timeout.TotalMilliseconds:0.###} ms simulated time. " + + $"tripLatch={(step.Protection.TripLatched ? 1 : 0)}, " + + $"pickup={(HasAnyPickup(step.Protection) ? 1 : 0)}, " + + $"BO2={(testSet.PickupContactRaw ? 1 : 0)}, " + + $"BO1={(testSet.TripContactRaw ? 1 : 0)}, " + + $"BI2={(testSet.PickupInput ? 1 : 0)}, " + + $"BI1={(testSet.TripInput ? 1 : 0)}."; + } +} + +public sealed record ClosedLoopRelayResetResult( + bool ResetApplied, + bool ReadyToRearm, + bool SourceWasRunning, + TimeSpan SimulatedSettleTime, + ClosedLoopVirtualTestBenchStep FinalStep, + string Detail); From d8c7e75cbd2a72395b1bcdb78e0dcdc02d24b549 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:44:05 +0700 Subject: [PATCH 14/20] fix: make relay reset wait for one-click re-arm postcondition --- .../MainWindow.RelayResetSeparation.cs | 132 +++++++++++++----- 1 file changed, 99 insertions(+), 33 deletions(-) diff --git a/src/Arvrel.App/MainWindow.RelayResetSeparation.cs b/src/Arvrel.App/MainWindow.RelayResetSeparation.cs index 09466e56..a5064731 100644 --- a/src/Arvrel.App/MainWindow.RelayResetSeparation.cs +++ b/src/Arvrel.App/MainWindow.RelayResetSeparation.cs @@ -2,18 +2,20 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Media; +using Arvrel.Application.Laboratory; using Arvrel.Protection; namespace Arvrel.App; public partial class MainWindow { + private bool _relayResetInProgress; + [ModuleInitializer] internal static void InitializeRelayResetSeparation() { - // Reset is a relay-equipment command. Registering at Button class level - // lets this authority run before legacy XAML/lambda click handlers that - // historically coupled relay reset to DeterministicLabScenario.Reset(). + // RESET is a relay-equipment command. Intercept legacy button routes so every + // visible reset entry point reaches the same authoritative transaction. EventManager.RegisterClassHandler( typeof(Button), Button.ClickEvent, @@ -35,10 +37,10 @@ private static void OnRelayResetButtonClick(object sender, RoutedEventArgs e) if (mainWindow is null) return; - // A handled routed event prevents the older Reset_Click handler or the - // editor lambda from running after the equipment-separated reset. + // A handled routed event prevents older Reset_Click/editor handlers from + // becoming a second reset authority for the same physical command. e.Handled = true; - mainWindow.ResetRelayEquipmentOnly(); + mainWindow.ExecuteRelayResetCommand(); } private static bool IsRelayResetCommand(Button button) @@ -82,6 +84,25 @@ private static string ExtractElementLabel(UIElement element) }; } + private void ExecuteRelayResetCommand() + { + if (_relayResetInProgress) + return; + + _relayResetInProgress = true; + try + { + StatusText.ToolTip = null; + ResetRelayEquipmentOnly(); + NotifyRelayOperatorReset(); + NotifyRelayEvidenceReset(); + } + finally + { + _relayResetInProgress = false; + } + } + private void ResetRelayEquipmentOnly() { ResetTransitionMarkers(); @@ -93,48 +114,93 @@ private void ResetRelayEquipmentOnly() var configuredFingerprint = _scenario.InjectionFingerprint; var outputFingerprint = _scenario.OutputFingerprint; var heldTripCapture = _closedLoopBench?.TripCapture; + ClosedLoopRelayResetResult? resetResult = null; + + if (_closedLoopBench is not null) + { + // One operator click owns the complete reset transaction. If auto-stop + // froze the simulation at the BI1 edge, first let the causal relay + // acquisition observe the already-OFF source, then clear the relay once + // and continue until BO1/BO2 and TESTSET BI1/BI2 are physically idle. + resetResult = ClosedLoopRelayResetTransaction.Execute( + _closedLoopBench, + _internalEngine); + _snapshot = resetResult.FinalStep.Protection; + } + else + { + _internalEngine.Reset(); + _snapshot = ProtectionSnapshot.Ready(DateTimeOffset.UtcNow); + } - _internalEngine.Reset(); - _snapshot = ProtectionSnapshot.Ready(DateTimeOffset.UtcNow); ClearRelayAnnunciation(); - // With output OFF, let the relay BO release, bounce and TESTSET BI - // debounce path settle naturally. This makes the BI lamps fall through - // the same modeled path used to assert them; no software-forced BI reset. - if (!wasRunning && _closedLoopBench is not null) + if (resetResult is not null) + { + var displayStep = _scenario.Project( + resetResult.FinalStep.Source, + _pickupPosition, + _tripPosition) with + { + Measurement = resetResult.FinalStep.RelayMeasurement + }; + RenderInternal(displayStep, _snapshot); + } + else { - var settled = _closedLoopBench.Advance(_closedLoopBench.FeedbackSettleTime); - _snapshot = settled.Protection; + // Legacy fallback only when the closed-loop bench has not initialized. + // Do not reset/restart the virtual source; relay and test set are + // separate equipment. + var currentSource = _scenario.Advance( + TimeSpan.Zero, + _pickupPosition, + _tripPosition); + RenderInternal(currentSource, _snapshot); } - // Re-render the relay against the source that is already present. - // Do not call scenario.Reset(), Restart(), StopInjection(), or apply - // a nominal preset: the virtual test set is separate equipment. - var currentSource = _scenario.Advance( - TimeSpan.Zero, - _pickupPosition, - _tripPosition); - RenderInternal(currentSource, _snapshot); RefreshPhasorFrame(); RefreshVirtualInjectionRunStopPresentation(); - AddEvent( - "RELAY RESET", - $"Trip latch and timers cleared; injection {(wasRunning ? "RUNNING" : "STOPPED")} · {configuredProfile.Name}"); - StatusText.Text = wasRunning - ? $"Relay reset complete. Virtual injection '{configuredProfile.Name}' remains RUNNING." - : heldTripCapture is not null - ? $"Relay reset complete. TESTSET feedback released; trip capture from run {heldTripCapture.TestRunId} remains frozen for inspection." + if (resetResult is { ReadyToRearm: true }) + { + AddEvent( + "RELAY RESET", + $"ONE CLICK · READY TO RE-ARM · settle {resetResult.SimulatedSettleTime.TotalMilliseconds:0.000} ms · {configuredProfile.Name}"); + StatusText.Text = heldTripCapture is not null + ? $"Relay reset complete · READY TO RE-ARM · BO/BI feedback released in {resetResult.SimulatedSettleTime.TotalMilliseconds:0.000} ms simulated time · trip capture run {heldTripCapture.TestRunId} retained." + : $"Relay reset complete · READY TO RE-ARM · BO/BI feedback released in {resetResult.SimulatedSettleTime.TotalMilliseconds:0.000} ms simulated time."; + } + else if (resetResult is { SourceWasRunning: true }) + { + AddEvent("RELAY RESET", "Reset applied while virtual source remains energized; protection may reassert"); + StatusText.Text = + "Relay reset applied once. Virtual injection remains RUNNING, so pickup/trip may reassert until the applied fault quantity is removed or output is stopped."; + } + else if (resetResult is not null) + { + AddEvent("RELAY RESET TIMEOUT", resetResult.Detail); + StatusText.Text = $"Relay reset incomplete: {resetResult.Detail}"; + } + else + { + AddEvent( + "RELAY RESET", + $"Trip latch and timers cleared; injection {(wasRunning ? "RUNNING" : "STOPPED")} · {configuredProfile.Name}"); + StatusText.Text = wasRunning + ? $"Relay reset complete. Virtual injection '{configuredProfile.Name}' remains RUNNING." : $"Relay reset complete. Virtual injection '{configuredProfile.Name}' remains STOPPED."; + } - // These values are intentionally observed after reset. They are - // included in the event tooltip/evidence path and make accidental - // source mutation visible during manual diagnostics. RelayFooterText.ToolTip = $"Relay reset did not change the virtual source.\n" + $"Configured fingerprint {configuredFingerprint}\n" + $"Effective output fingerprint {outputFingerprint}\n" + - $"Output state {_scenario.OutputState}"; + $"Output state {_scenario.OutputState}" + + (resetResult is null + ? string.Empty + : $"\nReset postcondition ready={(resetResult.ReadyToRearm ? 1 : 0)}" + + $"\nSimulated settle {resetResult.SimulatedSettleTime.TotalMilliseconds:0.000} ms" + + $"\n{resetResult.Detail}"); return; } From 775a416b37abed6fc36592b6d3fdaa4d88a76431 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:44:50 +0700 Subject: [PATCH 15/20] fix: route P6 RESET through authoritative reset transaction --- src/Arvrel.App/MainWindow.P6VirtualRelay.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Arvrel.App/MainWindow.P6VirtualRelay.cs b/src/Arvrel.App/MainWindow.P6VirtualRelay.cs index 3c250480..5f9a6073 100644 --- a/src/Arvrel.App/MainWindow.P6VirtualRelay.cs +++ b/src/Arvrel.App/MainWindow.P6VirtualRelay.cs @@ -170,10 +170,11 @@ private void RebindRelayStatePresentersToP6() private void P6Relay_ResetRequested(object sender, RoutedEventArgs e) { - StatusText.ToolTip = null; - Reset_Click(sender, e); - NotifyRelayOperatorReset(); - NotifyRelayEvidenceReset(); + // P6 RESET is the same relay-equipment command as every other reset entry + // point. Never fall back to legacy Reset_Click, which also reset the source + // scenario and left closed-loop BO/BI state on a separate lifecycle. + e.Handled = true; + ExecuteRelayResetCommand(); } private void P6Relay_HardwareKeyPressed(object? sender, VirtualRelayHardwareKeyEventArgs e) From 5d4be17f89a8b2331ad91493cff0219b09d3fa24 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:45:32 +0700 Subject: [PATCH 16/20] test: require one-click reset to fully release desktop feedback --- .../TestSetTimingCorrectnessTests.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/Arvrel.Application.Tests/TestSetTimingCorrectnessTests.cs b/tests/Arvrel.Application.Tests/TestSetTimingCorrectnessTests.cs index 01181756..222326d9 100644 --- a/tests/Arvrel.Application.Tests/TestSetTimingCorrectnessTests.cs +++ b/tests/Arvrel.Application.Tests/TestSetTimingCorrectnessTests.cs @@ -55,6 +55,52 @@ public void RelayResetAllowsFeedbackToReleaseBeforeNextTimedRunArms() Assert.IsTrue(bench.TestSetSnapshot.TestRunId > first.TestSet.TestRunId); } + [TestMethod] + public void OneResetTransactionFullyReleasesFrozenDesktopFeedbackAndRearms() + { + var source = CreateFaultSource(); + var relay = new ResearchProtectionEngine(FastPhase50Settings()); + var bench = new ClosedLoopVirtualTestBench( + source, + relay, + contactProfile: VirtualRelayContactProfile.RealisticNumericalRelay, + frontEndProfile: VirtualRelayFrontEndProfile.NumericalRelayDefault, + metrologyProfile: MetrologyTimingProfile.CmcStyle); + + Assert.IsTrue(bench.StartInjection()); + var first = bench.Advance(TimeSpan.FromMilliseconds(150)); + + Assert.AreEqual(VirtualTestSetTimerState.Completed, first.TestSet.TimerState); + Assert.IsTrue(first.Protection.TripLatched); + Assert.IsTrue(first.TestSet.TripInput); + Assert.IsFalse(source.IsRunning, "Accepted BI1 must auto-stop the virtual source before reset."); + var completedRunId = first.TestSet.TestRunId; + var capture = bench.TripCapture; + Assert.IsNotNull(capture); + + var reset = ClosedLoopRelayResetTransaction.Execute(bench, relay); + + Assert.IsTrue(reset.ResetApplied); + Assert.IsTrue(reset.ReadyToRearm, reset.Detail); + Assert.IsFalse(reset.SourceWasRunning); + Assert.IsFalse(reset.FinalStep.Protection.TripLatched); + Assert.IsFalse(reset.FinalStep.TestSet.PickupContactRaw, "BO2 must release during the same reset transaction."); + Assert.IsFalse(reset.FinalStep.TestSet.TripContactRaw, "BO1 must release during the same reset transaction."); + Assert.IsFalse(reset.FinalStep.TestSet.PickupInput, "TESTSET.BI2 must be low before RESET reports ready-to-rearm."); + Assert.IsFalse(reset.FinalStep.TestSet.TripInput, "TESTSET.BI1 must be low before RESET reports ready-to-rearm."); + Assert.IsFalse(source.IsRunning, "Relay reset must not restart or mutate the virtual source."); + Assert.AreSame(capture, bench.TripCapture, "Completed trip evidence must survive relay reset for inspection."); + Assert.IsTrue( + reset.SimulatedSettleTime > bench.FeedbackSettleTime, + "The desktop causal acquisition window should require more than the old fixed feedback delay, reproducing the former multi-click reset condition."); + + Assert.IsTrue(bench.TryStartInjection(out var reason), reason); + Assert.IsNull(reason); + Assert.AreEqual(VirtualTestSetTimerState.Armed, bench.TestSetSnapshot.TimerState); + Assert.IsTrue(source.IsRunning); + Assert.IsTrue(bench.TestSetSnapshot.TestRunId > completedRunId); + } + [TestMethod] public void AutoStopReturnsAtAcceptedBi1EdgeAndPreservesExactFaultCapture() { From 5559a78b92b14752e1b11f4f89dd40b5db487f73 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:48:46 +0700 Subject: [PATCH 17/20] test: lock P6 reset to unified relay transaction --- .../Arvrel.Protection.Tests/VirtualRelayHmiSourceTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Arvrel.Protection.Tests/VirtualRelayHmiSourceTests.cs b/tests/Arvrel.Protection.Tests/VirtualRelayHmiSourceTests.cs index b6e04d6b..a1385c62 100644 --- a/tests/Arvrel.Protection.Tests/VirtualRelayHmiSourceTests.cs +++ b/tests/Arvrel.Protection.Tests/VirtualRelayHmiSourceTests.cs @@ -37,12 +37,15 @@ public void P1RelayHmi_PreservesResetAsSeparateRelayAuthority() { var hardware = Read("src", "Arvrel.App", "Controls", "VirtualRelay", "VirtualRelayControl.xaml.cs"); var p6 = Read("src", "Arvrel.App", "MainWindow.P6VirtualRelay.cs"); + var resetAuthority = Read("src", "Arvrel.App", "MainWindow.RelayResetSeparation.cs"); StringAssert.Contains(hardware, "public event RoutedEventHandler? ResetRequested"); StringAssert.Contains(hardware, "case \"RESET\": key = default; return false;"); StringAssert.Contains(p6, "relay.ResetRequested += P6Relay_ResetRequested"); - StringAssert.Contains(p6, "Reset_Click(sender, e);"); - StringAssert.Contains(p6, "NotifyRelayOperatorReset();"); + StringAssert.Contains(p6, "ExecuteRelayResetCommand();"); + Assert.IsFalse(p6.Contains("Reset_Click(sender, e);", StringComparison.Ordinal)); + StringAssert.Contains(resetAuthority, "ClosedLoopRelayResetTransaction.Execute"); + StringAssert.Contains(resetAuthority, "NotifyRelayOperatorReset();"); } private static string Read(params string[] segments) From 7bd1582b22434947decfbab59f24bbed62f2ddce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:49:16 +0700 Subject: [PATCH 18/20] test: keep alarm ACK separate from unified relay reset --- .../VirtualRelayOperatorExperienceSourceTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Arvrel.Protection.Tests/VirtualRelayOperatorExperienceSourceTests.cs b/tests/Arvrel.Protection.Tests/VirtualRelayOperatorExperienceSourceTests.cs index 7883250b..efbc2da0 100644 --- a/tests/Arvrel.Protection.Tests/VirtualRelayOperatorExperienceSourceTests.cs +++ b/tests/Arvrel.Protection.Tests/VirtualRelayOperatorExperienceSourceTests.cs @@ -30,14 +30,16 @@ public void P12AlarmWorkflow_KeepsAckSeparateFromProtectionReset() var model = Read("src", "Arvrel.App", "Controls", "VirtualRelay", "VirtualRelayOperatorModel.cs"); var experience = Read("src", "Arvrel.App", "MainWindow.RelayOperatorExperience.cs"); var p6 = Read("src", "Arvrel.App", "MainWindow.P6VirtualRelay.cs"); + var resetAuthority = Read("src", "Arvrel.App", "MainWindow.RelayResetSeparation.cs"); StringAssert.Contains(model, "public int AcknowledgeAll()"); StringAssert.Contains(model, "public int ClearInactiveAcknowledged()"); StringAssert.Contains(experience, "RESET and ACK are intentionally different operations"); StringAssert.Contains(experience, "alarm history retained until ACK/CLEAR"); StringAssert.Contains(experience, "RESET DOES NOT ACK"); - StringAssert.Contains(p6, "Reset_Click(sender, e);"); - StringAssert.Contains(p6, "NotifyRelayOperatorReset();"); + StringAssert.Contains(p6, "ExecuteRelayResetCommand();"); + Assert.IsFalse(p6.Contains("Reset_Click(sender, e);", StringComparison.Ordinal)); + StringAssert.Contains(resetAuthority, "NotifyRelayOperatorReset();"); } [TestMethod] From 8ee5b4b8afa652541ffdd3fcedd3191c7ec0d234 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:50:14 +0700 Subject: [PATCH 19/20] test: require P6 RESET to use unified equipment authority --- tests/Arvrel.Protection.Tests/VirtualRelayP6SourceTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Arvrel.Protection.Tests/VirtualRelayP6SourceTests.cs b/tests/Arvrel.Protection.Tests/VirtualRelayP6SourceTests.cs index ebed2a6f..7d0f91ae 100644 --- a/tests/Arvrel.Protection.Tests/VirtualRelayP6SourceTests.cs +++ b/tests/Arvrel.Protection.Tests/VirtualRelayP6SourceTests.cs @@ -216,6 +216,7 @@ public void P6RuntimeMount_UsesExplicitApplicationAndWindowLifecycle() public void P6Adapter_RebindsExistingStateAuthoritiesWithoutProtectionChanges() { var adapter = Read("src", "Arvrel.App", "MainWindow.P6VirtualRelay.cs"); + var resetAuthority = Read("src", "Arvrel.App", "MainWindow.RelayResetSeparation.cs"); var annunciation = Read("src", "Arvrel.App", "MainWindow.RelayAnnunciation.cs"); StringAssert.Contains(adapter, "relayHost.Child = relay"); @@ -224,7 +225,9 @@ public void P6Adapter_RebindsExistingStateAuthoritiesWithoutProtectionChanges() StringAssert.Contains(adapter, "BlockLed = relay.BlockLamp.Lens"); StringAssert.Contains(adapter, "InitializeRelayFaceplate()"); StringAssert.Contains(adapter, "InitializeRelayMeasurementHome()"); - StringAssert.Contains(adapter, "Reset_Click(sender, e)"); + StringAssert.Contains(adapter, "ExecuteRelayResetCommand()"); + Assert.IsFalse(adapter.Contains("Reset_Click(sender, e)", StringComparison.Ordinal)); + StringAssert.Contains(resetAuthority, "ClosedLoopRelayResetTransaction.Execute"); Assert.IsFalse(adapter.Contains("ProtectionEngine", StringComparison.Ordinal)); Assert.IsFalse(adapter.Contains("UpdateSettings", StringComparison.Ordinal)); From 3f37c97f8ef031ea013811def675653d54c7b09d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:50:42 +0700 Subject: [PATCH 20/20] test: preserve evidence history through unified reset authority --- .../VirtualRelayProtectionEvidenceSourceTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Arvrel.Protection.Tests/VirtualRelayProtectionEvidenceSourceTests.cs b/tests/Arvrel.Protection.Tests/VirtualRelayProtectionEvidenceSourceTests.cs index 7b8a3a88..019e8d8c 100644 --- a/tests/Arvrel.Protection.Tests/VirtualRelayProtectionEvidenceSourceTests.cs +++ b/tests/Arvrel.Protection.Tests/VirtualRelayProtectionEvidenceSourceTests.cs @@ -59,11 +59,14 @@ public void P13EvidenceHistory_PersistsAcrossProtectionResetWithoutNewAuthority( { var evidence = Read("src", "Arvrel.App", "MainWindow.RelayProtectionEvidence.cs"); var p6 = Read("src", "Arvrel.App", "MainWindow.P6VirtualRelay.cs"); + var resetAuthority = Read("src", "Arvrel.App", "MainWindow.RelayResetSeparation.cs"); StringAssert.Contains(p6, "InitializeRelayProtectionEvidence();"); StringAssert.Contains(p6, "HandleRelayEvidenceHardwareKey(e.Key)"); StringAssert.Contains(p6, "RouteRelayEvidenceHostPage();"); - StringAssert.Contains(p6, "NotifyRelayEvidenceReset();"); + StringAssert.Contains(p6, "ExecuteRelayResetCommand();"); + StringAssert.Contains(resetAuthority, "NotifyRelayEvidenceReset();"); + StringAssert.Contains(resetAuthority, "heldTripCapture"); StringAssert.Contains(evidence, "Protection RESET clears the latch, not the stored fault/event history."); StringAssert.Contains(evidence, "_relayTripEvidenceLog");