diff --git a/App.xaml.cs b/App.xaml.cs
index 0b632689..f36f1946 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -46,6 +46,7 @@ protected override void OnStartup(StartupEventArgs e)
// isolated dynamic-DataSet qualification; it is never invoked by normal startup,
// Connect/Play, monitoring, reconnect or report-planner paths.
DynamicReportQualificationUiBehavior.Install();
+ DynamicReportCommandBoundWitnessUiBehavior.Install();
DispatcherUnhandledException += OnDispatcherUnhandledException;
TaskScheduler.UnobservedTaskException += (_, args) => args.SetObserved();
diff --git a/ControlCommandWindow.A21Witness.cs b/ControlCommandWindow.A21Witness.cs
new file mode 100644
index 00000000..4d2bb31b
--- /dev/null
+++ b/ControlCommandWindow.A21Witness.cs
@@ -0,0 +1,13 @@
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester;
+
+///
+/// Read-only A2.1 adapter. This partial exposes context only; it adds no command
+/// behavior and leaves the existing IEC 61850 control transaction completely untouched.
+///
+public partial class ControlCommandWindow
+{
+ internal SignalDefinition A21WitnessSignal => _signal;
+ internal Iec61850MonitorDevice A21WitnessDevice => _device;
+}
diff --git a/DynamicReportCommandBoundWitnessUiBehavior.cs b/DynamicReportCommandBoundWitnessUiBehavior.cs
new file mode 100644
index 00000000..2f56b4a3
--- /dev/null
+++ b/DynamicReportCommandBoundWitnessUiBehavior.cs
@@ -0,0 +1,130 @@
+using System.Threading;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester;
+
+internal static class DynamicReportCommandBoundWitnessUiBehavior
+{
+ private static int _installed;
+ private static int _busy;
+
+ public static void Install()
+ {
+ if (Interlocked.Exchange(ref _installed, 1) != 0)
+ return;
+
+ EventManager.RegisterClassHandler(
+ typeof(MainWindow),
+ Keyboard.PreviewKeyDownEvent,
+ new KeyEventHandler(OnPreviewKeyDown),
+ handledEventsToo: true);
+
+ // Observer-only bridge for the dedicated ControlCommandWindow path. WPF class
+ // handlers execute before the window's existing SendCommand_Click instance
+ // handler. We only publish immutable intent context; the control handler and
+ // its ExecuteControlAsync/SBOw/Operate sequence remain untouched.
+ EventManager.RegisterClassHandler(
+ typeof(Button),
+ Button.ClickEvent,
+ new RoutedEventHandler(OnAnyButtonClick),
+ handledEventsToo: true);
+ }
+
+ private static void OnAnyButtonClick(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button button || Window.GetWindow(button) is not ControlCommandWindow commandWindow)
+ return;
+
+ var label = button.Content?.ToString()?.Trim() ?? string.Empty;
+ if (!label.Equals("Send Command", StringComparison.OrdinalIgnoreCase) &&
+ !label.Equals("Send Test", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ if (!commandWindow.CanSend)
+ return;
+
+ DynamicReportCommandIntentObservation.Publish(new DynamicReportObservedCommandIntent(
+ commandWindow.A21WitnessDevice,
+ commandWindow.A21WitnessSignal,
+ commandWindow.SelectedValue,
+ "ControlCommandWindow.RoutedButtonClick",
+ DateTimeOffset.UtcNow));
+ }
+
+ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ if (sender is not MainWindow window ||
+ Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift) ||
+ e.Key != Key.F)
+ return;
+
+ e.Handled = true;
+ var device = window.SelectedDevice;
+ if (device is null)
+ {
+ MessageBox.Show(
+ window,
+ "Select one IEC 61850 IED first. G2.5-A2.1 is intentionally bound to one explicit IED and one explicit ARSAS command.",
+ "G2.5-A2.1 Command-Bound Witness",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ return;
+ }
+
+ if (Interlocked.Exchange(ref _busy, 1) != 0)
+ {
+ MessageBox.Show(
+ window,
+ "G2.5-A2.1 is already armed/running.",
+ "G2.5-A2.1 Command-Bound Witness",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ return;
+ }
+
+ try
+ {
+ var answer = MessageBox.Show(
+ window,
+ $"Arm G2.5-A2.1 command-bound high-speed stimulus witness for {device.Name} ({device.EndpointText})?\n\n" +
+ "READ-ONLY WITNESS + ONE EXISTING ARSAS CONTROL COMMAND\n\n" +
+ "A2.1 V2 opens one isolated read-only MMS association and captures a PRE-COMMAND baseline. It can observe BOTH the fast Command Panel and the dedicated Control Command dialog without changing either control transaction.\n\n" +
+ "After the status shows 'G2.5-A2.1 READY — ISSUE ONE ARSAS COMMAND', issue exactly ONE already-proven safe OPEN or CLOSE using the normal ARSAS Command Panel or dedicated Control Command dialog you normally use. Do NOT use an external/manual stimulus for this phase.\n\n" +
+ "The observer then narrows read-only sampling to at most six points around the exact ControlStatusReference. Existing ExecuteControlAsync, SBOw, Operate and CommandTermination behavior is NOT modified, delayed, wrapped or re-issued.\n\n" +
+ "Once 'G2.5-A2.1 COMMAND CAPTURED' appears, do not issue another command. If a transition is seen, A2.1 samples briefly to classify persistent/latched versus momentary/pulse behavior.\n\n" +
+ "The witness does not access/mutate RCB or DataSet state, does not send GI, does not save/advance the qualification profile, and production dynamic reporting remains OFF. Do not run another G2 hotkey while A2.1 is armed.\n\n" +
+ "Continue?",
+ "G2.5-A2.1 Command-Bound High-Speed Witness",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning,
+ MessageBoxResult.No);
+ if (answer != MessageBoxResult.Yes)
+ return;
+
+ window.LastStatusText = $"G2.5-A2.1 V2: opening isolated read-only MMS witness for {device.Name} and preparing pre-command baseline…";
+ var progress = new Progress(text => window.LastStatusText = text);
+ var service = new DynamicReportCommandBoundStimulusWitnessServiceV2();
+ var result = await service.RunAsync(device, device.Signals.ToArray(), progress, CancellationToken.None);
+ window.LastStatusText = result.Summary;
+ var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window };
+ evidenceWindow.ShowDialog();
+ }
+ catch (Exception ex)
+ {
+ window.LastStatusText = "G2.5-A2.1 V2 stopped locally; production dynamic reporting remains OFF.";
+ MessageBox.Show(
+ window,
+ "G2.5-A2.1 V2 stopped. The witness did not change production reporting policy.\n\n" + ex,
+ "G2.5-A2.1 Command-Bound Witness",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _busy, 0);
+ }
+ }
+}
diff --git a/DynamicReportQualificationResultWindow.G25A21.cs b/DynamicReportQualificationResultWindow.G25A21.cs
new file mode 100644
index 00000000..63a401b5
--- /dev/null
+++ b/DynamicReportQualificationResultWindow.G25A21.cs
@@ -0,0 +1,114 @@
+using System.Globalization;
+using System.Text;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester;
+
+internal partial class DynamicReportQualificationResultWindow
+{
+ internal DynamicReportQualificationResultWindow(DynamicReportCommandBoundStimulusWitnessResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ InitializeComponent();
+
+ Title = "G2.5-A2.1 Command-Bound Stimulus Witness Evidence";
+ HeaderText.Text = "G2.5-A2.1 Command-Bound High-Speed Witness";
+ SummaryText.Text = result.Summary;
+ StateText.Text = result.IsSuccess
+ ? "Command-Bound Transition Proven"
+ : result.IsBlocked
+ ? "Blocked"
+ : "Command-Bound Transition Not Proven";
+ EvidenceTextBox.Text = BuildG25A21Evidence(result);
+
+ if (result.IsSuccess)
+ SetPassBadge();
+ }
+
+ private static string BuildG25A21Evidence(DynamicReportCommandBoundStimulusWitnessResult result)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("ARSAS G2.5-A2.1 COMMAND-BOUND HIGH-SPEED STIMULUS WITNESS EVIDENCE");
+ builder.AppendLine(new string('=', 92));
+ builder.AppendLine($"Result: {result.Summary}");
+ builder.AppendLine($"Blocked: {result.IsBlocked}");
+ builder.AppendLine($"G2.5-A2.1 success: {result.IsSuccess}");
+ builder.AppendLine($"Stimulus witness proven: {result.StimulusWitnessProven}");
+
+ if (result.Identity is not null)
+ {
+ builder.AppendLine();
+ builder.AppendLine("IED IDENTITY");
+ builder.AppendLine($"Stable identity: {result.Identity.StableIdentityKey}");
+ builder.AppendLine($"Model fingerprint: {result.Identity.ModelFingerprint}");
+ builder.AppendLine($"Model: {result.Identity.Model}");
+ builder.AppendLine($"Firmware: {result.Identity.FirmwareRevision}");
+ builder.AppendLine($"Profile revision: {result.Identity.ProfileRevision}");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("COMMAND BINDING");
+ builder.AppendLine($"Input profile state: {result.InputProfile?.State.ToString() ?? "-"}");
+ builder.AppendLine($"Pre-command baseline captured: {result.BaselineCaptured}");
+ builder.AppendLine($"Command captured: {result.CommandCaptured}");
+ builder.AppendLine($"Command signal: {G25A21TextOrDash(result.CommandSignalReference)}");
+ builder.AppendLine($"ControlStatusReference: {G25A21TextOrDash(result.ControlStatusReference)}");
+ builder.AppendLine($"Control model: {G25A21TextOrDash(result.ControlModelText)}");
+ builder.AppendLine($"Pre-command baseline points: {result.PreCommandBaselineCount}");
+ builder.AppendLine($"Focused candidates: {result.FocusCandidateCount}");
+ builder.AppendLine($"Sample cycles: {result.SampleCycles}");
+ builder.AppendLine($"Read failures: {result.ReadFailures}");
+ builder.AppendLine($"Association healthy: {result.AssociationHealthy}");
+
+ builder.AppendLine();
+ builder.AppendLine("ELIGIBLE COMMAND-BOUND CANDIDATES");
+ if (result.EligibleCandidates.Count == 0)
+ {
+ builder.AppendLine(" none");
+ }
+ else
+ {
+ foreach (var candidate in result.EligibleCandidates)
+ {
+ builder.AppendLine($" #{candidate.Rank} {candidate.Reference}");
+ builder.AppendLine($" MMS: {candidate.MmsReference}");
+ builder.AppendLine($" Exact ControlStatusReference: {candidate.ExactControlStatus}");
+ builder.AppendLine($" Kind: {candidate.Kind}");
+ builder.AppendLine($" Baseline -> final: {candidate.BaselineValue} -> {candidate.FinalValue}");
+ builder.AppendLine($" Transitions: {candidate.TransitionCount}");
+ builder.AppendLine($" Observed active/pulse duration ms: {G25A21FormatDuration(candidate.ObservedActiveMilliseconds)}");
+ foreach (var transition in candidate.Transitions)
+ builder.AppendLine($" {transition.ObservedAtUtc:O} | {transition.BeforeValue} -> {transition.AfterValue}");
+ }
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("ALL FOCUSED OBSERVATIONS");
+ foreach (var candidate in result.Observations)
+ {
+ builder.AppendLine($" exact={candidate.ExactControlStatus,-5} | transitions={candidate.TransitionCount,2} | {candidate.Kind,-20} | {candidate.Reference} | {candidate.BaselineValue} -> {candidate.FinalValue}");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("WIRE / DIAGNOSTIC EVIDENCE");
+ foreach (var line in result.EvidenceLines)
+ builder.AppendLine(line);
+
+ builder.AppendLine();
+ builder.AppendLine("SAFETY STATE");
+ builder.AppendLine("G2.5-A2.1 witness is read-only and does not alter, delay, wrap or re-issue the existing ARSAS control transaction.");
+ builder.AppendLine("The one OPEN/CLOSE command is the operator-requested existing ARSAS control action; the witness itself performs no control write.");
+ builder.AppendLine("G2.5-A2.1 does not access/mutate RCB/DataSet state, send GI, save the InformationReportProven profile, or enable production dynamic reporting.");
+ builder.AppendLine("G2.5-A2.1 PASS identifies a command-bound physical MMS candidate only; it does NOT prove spontaneous dchg reporting.");
+ builder.AppendLine("Production automatic dynamic reporting remains OFF.");
+ return builder.ToString();
+ }
+
+ private static string G25A21FormatDuration(double? milliseconds)
+ => milliseconds.HasValue
+ ? milliseconds.Value.ToString("0.0", CultureInfo.InvariantCulture)
+ : "-";
+
+ private static string G25A21TextOrDash(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+}
diff --git a/Services/DynamicReportCommandBoundStimulusWitnessService.cs b/Services/DynamicReportCommandBoundStimulusWitnessService.cs
new file mode 100644
index 00000000..e8b09222
--- /dev/null
+++ b/Services/DynamicReportCommandBoundStimulusWitnessService.cs
@@ -0,0 +1,698 @@
+using System.ComponentModel;
+using ArIED61850Tester.Models;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ArIED61850Tester.Services;
+
+internal sealed class DynamicReportCommandBoundTransition
+{
+ public string Reference { get; init; } = string.Empty;
+ public string MmsReference { get; init; } = string.Empty;
+ public string BeforeValue { get; init; } = string.Empty;
+ public string AfterValue { get; init; } = string.Empty;
+ public DateTimeOffset ObservedAtUtc { get; init; }
+}
+
+internal sealed class DynamicReportCommandBoundObservation
+{
+ public int Rank { get; init; }
+ public bool ExactControlStatus { get; init; }
+ public string Reference { get; init; } = string.Empty;
+ public string MmsReference { get; init; } = string.Empty;
+ public string LogicalNode { get; init; } = string.Empty;
+ public string FunctionalConstraint { get; init; } = string.Empty;
+ public string BaselineValue { get; init; } = string.Empty;
+ public string FinalValue { get; init; } = string.Empty;
+ public int TransitionCount { get; init; }
+ public DynamicReportStimulusEligibilityKind Kind { get; init; }
+ public double? ObservedActiveMilliseconds { get; init; }
+ public IReadOnlyList Transitions { get; init; } = Array.Empty();
+}
+
+internal sealed class DynamicReportCommandBoundStimulusWitnessResult
+{
+ public bool IsSuccess { get; init; }
+ public bool IsBlocked { get; init; }
+ public bool BaselineCaptured { get; init; }
+ public bool CommandCaptured { get; init; }
+ public bool AssociationHealthy { get; init; }
+ public bool StimulusWitnessProven { get; init; }
+ public string CommandSignalReference { get; init; } = string.Empty;
+ public string ControlStatusReference { get; init; } = string.Empty;
+ public string ControlModelText { get; init; } = string.Empty;
+ public int PreCommandBaselineCount { get; init; }
+ public int FocusCandidateCount { get; init; }
+ public int SampleCycles { get; init; }
+ public int ReadFailures { get; init; }
+ public string Summary { get; init; } = string.Empty;
+ public ArMms.MmsDynamicReportIedIdentity? Identity { get; init; }
+ public ArMms.MmsDynamicReportQualificationProfile? InputProfile { get; init; }
+ public IReadOnlyList Observations { get; init; } = Array.Empty();
+ public IReadOnlyList EligibleCandidates { get; init; } = Array.Empty();
+ public IReadOnlyList EvidenceLines { get; init; } = Array.Empty();
+}
+
+///
+/// G2.5-A2.1 command-bound high-speed stimulus witness.
+///
+/// The witness is read-only. It does not alter the existing ARSAS control transaction.
+/// Before the operator issues a command, an isolated MMS association captures a bounded
+/// baseline for live control-feedback/status points. The service then listens only to
+/// SignalDefinition.PropertyChanged and identifies the exact signal whose existing
+/// ControlCommandBusy state becomes true. Once captured, sampling narrows to the exact
+/// ControlStatusReference plus a tiny related status chain. No RCB/DataSet/report API is
+/// used and the qualification profile is never saved or advanced.
+///
+internal sealed class DynamicReportCommandBoundStimulusWitnessService
+{
+ internal const string ReadyMarker = "G2.5-A2.1 READY — ISSUE ONE ARSAS COMMAND";
+ internal const string CommandCapturedMarker = "G2.5-A2.1 COMMAND CAPTURED";
+ internal const string TransitionMarker = "G2.5-A2.1 TRANSITION OBSERVED";
+ internal const int MaximumPreCommandBaselinePoints = 128;
+ internal const int MaximumFocusCandidates = 6;
+
+ internal static readonly TimeSpan AssociationTimeout = TimeSpan.FromSeconds(10);
+ internal static readonly TimeSpan CommandWaitWindow = TimeSpan.FromSeconds(45);
+ internal static readonly TimeSpan FocusObservationWindow = TimeSpan.FromSeconds(5);
+ internal static readonly TimeSpan PostTransitionSettleWindow = TimeSpan.FromSeconds(2);
+ internal static readonly TimeSpan InterCycleDelay = TimeSpan.FromMilliseconds(1);
+
+ private readonly DynamicReportQualificationProfileStore _profileStore;
+
+ public DynamicReportCommandBoundStimulusWitnessService(
+ DynamicReportQualificationProfileStore? profileStore = null)
+ {
+ _profileStore = profileStore ?? new DynamicReportQualificationProfileStore();
+ }
+
+ public async Task RunAsync(
+ Iec61850MonitorDevice device,
+ IReadOnlyList fullModelSignals,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentNullException.ThrowIfNull(fullModelSignals);
+
+ var evidence = new List
+ {
+ "G2.5-A2.1 contract: command-bound HIGH-SPEED witness is READ ONLY. Existing ARSAS control logic is not modified, delayed, wrapped, replaced or re-issued by this service.",
+ "G2.5-A2.1 witness performs no RCB attribute access, no RptEna/Resv/DatSet/TrgOps/OptFlds mutation, no GI, no Define/DeleteNamedVariableList, no report monitor and no profile save.",
+ $"G2.5-A2.1 bounds: preCommandBaseline<={MaximumPreCommandBaselinePoints}; focusedCandidates<={MaximumFocusCandidates}; waitForCommand={CommandWaitWindow.TotalSeconds:0}s; focusWindow={FocusObservationWindow.TotalSeconds:0}s; settleAfterFirstTransition={PostTransitionSettleWindow.TotalSeconds:0}s."
+ };
+
+ ArMms.MmsDynamicReportIedIdentity identity;
+ try
+ {
+ identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ return Blocked("G2.5-A2.1 identity preflight failed: " + ex.Message, evidence);
+ }
+
+ var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false);
+ evidence.Add($"G2.5-A2.1 persisted profile: exists={loaded.Exists}; valid={loaded.IsValid}; state={loaded.Profile?.State.ToString() ?? "-"}; reason={loaded.Reason}");
+ if (!loaded.IsValid || loaded.Profile is null ||
+ loaded.Profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven)
+ {
+ return Blocked(
+ "G2.5-A2.1 requires the identity-compatible InformationReportProven G2.4 profile.",
+ evidence,
+ identity,
+ loaded.Profile);
+ }
+
+ var profile = loaded.Profile;
+ var commandSignals = fullModelSignals
+ .Where(signal => signal.IsControlSignal && !string.IsNullOrWhiteSpace(signal.ControlStatusReference))
+ .Distinct()
+ .ToArray();
+
+ if (commandSignals.Length == 0)
+ {
+ return Failed(
+ "No live control signal exposes a ControlStatusReference. Inspect the control model first; A2.1 will not guess a command object.",
+ evidence,
+ identity,
+ profile,
+ associationHealthy: true);
+ }
+
+ if (commandSignals.Any(signal => signal.ControlCommandBusy))
+ {
+ return Blocked(
+ "A control command is already in progress. A2.1 must be armed before the one test command begins.",
+ evidence,
+ identity,
+ profile);
+ }
+
+ await using var session = new ArMms.MmsClientSession();
+ try
+ {
+ await session.ConnectAsync(device.IpAddress, device.Port, AssociationTimeout, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException)
+ {
+ evidence.Add($"G2.5-A2.1 association failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed("The isolated read-only A2.1 MMS association could not be established.", evidence, identity, profile, false);
+ }
+
+ evidence.Add($"G2.5-A2.1 association ready: state={session.State}; localTcpAddress={TextOrDash(session.LocalTcpAddress)}; READ-ONLY=true");
+
+ ArMms.MmsDiscoveryResult discovery;
+ try
+ {
+ discovery = await session.DiscoverAsync(
+ probeReportAttributes: false,
+ maxReportAttributeProbes: 0,
+ cancellationToken: cancellationToken,
+ readDataSetDirectories: false,
+ maxDataSetDirectoryReads: 0).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.5-A2.1 discovery failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed("A2.1 live discovery failed before command arming.", evidence, identity, profile, session.IsMmsInitiated);
+ }
+
+ evidence.Add("G2.5-A2.1 discovery: " + discovery.Summary);
+
+ var signalStatusPoints = ResolveCommandStatusPoints(discovery.IedDirectory, commandSignals, evidence);
+ if (signalStatusPoints.Count == 0)
+ {
+ return Failed(
+ "None of the live ControlStatusReference values resolved to an ST/stVal MMS point.",
+ evidence,
+ identity,
+ profile,
+ session.IsMmsInitiated);
+ }
+
+ var preCommandPoints = BuildPreCommandBaselinePoints(discovery.IedDirectory, signalStatusPoints.Values)
+ .Take(MaximumPreCommandBaselinePoints + 1)
+ .ToArray();
+ if (preCommandPoints.Length > MaximumPreCommandBaselinePoints)
+ {
+ return Blocked(
+ $"A2.1 bounded baseline would exceed {MaximumPreCommandBaselinePoints} status points. Narrow the connected model rather than silently truncating command evidence.",
+ evidence,
+ identity,
+ profile);
+ }
+
+ var baseline = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var readFailures = 0;
+ foreach (var point in preCommandPoints)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var read = await session.ReadSingleVariableAsync(point.ToObjectReference(), cancellationToken).ConfigureAwait(false);
+ if (!read.IsSuccess || read.Value is null)
+ {
+ readFailures++;
+ evidence.Add($"G2.5-A2.1 pre-command baseline read failed: ref={point.UserReference}; result={read.Message}");
+ continue;
+ }
+
+ baseline[point.MmsReference] = NormalizeValue(ArMms.MmsDataValueRenderer.ToCompactString(read.Value));
+ }
+
+ if (!session.IsMmsInitiated || baseline.Count == 0)
+ {
+ return Failed(
+ "A2.1 could not capture a reliable pre-command read-only baseline.",
+ evidence,
+ identity,
+ profile,
+ session.IsMmsInitiated,
+ baselineCount: baseline.Count,
+ readFailures: readFailures);
+ }
+
+ evidence.Add($"G2.5-A2.1 pre-command baseline captured: successful={baseline.Count}/{preCommandPoints.Length}; failures={readFailures}");
+ evidence.Add("G2.5-A2.1 resolved command status references: " + string.Join(" | ", signalStatusPoints.Select(pair => $"{pair.Key.ObjectReference} -> {pair.Value.UserReference}")));
+
+ var commandCapture = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ PropertyChangedEventHandler handler = (sender, args) =>
+ {
+ if (args.PropertyName == nameof(SignalDefinition.ControlCommandBusy) &&
+ sender is SignalDefinition signal &&
+ signal.ControlCommandBusy &&
+ signalStatusPoints.ContainsKey(signal))
+ {
+ commandCapture.TrySetResult(signal);
+ }
+ };
+
+ foreach (var signal in commandSignals)
+ signal.PropertyChanged += handler;
+
+ SignalDefinition commandedSignal;
+ try
+ {
+ progress?.Report($"{ReadyMarker} — baseline is already captured. NOW issue exactly ONE already-proven safe OPEN/CLOSE from the ARSAS Command Panel. Do not use an external/manual stimulus for A2.1.");
+ evidence.Add($"{ReadyMarker}: waiting for one existing ARSAS command; witness itself will not issue or delay the command.");
+ commandedSignal = await commandCapture.Task.WaitAsync(CommandWaitWindow, cancellationToken).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ evidence.Add("G2.5-A2.1 command wait timed out with no ControlCommandBusy transition on a resolved command signal.");
+ return Failed(
+ "A2.1 timed out before an ARSAS command was captured. No stimulus conclusion is possible.",
+ evidence,
+ identity,
+ profile,
+ session.IsMmsInitiated,
+ baselineCount: baseline.Count,
+ readFailures: readFailures);
+ }
+ finally
+ {
+ foreach (var signal in commandSignals)
+ signal.PropertyChanged -= handler;
+ }
+
+ var commandObservedAt = DateTimeOffset.UtcNow;
+ var exactStatus = signalStatusPoints[commandedSignal];
+ var focusPoints = BuildFocusChain(discovery.IedDirectory, exactStatus)
+ .Take(MaximumFocusCandidates)
+ .ToArray();
+
+ evidence.Add($"{CommandCapturedMarker}: signal={commandedSignal.ObjectReference}; controlStatus={commandedSignal.ControlStatusReference}; resolvedStatus={exactStatus.UserReference}; controlModel={TextOrDash(commandedSignal.ControlModelText)}; at={commandObservedAt:O}");
+ evidence.Add("G2.5-A2.1 focused chain: " + string.Join(" | ", focusPoints.Select(point => point.UserReference)));
+ progress?.Report($"{CommandCapturedMarker} — exact object {commandedSignal.ObjectReference}. High-speed read-only sampling is active; do NOT issue another command.");
+
+ var trackers = new List();
+ foreach (var point in focusPoints)
+ {
+ if (!baseline.TryGetValue(point.MmsReference, out var value))
+ {
+ var read = await session.ReadSingleVariableAsync(point.ToObjectReference(), cancellationToken).ConfigureAwait(false);
+ if (!read.IsSuccess || read.Value is null)
+ {
+ readFailures++;
+ evidence.Add($"G2.5-A2.1 focused fallback baseline failed: ref={point.UserReference}; result={read.Message}");
+ continue;
+ }
+
+ value = NormalizeValue(ArMms.MmsDataValueRenderer.ToCompactString(read.Value));
+ evidence.Add($"G2.5-A2.1 focused fallback baseline captured immediately after command claim: ref={point.UserReference}; value={value}");
+ }
+
+ trackers.Add(new FocusTracker(point, value, SameReference(point.MmsReference, exactStatus.MmsReference)));
+ }
+
+ if (trackers.Count == 0)
+ {
+ return Failed(
+ "A2.1 captured the command but could not establish any focused status point for high-speed sampling.",
+ evidence,
+ identity,
+ profile,
+ session.IsMmsInitiated,
+ commandedSignal,
+ baseline.Count,
+ 0,
+ 0,
+ readFailures);
+ }
+
+ var hardDeadline = DateTimeOffset.UtcNow + FocusObservationWindow;
+ DateTimeOffset? settleDeadline = null;
+ var cycles = 0;
+ var transitionAnnounced = false;
+
+ while (DateTimeOffset.UtcNow < hardDeadline)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ cycles++;
+
+ foreach (var tracker in trackers)
+ {
+ var read = await session.ReadSingleVariableAsync(tracker.Point.ToObjectReference(), cancellationToken).ConfigureAwait(false);
+ if (!read.IsSuccess || read.Value is null)
+ {
+ readFailures++;
+ continue;
+ }
+
+ var current = NormalizeValue(ArMms.MmsDataValueRenderer.ToCompactString(read.Value));
+ if (!SameValue(current, tracker.CurrentValue))
+ {
+ var transition = new DynamicReportCommandBoundTransition
+ {
+ Reference = tracker.Point.UserReference,
+ MmsReference = tracker.Point.MmsReference,
+ BeforeValue = tracker.CurrentValue,
+ AfterValue = current,
+ ObservedAtUtc = DateTimeOffset.UtcNow
+ };
+ tracker.Transitions.Add(transition);
+ tracker.CurrentValue = current;
+ evidence.Add($"G2.5-A2.1 transition: exactStatus={tracker.ExactControlStatus}; ref={transition.Reference}; before={transition.BeforeValue}; after={transition.AfterValue}; at={transition.ObservedAtUtc:O}");
+ }
+ }
+
+ if (!session.IsMmsInitiated)
+ {
+ evidence.Add("G2.5-A2.1 association left MmsInitiated during focused sampling.");
+ break;
+ }
+
+ var firstTransition = trackers.SelectMany(item => item.Transitions).OrderBy(item => item.ObservedAtUtc).FirstOrDefault();
+ if (firstTransition is not null && !transitionAnnounced)
+ {
+ transitionAnnounced = true;
+ settleDeadline = DateTimeOffset.UtcNow + PostTransitionSettleWindow;
+ evidence.Add($"{TransitionMarker}: first={firstTransition.Reference}; {firstTransition.BeforeValue}->{firstTransition.AfterValue}; settleUntil={settleDeadline:O}");
+ progress?.Report($"{TransitionMarker} — {firstTransition.Reference}: {firstTransition.BeforeValue} → {firstTransition.AfterValue}. No more commands; classifying pulse vs persistent state.");
+ }
+
+ if (settleDeadline.HasValue && DateTimeOffset.UtcNow >= settleDeadline.Value)
+ break;
+
+ if (InterCycleDelay > TimeSpan.Zero)
+ await Task.Delay(InterCycleDelay, cancellationToken).ConfigureAwait(false);
+ }
+
+ var endedAt = DateTimeOffset.UtcNow;
+ var observations = trackers.Select(item => BuildObservation(item, endedAt)).ToArray();
+ var eligible = observations
+ .Where(item => item.TransitionCount > 0)
+ .OrderByDescending(EligibilityScore)
+ .ThenBy(item => item.Reference, StringComparer.OrdinalIgnoreCase)
+ .Select((item, index) => CloneWithRank(item, index + 1))
+ .ToArray();
+
+ foreach (var item in eligible)
+ {
+ evidence.Add($"G2.5-A2.1 ELIGIBLE: rank={item.Rank}; exactStatus={item.ExactControlStatus}; kind={item.Kind}; ref={item.Reference}; baseline={item.BaselineValue}; final={item.FinalValue}; transitions={item.TransitionCount}; activeMs={FormatMilliseconds(item.ObservedActiveMilliseconds)}");
+ }
+
+ var healthy = session.IsMmsInitiated;
+ var success = healthy && eligible.Length > 0;
+ var summary = success
+ ? $"G2.5-A2.1 PASS: exact ARSAS command {commandedSignal.ObjectReference} was captured and {eligible.Length} command-bound status candidate(s) changed. Top candidate: {eligible[0].Reference} ({eligible[0].Kind}). Use this exact evidence for narrow A3 dchg qualification. Production automatic dynamic reporting remains OFF."
+ : $"G2.5-A2.1 captured exact ARSAS command {commandedSignal.ObjectReference}, but no focused status transition was observed. Do not advance to A3/G2.5-B; inspect the exact control-feedback mapping. Production automatic dynamic reporting remains OFF.";
+
+ evidence.Add($"G2.5-A2.1 combined: success={success}; commandCaptured=True; exactSignal={commandedSignal.ObjectReference}; exactStatus={exactStatus.UserReference}; focused={trackers.Count}; cycles={cycles}; readFailures={readFailures}; eligible={eligible.Length}; associationHealthy={healthy}");
+ evidence.Add("G2.5-A2.1 safety: witness did not modify the control transaction, persisted InformationReportProven profile, RCB/DataSet state, or production dynamic-report policy.");
+
+ return new DynamicReportCommandBoundStimulusWitnessResult
+ {
+ IsSuccess = success,
+ BaselineCaptured = true,
+ CommandCaptured = true,
+ AssociationHealthy = healthy,
+ StimulusWitnessProven = eligible.Length > 0,
+ CommandSignalReference = commandedSignal.ObjectReference,
+ ControlStatusReference = commandedSignal.ControlStatusReference,
+ ControlModelText = commandedSignal.ControlModelText,
+ PreCommandBaselineCount = baseline.Count,
+ FocusCandidateCount = trackers.Count,
+ SampleCycles = cycles,
+ ReadFailures = readFailures,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ Observations = observations,
+ EligibleCandidates = eligible,
+ EvidenceLines = evidence.ToArray()
+ };
+ }
+
+ internal static IReadOnlyDictionary ResolveCommandStatusPoints(
+ ArMms.MmsIedModelDirectory directory,
+ IReadOnlyList commandSignals,
+ ICollection? evidence = null)
+ {
+ var result = new Dictionary();
+ foreach (var signal in commandSignals)
+ {
+ var point = ResolveStatusPoint(directory, signal.ControlStatusReference);
+ if (point is null)
+ {
+ evidence?.Add($"G2.5-A2.1 unresolved ControlStatusReference: signal={signal.ObjectReference}; status={signal.ControlStatusReference}");
+ continue;
+ }
+ result[signal] = point;
+ }
+ return result;
+ }
+
+ internal static IReadOnlyList BuildPreCommandBaselinePoints(
+ ArMms.MmsIedModelDirectory directory,
+ IEnumerable resolvedStatusPoints)
+ {
+ var list = new List();
+ AddDistinct(list, resolvedStatusPoints);
+ AddDistinct(list, directory.Points.Where(IsPositionStatusPoint));
+ AddDistinct(list, directory.Points.Where(IsCommandCorrelationPoint));
+ return list;
+ }
+
+ internal static IReadOnlyList BuildFocusChain(
+ ArMms.MmsIedModelDirectory directory,
+ ArMms.MmsFcResolvedPoint exactStatus)
+ {
+ var list = new List { exactStatus };
+
+ AddDistinct(list, directory.Points.Where(point =>
+ point.Domain.Equals(exactStatus.Domain, StringComparison.OrdinalIgnoreCase) &&
+ IsPositionStatusPoint(point)));
+
+ AddDistinct(list, directory.Points.Where(point =>
+ point.Domain.Equals(exactStatus.Domain, StringComparison.OrdinalIgnoreCase) &&
+ point.LogicalNode.Equals(exactStatus.LogicalNode, StringComparison.OrdinalIgnoreCase) &&
+ IsStatusValuePoint(point)));
+
+ AddDistinct(list, directory.Points.Where(IsCommandCorrelationPoint));
+ return list.Take(MaximumFocusCandidates).ToArray();
+ }
+
+ internal static DynamicReportStimulusEligibilityKind Classify(
+ string baseline,
+ string final,
+ IReadOnlyList transitions)
+ {
+ if (transitions.Count == 0)
+ return DynamicReportStimulusEligibilityKind.None;
+ if (!SameValue(final, baseline))
+ return DynamicReportStimulusEligibilityKind.PersistentOrLatched;
+ if (transitions.Count >= 2)
+ return DynamicReportStimulusEligibilityKind.MomentaryOrPulse;
+ return DynamicReportStimulusEligibilityKind.TransitionObserved;
+ }
+
+ private static ArMms.MmsFcResolvedPoint? ResolveStatusPoint(ArMms.MmsIedModelDirectory directory, string reference)
+ {
+ if (string.IsNullOrWhiteSpace(reference))
+ return null;
+
+ if (directory.TryFindByMmsReference(reference, out var direct) && IsStatusValuePoint(direct))
+ return direct;
+
+ var userMatches = directory.FindByUserReference(reference).Where(IsStatusValuePoint).OrderByDescending(point => point.Confidence).ToArray();
+ if (userMatches.Length > 0)
+ return userMatches[0];
+
+ return directory.FindByPathSuffix(reference).Where(IsStatusValuePoint).OrderByDescending(point => point.Confidence).FirstOrDefault();
+ }
+
+ private static bool IsStatusValuePoint(ArMms.MmsFcResolvedPoint point)
+ => point.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) &&
+ !point.IsReportAttribute &&
+ !point.IsControlAttribute &&
+ (point.DataObjectPath.Equals("stVal", StringComparison.OrdinalIgnoreCase) ||
+ point.DataObjectPath.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase));
+
+ private static bool IsPositionStatusPoint(ArMms.MmsFcResolvedPoint point)
+ {
+ if (!IsStatusValuePoint(point) || !point.DataObjectPath.Equals("Pos.stVal", StringComparison.OrdinalIgnoreCase))
+ return false;
+ var lnClass = ExtractLogicalNodeClass(point.LogicalNode);
+ return lnClass is "XCBR" or "CSWI" or "XSWI";
+ }
+
+ private static bool IsCommandCorrelationPoint(ArMms.MmsFcResolvedPoint point)
+ {
+ if (!IsStatusValuePoint(point))
+ return false;
+ var path = point.DataObjectPath;
+ return path.Contains("CBClsCmdRecv", StringComparison.OrdinalIgnoreCase) ||
+ path.Contains("CBOpnCmdRecv", StringComparison.OrdinalIgnoreCase) ||
+ path.Contains("LocClsCMDsta", StringComparison.OrdinalIgnoreCase) ||
+ path.Contains("LocOpnCMDsta", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string ExtractLogicalNodeClass(string logicalNode)
+ {
+ var text = logicalNode ?? string.Empty;
+ var index = 0;
+ while (index < text.Length && !char.IsDigit(text[index]))
+ index++;
+ return text[..index].ToUpperInvariant();
+ }
+
+ private static void AddDistinct(List target, IEnumerable points)
+ {
+ foreach (var point in points)
+ {
+ if (target.Any(existing => SameReference(existing.MmsReference, point.MmsReference)))
+ continue;
+ target.Add(point);
+ }
+ }
+
+ private static DynamicReportCommandBoundObservation BuildObservation(FocusTracker tracker, DateTimeOffset endedAt)
+ {
+ var kind = Classify(tracker.BaselineValue, tracker.CurrentValue, tracker.Transitions);
+ return new DynamicReportCommandBoundObservation
+ {
+ ExactControlStatus = tracker.ExactControlStatus,
+ Reference = tracker.Point.UserReference,
+ MmsReference = tracker.Point.MmsReference,
+ LogicalNode = tracker.Point.LogicalNode,
+ FunctionalConstraint = tracker.Point.FunctionalConstraint,
+ BaselineValue = tracker.BaselineValue,
+ FinalValue = tracker.CurrentValue,
+ TransitionCount = tracker.Transitions.Count,
+ Kind = kind,
+ ObservedActiveMilliseconds = ComputeObservedActiveMilliseconds(tracker.BaselineValue, tracker.CurrentValue, tracker.Transitions, endedAt),
+ Transitions = tracker.Transitions.ToArray()
+ };
+ }
+
+ private static int EligibilityScore(DynamicReportCommandBoundObservation item)
+ {
+ var score = item.ExactControlStatus ? 1000 : 0;
+ score += item.Kind switch
+ {
+ DynamicReportStimulusEligibilityKind.PersistentOrLatched => 500,
+ DynamicReportStimulusEligibilityKind.MomentaryOrPulse => 350,
+ DynamicReportStimulusEligibilityKind.TransitionObserved => 200,
+ _ => 0
+ };
+ var lnClass = ExtractLogicalNodeClass(item.LogicalNode);
+ score += lnClass switch
+ {
+ "XCBR" => 120,
+ "CSWI" => 100,
+ "XSWI" => 90,
+ "GGIO" => 50,
+ _ => 0
+ };
+ return score;
+ }
+
+ private static DynamicReportCommandBoundObservation CloneWithRank(DynamicReportCommandBoundObservation source, int rank)
+ => new()
+ {
+ Rank = rank,
+ ExactControlStatus = source.ExactControlStatus,
+ Reference = source.Reference,
+ MmsReference = source.MmsReference,
+ LogicalNode = source.LogicalNode,
+ FunctionalConstraint = source.FunctionalConstraint,
+ BaselineValue = source.BaselineValue,
+ FinalValue = source.FinalValue,
+ TransitionCount = source.TransitionCount,
+ Kind = source.Kind,
+ ObservedActiveMilliseconds = source.ObservedActiveMilliseconds,
+ Transitions = source.Transitions
+ };
+
+ private static double? ComputeObservedActiveMilliseconds(
+ string baseline,
+ string final,
+ IReadOnlyList transitions,
+ DateTimeOffset endedAt)
+ {
+ if (transitions.Count == 0)
+ return null;
+ var departure = transitions.FirstOrDefault(item => SameValue(item.BeforeValue, baseline) && !SameValue(item.AfterValue, baseline)) ?? transitions[0];
+ var returned = transitions.FirstOrDefault(item => item.ObservedAtUtc >= departure.ObservedAtUtc && SameValue(item.AfterValue, baseline));
+ var end = returned?.ObservedAtUtc ?? endedAt;
+ var milliseconds = (end - departure.ObservedAtUtc).TotalMilliseconds;
+ return milliseconds < 0 ? null : milliseconds;
+ }
+
+ private static bool SameReference(string? left, string? right)
+ => string.Equals(NormalizeReference(left), NormalizeReference(right), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeReference(string? value)
+ => (value ?? string.Empty).Trim().Replace('.', '$');
+
+ private static bool SameValue(string? left, string? right)
+ => string.Equals(NormalizeValue(left), NormalizeValue(right), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeValue(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+
+ private static string TextOrDash(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+
+ private static string FormatMilliseconds(double? value)
+ => value.HasValue ? value.Value.ToString("0.###") : "-";
+
+ private static DynamicReportCommandBoundStimulusWitnessResult Blocked(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity? identity = null,
+ ArMms.MmsDynamicReportQualificationProfile? profile = null)
+ => new()
+ {
+ IsBlocked = true,
+ Summary = summary + " Production automatic dynamic reporting remains OFF.",
+ Identity = identity,
+ InputProfile = profile,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static DynamicReportCommandBoundStimulusWitnessResult Failed(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity? identity,
+ ArMms.MmsDynamicReportQualificationProfile? profile,
+ bool associationHealthy,
+ SignalDefinition? commandedSignal = null,
+ int baselineCount = 0,
+ int focusCount = 0,
+ int cycles = 0,
+ int readFailures = 0)
+ => new()
+ {
+ Summary = summary + " Production automatic dynamic reporting remains OFF.",
+ Identity = identity,
+ InputProfile = profile,
+ BaselineCaptured = baselineCount > 0,
+ CommandCaptured = commandedSignal is not null,
+ AssociationHealthy = associationHealthy,
+ CommandSignalReference = commandedSignal?.ObjectReference ?? string.Empty,
+ ControlStatusReference = commandedSignal?.ControlStatusReference ?? string.Empty,
+ ControlModelText = commandedSignal?.ControlModelText ?? string.Empty,
+ PreCommandBaselineCount = baselineCount,
+ FocusCandidateCount = focusCount,
+ SampleCycles = cycles,
+ ReadFailures = readFailures,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private sealed class FocusTracker
+ {
+ public FocusTracker(ArMms.MmsFcResolvedPoint point, string baselineValue, bool exactControlStatus)
+ {
+ Point = point;
+ BaselineValue = baselineValue;
+ CurrentValue = baselineValue;
+ ExactControlStatus = exactControlStatus;
+ }
+
+ public ArMms.MmsFcResolvedPoint Point { get; }
+ public string BaselineValue { get; }
+ public string CurrentValue { get; set; }
+ public bool ExactControlStatus { get; }
+ public List Transitions { get; } = new();
+ }
+}
diff --git a/Services/DynamicReportCommandBoundStimulusWitnessServiceV2.cs b/Services/DynamicReportCommandBoundStimulusWitnessServiceV2.cs
new file mode 100644
index 00000000..3cf8e067
--- /dev/null
+++ b/Services/DynamicReportCommandBoundStimulusWitnessServiceV2.cs
@@ -0,0 +1,423 @@
+using System.ComponentModel;
+using ArIED61850Tester.Models;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// G2.5-A2.1 correction after the first physical run proved that ControlCommandWindow
+/// bypasses SignalDefinition.ControlCommandBusy. V2 listens to BOTH existing fast-panel
+/// ControlCommandBusy and the observer-only ControlCommandWindow routed-click intent bus.
+/// It never mutates or re-issues a control transaction.
+///
+internal sealed class DynamicReportCommandBoundStimulusWitnessServiceV2
+{
+ internal const string ReadyMarker = "G2.5-A2.1 READY — ISSUE ONE ARSAS COMMAND";
+ internal const string CommandCapturedMarker = "G2.5-A2.1 COMMAND CAPTURED";
+ internal const string TransitionMarker = "G2.5-A2.1 TRANSITION OBSERVED";
+ internal const int MaximumPreCommandBaselinePoints = 128;
+ internal const int MaximumFocusCandidates = 6;
+
+ internal static readonly TimeSpan AssociationTimeout = TimeSpan.FromSeconds(10);
+ internal static readonly TimeSpan CommandWaitWindow = TimeSpan.FromSeconds(45);
+ internal static readonly TimeSpan FocusObservationWindow = TimeSpan.FromSeconds(5);
+ internal static readonly TimeSpan PostTransitionSettleWindow = TimeSpan.FromSeconds(2);
+ internal static readonly TimeSpan InterCycleDelay = TimeSpan.FromMilliseconds(1);
+
+ private readonly DynamicReportQualificationProfileStore _profileStore;
+
+ internal DynamicReportCommandBoundStimulusWitnessServiceV2(
+ DynamicReportQualificationProfileStore? profileStore = null)
+ {
+ _profileStore = profileStore ?? new DynamicReportQualificationProfileStore();
+ }
+
+ internal async Task RunAsync(
+ Iec61850MonitorDevice device,
+ IReadOnlyList fullModelSignals,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentNullException.ThrowIfNull(fullModelSignals);
+
+ var evidence = new List
+ {
+ "G2.5-A2.1 V2 contract: observer-only command capture + HIGH-SPEED MMS witness. Existing control source and wire transaction are untouched.",
+ "G2.5-A2.1 V2 capture sources: fast Command Panel ControlCommandBusy OR ControlCommandWindow routed Button.Click intent observed before its existing SendCommand_Click handler.",
+ "G2.5-A2.1 V2 performs no RCB attribute access, no RptEna/Resv/DatSet/TrgOps/OptFlds mutation, no GI, no Define/DeleteNamedVariableList, no report monitor and no profile save.",
+ $"G2.5-A2.1 V2 bounds: preCommandBaseline<={MaximumPreCommandBaselinePoints}; focusedCandidates<={MaximumFocusCandidates}; waitForCommand={CommandWaitWindow.TotalSeconds:0}s; focusWindow={FocusObservationWindow.TotalSeconds:0}s; settleAfterFirstTransition={PostTransitionSettleWindow.TotalSeconds:0}s."
+ };
+
+ ArMms.MmsDynamicReportIedIdentity identity;
+ try
+ {
+ identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ return Blocked("G2.5-A2.1 V2 identity preflight failed: " + ex.Message, evidence);
+ }
+
+ var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false);
+ evidence.Add($"G2.5-A2.1 V2 persisted profile: exists={loaded.Exists}; valid={loaded.IsValid}; state={loaded.Profile?.State.ToString() ?? "-"}; reason={loaded.Reason}");
+ if (!loaded.IsValid || loaded.Profile is null ||
+ loaded.Profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven)
+ {
+ return Blocked(
+ "G2.5-A2.1 V2 requires the identity-compatible InformationReportProven G2.4 profile.",
+ evidence,
+ identity,
+ loaded.Profile);
+ }
+
+ var profile = loaded.Profile;
+ var commandSignals = fullModelSignals
+ .Where(signal => signal.IsControlSignal && !string.IsNullOrWhiteSpace(signal.ControlStatusReference))
+ .Distinct()
+ .ToArray();
+ if (commandSignals.Length == 0)
+ return Failed("No control signal exposes a ControlStatusReference.", evidence, identity, profile, true);
+
+ if (commandSignals.Any(signal => signal.ControlCommandBusy))
+ return Blocked("A control command is already in progress. Arm A2.1 before the one test command.", evidence, identity, profile);
+
+ await using var session = new ArMms.MmsClientSession();
+ try
+ {
+ await session.ConnectAsync(device.IpAddress, device.Port, AssociationTimeout, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException)
+ {
+ evidence.Add($"G2.5-A2.1 V2 association failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed("The isolated read-only A2.1 V2 MMS association could not be established.", evidence, identity, profile, false);
+ }
+
+ evidence.Add($"G2.5-A2.1 V2 association ready: state={session.State}; localTcpAddress={TextOrDash(session.LocalTcpAddress)}; READ-ONLY=true");
+
+ ArMms.MmsDiscoveryResult discovery;
+ try
+ {
+ discovery = await session.DiscoverAsync(
+ probeReportAttributes: false,
+ maxReportAttributeProbes: 0,
+ cancellationToken: cancellationToken,
+ readDataSetDirectories: false,
+ maxDataSetDirectoryReads: 0).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.5-A2.1 V2 discovery failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed("A2.1 V2 live discovery failed before command arming.", evidence, identity, profile, session.IsMmsInitiated);
+ }
+
+ evidence.Add("G2.5-A2.1 V2 discovery: " + discovery.Summary);
+ var signalStatusPoints = DynamicReportCommandBoundStimulusWitnessService.ResolveCommandStatusPoints(
+ discovery.IedDirectory,
+ commandSignals,
+ evidence);
+ if (signalStatusPoints.Count == 0)
+ return Failed("None of the live ControlStatusReference values resolved to an ST/stVal MMS point.", evidence, identity, profile, session.IsMmsInitiated);
+
+ var preCommandPoints = DynamicReportCommandBoundStimulusWitnessService.BuildPreCommandBaselinePoints(
+ discovery.IedDirectory,
+ signalStatusPoints.Values)
+ .Take(MaximumPreCommandBaselinePoints + 1)
+ .ToArray();
+ if (preCommandPoints.Length > MaximumPreCommandBaselinePoints)
+ return Blocked($"A2.1 V2 baseline exceeds {MaximumPreCommandBaselinePoints} points; refusing silent truncation.", evidence, identity, profile);
+
+ var baseline = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var readFailures = 0;
+ foreach (var point in preCommandPoints)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var read = await session.ReadSingleVariableAsync(point.ToObjectReference(), cancellationToken).ConfigureAwait(false);
+ if (!read.IsSuccess || read.Value is null)
+ {
+ readFailures++;
+ evidence.Add($"G2.5-A2.1 V2 pre-command baseline read failed: ref={point.UserReference}; result={read.Message}");
+ continue;
+ }
+ baseline[point.MmsReference] = NormalizeValue(ArMms.MmsDataValueRenderer.ToCompactString(read.Value));
+ }
+ if (!session.IsMmsInitiated || baseline.Count == 0)
+ return Failed("A2.1 V2 could not capture a reliable pre-command baseline.", evidence, identity, profile, session.IsMmsInitiated, baselineCount: baseline.Count, readFailures: readFailures);
+
+ evidence.Add($"G2.5-A2.1 V2 pre-command baseline captured: successful={baseline.Count}/{preCommandPoints.Length}; failures={readFailures}");
+ evidence.Add("G2.5-A2.1 V2 resolved command status references: " + string.Join(" | ", signalStatusPoints.Select(pair => $"{pair.Key.ObjectReference} -> {pair.Value.UserReference}")));
+
+ var commandCapture = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ PropertyChangedEventHandler busyHandler = (sender, args) =>
+ {
+ if (args.PropertyName == nameof(SignalDefinition.ControlCommandBusy) &&
+ sender is SignalDefinition signal && signal.ControlCommandBusy && signalStatusPoints.ContainsKey(signal))
+ {
+ commandCapture.TrySetResult(new CommandCapture(signal, "FastCommandPanel.ControlCommandBusy", signal.ControlPendingValue, DateTimeOffset.UtcNow));
+ }
+ };
+ foreach (var signal in commandSignals)
+ signal.PropertyChanged += busyHandler;
+
+ using var intentSubscription = DynamicReportCommandIntentObservation.Subscribe(intent =>
+ {
+ if (!ReferenceEquals(intent.Device, device) &&
+ !string.Equals(intent.Device.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var matched = commandSignals.FirstOrDefault(signal =>
+ ReferenceEquals(signal, intent.Signal) || SameReference(signal.ObjectReference, intent.Signal.ObjectReference));
+ if (matched is null || !signalStatusPoints.ContainsKey(matched))
+ return;
+
+ commandCapture.TrySetResult(new CommandCapture(matched, intent.Source, intent.RequestedValue, intent.ObservedAtUtc));
+ });
+
+ CommandCapture capture;
+ try
+ {
+ progress?.Report($"{ReadyMarker} — baseline captured. NOW issue exactly ONE already-proven safe OPEN/CLOSE using either normal ARSAS control UI. Do not use an external stimulus.");
+ evidence.Add($"{ReadyMarker}: waiting for fast-panel busy OR ControlCommandWindow observer intent; witness does not issue/delay command.");
+ capture = await commandCapture.Task.WaitAsync(CommandWaitWindow, cancellationToken).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ evidence.Add("G2.5-A2.1 V2 command wait timed out: neither ControlCommandBusy nor ControlCommandWindow observer intent was captured.");
+ return Failed("A2.1 V2 timed out before an ARSAS command was captured. No stimulus conclusion is possible.", evidence, identity, profile, session.IsMmsInitiated, baselineCount: baseline.Count, readFailures: readFailures);
+ }
+ finally
+ {
+ foreach (var signal in commandSignals)
+ signal.PropertyChanged -= busyHandler;
+ }
+
+ var commandedSignal = capture.Signal;
+ var exactStatus = signalStatusPoints[commandedSignal];
+ var focusPoints = DynamicReportCommandBoundStimulusWitnessService.BuildFocusChain(discovery.IedDirectory, exactStatus)
+ .Take(MaximumFocusCandidates)
+ .ToArray();
+
+ evidence.Add($"{CommandCapturedMarker}: source={capture.Source}; requested={TextOrDash(capture.RequestedValue)}; signal={commandedSignal.ObjectReference}; controlStatus={commandedSignal.ControlStatusReference}; resolvedStatus={exactStatus.UserReference}; controlModel={TextOrDash(commandedSignal.ControlModelText)}; at={capture.ObservedAtUtc:O}");
+ evidence.Add("G2.5-A2.1 V2 focused chain: " + string.Join(" | ", focusPoints.Select(point => point.UserReference)));
+ progress?.Report($"{CommandCapturedMarker} — {commandedSignal.ObjectReference} via {capture.Source}. High-speed read-only sampling active; do NOT issue another command.");
+
+ var trackers = new List();
+ foreach (var point in focusPoints)
+ {
+ if (!baseline.TryGetValue(point.MmsReference, out var baselineValue))
+ {
+ // Do not invent a post-command baseline: it could erase a short physical pulse.
+ evidence.Add($"G2.5-A2.1 V2 focus point excluded because no PRE-command baseline exists: {point.UserReference}");
+ continue;
+ }
+ trackers.Add(new FocusTracker(point, baselineValue, SameReference(point.MmsReference, exactStatus.MmsReference)));
+ }
+ if (trackers.Count == 0)
+ return Failed("Command captured, but no focused point had a trustworthy PRE-command baseline.", evidence, identity, profile, session.IsMmsInitiated, commandedSignal, baseline.Count, 0, 0, readFailures);
+
+ var hardDeadline = DateTimeOffset.UtcNow + FocusObservationWindow;
+ DateTimeOffset? settleDeadline = null;
+ var cycles = 0;
+ var transitionAnnounced = false;
+ while (DateTimeOffset.UtcNow < hardDeadline)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ cycles++;
+ foreach (var tracker in trackers)
+ {
+ var read = await session.ReadSingleVariableAsync(tracker.Point.ToObjectReference(), cancellationToken).ConfigureAwait(false);
+ if (!read.IsSuccess || read.Value is null)
+ {
+ readFailures++;
+ continue;
+ }
+ var current = NormalizeValue(ArMms.MmsDataValueRenderer.ToCompactString(read.Value));
+ if (SameValue(current, tracker.CurrentValue))
+ continue;
+
+ var transition = new DynamicReportCommandBoundTransition
+ {
+ Reference = tracker.Point.UserReference,
+ MmsReference = tracker.Point.MmsReference,
+ BeforeValue = tracker.CurrentValue,
+ AfterValue = current,
+ ObservedAtUtc = DateTimeOffset.UtcNow
+ };
+ tracker.Transitions.Add(transition);
+ tracker.CurrentValue = current;
+ evidence.Add($"G2.5-A2.1 V2 transition: exactStatus={tracker.ExactControlStatus}; ref={transition.Reference}; before={transition.BeforeValue}; after={transition.AfterValue}; at={transition.ObservedAtUtc:O}");
+ }
+
+ if (!session.IsMmsInitiated)
+ {
+ evidence.Add("G2.5-A2.1 V2 association left MmsInitiated during focused sampling.");
+ break;
+ }
+
+ var first = trackers.SelectMany(t => t.Transitions).OrderBy(t => t.ObservedAtUtc).FirstOrDefault();
+ if (first is not null && !transitionAnnounced)
+ {
+ transitionAnnounced = true;
+ settleDeadline = DateTimeOffset.UtcNow + PostTransitionSettleWindow;
+ evidence.Add($"{TransitionMarker}: first={first.Reference}; {first.BeforeValue}->{first.AfterValue}; settleUntil={settleDeadline:O}");
+ progress?.Report($"{TransitionMarker} — {first.Reference}: {first.BeforeValue} → {first.AfterValue}. No more commands; classifying state behavior.");
+ }
+ if (settleDeadline.HasValue && DateTimeOffset.UtcNow >= settleDeadline.Value)
+ break;
+ if (InterCycleDelay > TimeSpan.Zero)
+ await Task.Delay(InterCycleDelay, cancellationToken).ConfigureAwait(false);
+ }
+
+ var endedAt = DateTimeOffset.UtcNow;
+ var observations = trackers.Select(t => ToObservation(t, endedAt)).ToArray();
+ var eligible = observations
+ .Where(o => o.TransitionCount > 0)
+ .OrderByDescending(o => o.ExactControlStatus)
+ .ThenByDescending(o => o.Kind == DynamicReportStimulusEligibilityKind.PersistentOrLatched)
+ .ThenByDescending(o => o.TransitionCount)
+ .ThenBy(o => o.Reference, StringComparer.OrdinalIgnoreCase)
+ .Select((o, index) => WithRank(o, index + 1))
+ .ToArray();
+
+ foreach (var item in eligible)
+ evidence.Add($"G2.5-A2.1 V2 ELIGIBLE: rank={item.Rank}; exactStatus={item.ExactControlStatus}; kind={item.Kind}; ref={item.Reference}; baseline={item.BaselineValue}; final={item.FinalValue}; transitions={item.TransitionCount}; activeMs={FormatMs(item.ObservedActiveMilliseconds)}");
+
+ var healthy = session.IsMmsInitiated;
+ var success = healthy && eligible.Length > 0;
+ evidence.Add($"G2.5-A2.1 V2 combined: success={success}; commandCaptured=True; source={capture.Source}; baseline={baseline.Count}; focus={trackers.Count}; cycles={cycles}; readFailures={readFailures}; eligible={eligible.Length}; associationHealthy={healthy}");
+ evidence.Add("G2.5-A2.1 V2 safety: profile remains InformationReportProven; production automatic dynamic reporting remains OFF.");
+
+ return new DynamicReportCommandBoundStimulusWitnessResult
+ {
+ IsSuccess = success,
+ BaselineCaptured = true,
+ CommandCaptured = true,
+ AssociationHealthy = healthy,
+ StimulusWitnessProven = eligible.Length > 0,
+ CommandSignalReference = commandedSignal.ObjectReference,
+ ControlStatusReference = commandedSignal.ControlStatusReference,
+ ControlModelText = commandedSignal.ControlModelText,
+ PreCommandBaselineCount = baseline.Count,
+ FocusCandidateCount = trackers.Count,
+ SampleCycles = cycles,
+ ReadFailures = readFailures,
+ Summary = success
+ ? $"G2.5-A2.1 PASS: exact ARSAS command was captured via {capture.Source} and {eligible.Length} command-bound MMS transition candidate(s) were proven. Use the ranked evidence for narrow A3; production dynamic reporting remains OFF."
+ : $"G2.5-A2.1 captured the exact ARSAS command via {capture.Source}, but no focused MMS transition was observed. Do not advance to A3/G2.5-B; production dynamic reporting remains OFF.",
+ Identity = identity,
+ InputProfile = profile,
+ Observations = observations,
+ EligibleCandidates = eligible,
+ EvidenceLines = evidence.ToArray()
+ };
+ }
+
+ private static DynamicReportCommandBoundObservation ToObservation(FocusTracker tracker, DateTimeOffset endedAt)
+ {
+ var kind = tracker.Transitions.Count == 0
+ ? DynamicReportStimulusEligibilityKind.None
+ : SameValue(tracker.CurrentValue, tracker.BaselineValue) && tracker.Transitions.Count >= 2
+ ? DynamicReportStimulusEligibilityKind.MomentaryOrPulse
+ : !SameValue(tracker.CurrentValue, tracker.BaselineValue)
+ ? DynamicReportStimulusEligibilityKind.PersistentOrLatched
+ : DynamicReportStimulusEligibilityKind.TransitionObserved;
+ return new DynamicReportCommandBoundObservation
+ {
+ ExactControlStatus = tracker.ExactControlStatus,
+ Reference = tracker.Point.UserReference,
+ MmsReference = tracker.Point.MmsReference,
+ LogicalNode = tracker.Point.LogicalNode,
+ FunctionalConstraint = tracker.Point.FunctionalConstraint,
+ BaselineValue = tracker.BaselineValue,
+ FinalValue = tracker.CurrentValue,
+ TransitionCount = tracker.Transitions.Count,
+ Kind = kind,
+ ObservedActiveMilliseconds = ActiveMilliseconds(tracker, endedAt),
+ Transitions = tracker.Transitions.ToArray()
+ };
+ }
+
+ private static double? ActiveMilliseconds(FocusTracker tracker, DateTimeOffset endedAt)
+ {
+ if (tracker.Transitions.Count == 0)
+ return null;
+ var departure = tracker.Transitions.FirstOrDefault(t => SameValue(t.BeforeValue, tracker.BaselineValue) && !SameValue(t.AfterValue, tracker.BaselineValue)) ?? tracker.Transitions[0];
+ var returned = tracker.Transitions.FirstOrDefault(t => t.ObservedAtUtc >= departure.ObservedAtUtc && SameValue(t.AfterValue, tracker.BaselineValue));
+ return ((returned?.ObservedAtUtc ?? endedAt) - departure.ObservedAtUtc).TotalMilliseconds;
+ }
+
+ private static DynamicReportCommandBoundObservation WithRank(DynamicReportCommandBoundObservation source, int rank) => new()
+ {
+ Rank = rank,
+ ExactControlStatus = source.ExactControlStatus,
+ Reference = source.Reference,
+ MmsReference = source.MmsReference,
+ LogicalNode = source.LogicalNode,
+ FunctionalConstraint = source.FunctionalConstraint,
+ BaselineValue = source.BaselineValue,
+ FinalValue = source.FinalValue,
+ TransitionCount = source.TransitionCount,
+ Kind = source.Kind,
+ ObservedActiveMilliseconds = source.ObservedActiveMilliseconds,
+ Transitions = source.Transitions
+ };
+
+ private static string NormalizeValue(string? value) => (value ?? string.Empty).Trim();
+ private static bool SameValue(string? a, string? b) => string.Equals(NormalizeValue(a), NormalizeValue(b), StringComparison.OrdinalIgnoreCase);
+ private static bool SameReference(string? a, string? b) => string.Equals(NormalizeReference(a), NormalizeReference(b), StringComparison.OrdinalIgnoreCase);
+ private static string NormalizeReference(string? value) => (value ?? string.Empty).Trim().Replace('$', '.');
+ private static string TextOrDash(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+ private static string FormatMs(double? value) => value.HasValue ? value.Value.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) : "-";
+
+ private static DynamicReportCommandBoundStimulusWitnessResult Blocked(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity? identity = null,
+ ArMms.MmsDynamicReportQualificationProfile? profile = null) => new()
+ {
+ IsBlocked = true,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static DynamicReportCommandBoundStimulusWitnessResult Failed(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity? identity,
+ ArMms.MmsDynamicReportQualificationProfile? profile,
+ bool associationHealthy,
+ SignalDefinition? commandedSignal = null,
+ int baselineCount = 0,
+ int focusCount = 0,
+ int cycles = 0,
+ int readFailures = 0) => new()
+ {
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ BaselineCaptured = baselineCount > 0,
+ CommandCaptured = commandedSignal is not null,
+ AssociationHealthy = associationHealthy,
+ CommandSignalReference = commandedSignal?.ObjectReference ?? string.Empty,
+ ControlStatusReference = commandedSignal?.ControlStatusReference ?? string.Empty,
+ ControlModelText = commandedSignal?.ControlModelText ?? string.Empty,
+ PreCommandBaselineCount = baselineCount,
+ FocusCandidateCount = focusCount,
+ SampleCycles = cycles,
+ ReadFailures = readFailures,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private sealed record CommandCapture(SignalDefinition Signal, string Source, string RequestedValue, DateTimeOffset ObservedAtUtc);
+
+ private sealed class FocusTracker(ArMms.MmsFcResolvedPoint point, string baselineValue, bool exactControlStatus)
+ {
+ internal ArMms.MmsFcResolvedPoint Point { get; } = point;
+ internal string BaselineValue { get; } = baselineValue;
+ internal string CurrentValue { get; set; } = baselineValue;
+ internal bool ExactControlStatus { get; } = exactControlStatus;
+ internal List Transitions { get; } = new();
+ }
+}
diff --git a/Services/DynamicReportCommandIntentObservation.cs b/Services/DynamicReportCommandIntentObservation.cs
new file mode 100644
index 00000000..86328bcc
--- /dev/null
+++ b/Services/DynamicReportCommandIntentObservation.cs
@@ -0,0 +1,68 @@
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester.Services;
+
+internal sealed record DynamicReportObservedCommandIntent(
+ Iec61850MonitorDevice Device,
+ SignalDefinition Signal,
+ string RequestedValue,
+ string Source,
+ DateTimeOffset ObservedAtUtc);
+
+///
+/// Observer-only command-intent bus used by G2.5-A2.1. Publishers never depend on
+/// subscribers and every subscriber exception is contained so commissioning
+/// observability can never disturb the existing control transaction.
+///
+internal static class DynamicReportCommandIntentObservation
+{
+ private static readonly object Sync = new();
+ private static readonly List> Subscribers = new();
+
+ internal static IDisposable Subscribe(Action subscriber)
+ {
+ ArgumentNullException.ThrowIfNull(subscriber);
+ lock (Sync)
+ Subscribers.Add(subscriber);
+ return new Subscription(subscriber);
+ }
+
+ internal static void Publish(DynamicReportObservedCommandIntent intent)
+ {
+ ArgumentNullException.ThrowIfNull(intent);
+ Action[] snapshot;
+ lock (Sync)
+ snapshot = Subscribers.ToArray();
+
+ foreach (var subscriber in snapshot)
+ {
+ try
+ {
+ subscriber(intent);
+ }
+ catch
+ {
+ // Fail open for the user's existing control command. A diagnostic
+ // observer must never throw into the routed Button.Click/control path.
+ }
+ }
+ }
+
+ private static void Unsubscribe(Action subscriber)
+ {
+ lock (Sync)
+ Subscribers.Remove(subscriber);
+ }
+
+ private sealed class Subscription(Action subscriber) : IDisposable
+ {
+ private Action? _subscriber = subscriber;
+
+ public void Dispose()
+ {
+ var value = Interlocked.Exchange(ref _subscriber, null);
+ if (value is not null)
+ Unsubscribe(value);
+ }
+ }
+}
diff --git a/tests/ARSAS.Tests/DynamicReportCommandBoundStimulusWitnessServiceTests.cs b/tests/ARSAS.Tests/DynamicReportCommandBoundStimulusWitnessServiceTests.cs
new file mode 100644
index 00000000..b3d45992
--- /dev/null
+++ b/tests/ARSAS.Tests/DynamicReportCommandBoundStimulusWitnessServiceTests.cs
@@ -0,0 +1,165 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+using AR.Iec61850.Mms;
+using Xunit;
+
+namespace ARSAS.Tests;
+
+public sealed class DynamicReportCommandBoundStimulusWitnessServiceTests
+{
+ [Fact]
+ public void Contract_IsBoundedAndHighSpeed()
+ {
+ Assert.Equal(6, DynamicReportCommandBoundStimulusWitnessService.MaximumFocusCandidates);
+ Assert.Equal(128, DynamicReportCommandBoundStimulusWitnessService.MaximumPreCommandBaselinePoints);
+ Assert.Equal("G2.5-A2.1 READY — ISSUE ONE ARSAS COMMAND", DynamicReportCommandBoundStimulusWitnessService.ReadyMarker);
+ Assert.True(DynamicReportCommandBoundStimulusWitnessService.FocusObservationWindow <= TimeSpan.FromSeconds(5));
+ Assert.True(DynamicReportCommandBoundStimulusWitnessService.InterCycleDelay <= TimeSpan.FromMilliseconds(1));
+ }
+
+ [Fact]
+ public void ResolveCommandStatusPoints_UsesExactControlStatusReference()
+ {
+ var status = Point("AA1Q0", "XCBR1", "ST", "Pos.stVal", "XCBR1$ST$Pos$stVal");
+ var unrelated = Point("AA1Q8", "XCBR1", "ST", "Pos.stVal", "XCBR1$ST$Pos$stVal");
+ var directory = new MmsIedModelDirectory([status, unrelated]);
+ var signal = new SignalDefinition
+ {
+ IsControlSignal = true,
+ ObjectReference = "AA1Q0/CSWI1.Pos",
+ ControlStatusReference = "AA1Q0/XCBR1.Pos.stVal"
+ };
+
+ var resolved = DynamicReportCommandBoundStimulusWitnessService.ResolveCommandStatusPoints(directory, [signal]);
+
+ Assert.True(resolved.TryGetValue(signal, out var point));
+ Assert.Equal("AA1Q0/XCBR1$ST$Pos$stVal", point!.MmsReference);
+ }
+
+ [Fact]
+ public void BuildFocusChain_StartsWithExactStatus_AndStaysBounded()
+ {
+ var exact = Point("AA1Q0", "XCBR1", "ST", "Pos.stVal", "XCBR1$ST$Pos$stVal");
+ var cswi = Point("AA1Q0", "CSWI1", "ST", "Pos.stVal", "CSWI1$ST$Pos$stVal");
+ var xswi = Point("AA1Q0", "XSWI1", "ST", "Pos.stVal", "XSWI1$ST$Pos$stVal");
+ var openPulse = Point("AA1ADD", "GGIO2", "ST", "CBOpnCmdRecv.stVal", "GGIO2$ST$CBOpnCmdRecv$stVal");
+ var closePulse = Point("AA1ADD", "GGIO2", "ST", "CBClsCmdRecv.stVal", "GGIO2$ST$CBClsCmdRecv$stVal");
+ var localOpen = Point("AA1ADD", "GGIO1", "ST", "LocOpnCMDsta.stVal", "GGIO1$ST$LocOpnCMDsta$stVal");
+ var localClose = Point("AA1ADD", "GGIO1", "ST", "LocClsCMDsta.stVal", "GGIO1$ST$LocClsCMDsta$stVal");
+ var directory = new MmsIedModelDirectory([exact, cswi, xswi, openPulse, closePulse, localOpen, localClose]);
+
+ var focus = DynamicReportCommandBoundStimulusWitnessService.BuildFocusChain(directory, exact);
+
+ Assert.NotEmpty(focus);
+ Assert.Equal(exact.MmsReference, focus[0].MmsReference);
+ Assert.True(focus.Count <= DynamicReportCommandBoundStimulusWitnessService.MaximumFocusCandidates);
+ Assert.Contains(focus, point => point.MmsReference == cswi.MmsReference);
+ Assert.Contains(focus, point => point.MmsReference.Contains("CmdRecv", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Classify_PersistentTransition_IsLatched()
+ {
+ var transitions = new[]
+ {
+ Transition("bits(80, unused=6)", "bits(40, unused=6)", 0)
+ };
+
+ var kind = DynamicReportCommandBoundStimulusWitnessService.Classify(
+ "bits(80, unused=6)",
+ "bits(40, unused=6)",
+ transitions);
+
+ Assert.Equal(DynamicReportStimulusEligibilityKind.PersistentOrLatched, kind);
+ }
+
+ [Fact]
+ public void Classify_ReturnToBaseline_IsPulse()
+ {
+ var transitions = new[]
+ {
+ Transition("false", "true", 0),
+ Transition("true", "false", 50)
+ };
+
+ var kind = DynamicReportCommandBoundStimulusWitnessService.Classify("false", "false", transitions);
+
+ Assert.Equal(DynamicReportStimulusEligibilityKind.MomentaryOrPulse, kind);
+ }
+
+ [Fact]
+ public void Source_IsReadOnlyAndDetectsExistingControlBusySignal()
+ {
+ var root = FindRepositoryRoot();
+ var source = File.ReadAllText(Path.Combine(root, "Services", "DynamicReportCommandBoundStimulusWitnessService.cs"));
+
+ Assert.Contains("PropertyChanged", source, StringComparison.Ordinal);
+ Assert.Contains("ControlCommandBusy", source, StringComparison.Ordinal);
+ Assert.Contains("commandCapture.TrySetResult(signal)", source, StringComparison.Ordinal);
+ Assert.Contains("ReadSingleVariableAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("WriteReportControl", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("PrepareDynamicRcbCommissioningFieldsAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("StartPersistentReportMonitor", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DefineDataSet", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DeleteDataSet", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("SaveAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Ui_IsExplicitShortcut_AndDoesNotTouchExistingG25UiHandler()
+ {
+ var root = FindRepositoryRoot();
+ var ui = File.ReadAllText(Path.Combine(root, "DynamicReportCommandBoundWitnessUiBehavior.cs"));
+ var app = File.ReadAllText(Path.Combine(root, "App.xaml.cs"));
+ var existing = File.ReadAllText(Path.Combine(root, "DynamicReportQualificationUiBehavior.cs"));
+
+ Assert.Contains("Key.F", ui, StringComparison.Ordinal);
+ Assert.Contains("READY — ISSUE ONE ARSAS COMMAND", ui, StringComparison.Ordinal);
+ Assert.Contains("normal ARSAS Command Panel", ui, StringComparison.Ordinal);
+ Assert.Contains("DynamicReportCommandBoundWitnessUiBehavior.Install();", app, StringComparison.Ordinal);
+ Assert.DoesNotContain("Key.F", existing, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ProductionDynamicReporting_RemainsOff()
+ {
+ var root = FindRepositoryRoot();
+ var runtime = File.ReadAllText(Path.Combine(root, "Services", "Iec61850MonitorRuntime.cs"));
+ Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal);
+ Assert.DoesNotContain("AllowDynamicUrcb = true", runtime, StringComparison.Ordinal);
+ }
+
+ private static MmsFcResolvedPoint Point(string domain, string logicalNode, string fc, string path, string item)
+ => new()
+ {
+ Domain = domain,
+ LogicalNode = logicalNode,
+ FunctionalConstraint = fc,
+ DataObjectPath = path,
+ MmsItemName = item,
+ Confidence = 100
+ };
+
+ private static DynamicReportCommandBoundTransition Transition(string before, string after, int milliseconds)
+ => new()
+ {
+ Reference = "AA1/XCBR1.Pos.stVal",
+ MmsReference = "AA1/XCBR1$ST$Pos$stVal",
+ BeforeValue = before,
+ AfterValue = after,
+ ObservedAtUtc = DateTimeOffset.UnixEpoch.AddMilliseconds(milliseconds)
+ };
+
+ private static string FindRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "ArIED61850Tester.csproj")))
+ return directory.FullName;
+ directory = directory.Parent;
+ }
+ throw new DirectoryNotFoundException("ARSAS repository root was not found from test base directory.");
+ }
+}
diff --git a/tests/ARSAS.Tests/DynamicReportCommandIntentObservationTests.cs b/tests/ARSAS.Tests/DynamicReportCommandIntentObservationTests.cs
new file mode 100644
index 00000000..bc7ceb07
--- /dev/null
+++ b/tests/ARSAS.Tests/DynamicReportCommandIntentObservationTests.cs
@@ -0,0 +1,105 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+using Xunit;
+
+namespace ARSAS.Tests;
+
+public sealed class DynamicReportCommandIntentObservationTests
+{
+ [Fact]
+ public void Bus_IsObserverOnly_AndContainsSubscriberFailures()
+ {
+ var device = new Iec61850MonitorDevice();
+ var signal = new SignalDefinition { IsControlSignal = true, ObjectReference = "AA1Q0/CSWI1.Pos" };
+ var delivered = 0;
+
+ using var failing = DynamicReportCommandIntentObservation.Subscribe(_ => throw new InvalidOperationException("observer failure"));
+ using var healthy = DynamicReportCommandIntentObservation.Subscribe(_ => delivered++);
+
+ DynamicReportCommandIntentObservation.Publish(new DynamicReportObservedCommandIntent(
+ device,
+ signal,
+ "Open [01]",
+ "test",
+ DateTimeOffset.UtcNow));
+
+ Assert.Equal(1, delivered);
+ }
+
+ [Fact]
+ public void Ui_ObservesDedicatedControlWindowBeforeExistingHandler_AndUsesV2()
+ {
+ var root = FindRepositoryRoot();
+ var ui = File.ReadAllText(Path.Combine(root, "DynamicReportCommandBoundWitnessUiBehavior.cs"));
+
+ Assert.Contains("typeof(Button)", ui, StringComparison.Ordinal);
+ Assert.Contains("Button.ClickEvent", ui, StringComparison.Ordinal);
+ Assert.Contains("ControlCommandWindow", ui, StringComparison.Ordinal);
+ Assert.Contains("Send Command", ui, StringComparison.Ordinal);
+ Assert.Contains("Send Test", ui, StringComparison.Ordinal);
+ Assert.Contains("commandWindow.CanSend", ui, StringComparison.Ordinal);
+ Assert.Contains("DynamicReportCommandIntentObservation.Publish", ui, StringComparison.Ordinal);
+ Assert.Contains("DynamicReportCommandBoundStimulusWitnessServiceV2", ui, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void V2_ListensToBothControlUiPaths_AndRemainsReadOnly()
+ {
+ var root = FindRepositoryRoot();
+ var source = File.ReadAllText(Path.Combine(root, "Services", "DynamicReportCommandBoundStimulusWitnessServiceV2.cs"));
+
+ Assert.Contains("ControlCommandBusy", source, StringComparison.Ordinal);
+ Assert.Contains("DynamicReportCommandIntentObservation.Subscribe", source, StringComparison.Ordinal);
+ Assert.Contains("FastCommandPanel.ControlCommandBusy", source, StringComparison.Ordinal);
+ Assert.Contains("ReadSingleVariableAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("ExecuteControlAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("WriteReportControl", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("PrepareDynamicRcbCommissioningFieldsAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("StartPersistentReportMonitor", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DefineDataSet", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DeleteDataSet", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("SaveAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void OriginalControlTransactionSources_AreNotInstrumented()
+ {
+ var root = FindRepositoryRoot();
+ var dialog = File.ReadAllText(Path.Combine(root, "ControlCommandWindow.xaml.cs"));
+ var mainWindow = File.ReadAllText(Path.Combine(root, "MainWindow.xaml.cs"));
+ var signal = File.ReadAllText(Path.Combine(root, "Models", "SignalDefinition.cs"));
+ var adapter = File.ReadAllText(Path.Combine(root, "ControlCommandWindow.A21Witness.cs"));
+
+ Assert.DoesNotContain("DynamicReportCommandIntentObservation", dialog, StringComparison.Ordinal);
+ Assert.DoesNotContain("A21Witness", dialog, StringComparison.Ordinal);
+ Assert.DoesNotContain("DynamicReportCommandIntentObservation", mainWindow, StringComparison.Ordinal);
+ Assert.DoesNotContain("A21Witness", mainWindow, StringComparison.Ordinal);
+ Assert.DoesNotContain("DynamicReportCommandIntentObservation", signal, StringComparison.Ordinal);
+ Assert.DoesNotContain("ExecuteControlAsync", adapter, StringComparison.Ordinal);
+ Assert.DoesNotContain("SendCommand_Click", adapter, StringComparison.Ordinal);
+ Assert.Contains("A21WitnessSignal => _signal", adapter, StringComparison.Ordinal);
+ Assert.Contains("A21WitnessDevice => _device", adapter, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ProductionDynamicReporting_RemainsOff()
+ {
+ var root = FindRepositoryRoot();
+ var runtime = File.ReadAllText(Path.Combine(root, "Services", "Iec61850MonitorRuntime.cs"));
+ Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal);
+ Assert.DoesNotContain("AllowDynamicUrcb = true", runtime, StringComparison.Ordinal);
+ }
+
+ private static string FindRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "ArIED61850Tester.csproj")))
+ return directory.FullName;
+ directory = directory.Parent;
+ }
+ throw new DirectoryNotFoundException("ARSAS repository root was not found from test base directory.");
+ }
+}