From 3eb9d358aac8ac7c83263ad224d0af09ea756d03 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 16:15:50 +0700 Subject: [PATCH 01/13] G2.5-A2.1 add command-bound high-speed read-only witness --- ...eportCommandBoundStimulusWitnessService.cs | 698 ++++++++++++++++++ 1 file changed, 698 insertions(+) create mode 100644 Services/DynamicReportCommandBoundStimulusWitnessService.cs 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(); + } +} From 7d4e1a97a7fee697c1c95c5851567c9198e7d08c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 16:16:35 +0700 Subject: [PATCH 02/13] G2.5-A2.1 add command-bound witness evidence window --- ...cReportQualificationResultWindow.G25A21.cs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 DynamicReportQualificationResultWindow.G25A21.cs diff --git a/DynamicReportQualificationResultWindow.G25A21.cs b/DynamicReportQualificationResultWindow.G25A21.cs new file mode 100644 index 00000000..b7f90f21 --- /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: {TextOrDash(result.CommandSignalReference)}"); + builder.AppendLine($"ControlStatusReference: {TextOrDash(result.ControlStatusReference)}"); + builder.AppendLine($"Control model: {TextOrDash(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: {FormatDuration(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 FormatDuration(double? milliseconds) + => milliseconds.HasValue + ? milliseconds.Value.ToString("0.0", CultureInfo.InvariantCulture) + : "-"; + + private static string TextOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); +} From 1b754394546e484920437a133224ca8d376fe5bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 16:17:08 +0700 Subject: [PATCH 03/13] G2.5-A2.1 add explicit command-bound witness UI --- DynamicReportCommandBoundWitnessUiBehavior.cs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 DynamicReportCommandBoundWitnessUiBehavior.cs diff --git a/DynamicReportCommandBoundWitnessUiBehavior.cs b/DynamicReportCommandBoundWitnessUiBehavior.cs new file mode 100644 index 00000000..c88c7596 --- /dev/null +++ b/DynamicReportCommandBoundWitnessUiBehavior.cs @@ -0,0 +1,98 @@ +using System.Threading; +using System.Windows; +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); + } + + 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 opens one isolated read-only MMS association, discovers live status points, and captures a PRE-COMMAND baseline from resolved ControlStatusReference feedback plus bounded related status points.\n\n" + + "After the status shows 'G2.5-A2.1 READY — ISSUE ONE ARSAS COMMAND', issue exactly ONE already-proven safe OPEN or CLOSE from the normal ARSAS Command Panel. Do NOT use an external/manual stimulus for this phase.\n\n" + + "A2.1 identifies the exact SignalDefinition whose existing ControlCommandBusy state becomes true, then immediately narrows read-only sampling to at most six points around that exact ControlStatusReference. The normal SBO/Operate/control path is NOT modified, delayed, wrapped or re-issued by A2.1.\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: 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 DynamicReportCommandBoundStimulusWitnessService(); + 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 stopped locally; production dynamic reporting remains OFF."; + MessageBox.Show( + window, + "G2.5-A2.1 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); + } + } +} From c3e83c8f6653f96b5702839b45fdfc178f5fecac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 16:17:43 +0700 Subject: [PATCH 04/13] G2.5-A2.1 add command-bound witness regressions --- ...CommandBoundStimulusWitnessServiceTests.cs | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 tests/ARSAS.Tests/DynamicReportCommandBoundStimulusWitnessServiceTests.cs 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."); + } +} From 945d87cea18b5d9fb15cea09cdc7ab5632880823 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 16:18:08 +0700 Subject: [PATCH 05/13] G2.5-A2.1 avoid partial result helper collisions --- DynamicReportQualificationResultWindow.G25A21.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/DynamicReportQualificationResultWindow.G25A21.cs b/DynamicReportQualificationResultWindow.G25A21.cs index b7f90f21..63a401b5 100644 --- a/DynamicReportQualificationResultWindow.G25A21.cs +++ b/DynamicReportQualificationResultWindow.G25A21.cs @@ -51,9 +51,9 @@ private static string BuildG25A21Evidence(DynamicReportCommandBoundStimulusWitne 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: {TextOrDash(result.CommandSignalReference)}"); - builder.AppendLine($"ControlStatusReference: {TextOrDash(result.ControlStatusReference)}"); - builder.AppendLine($"Control model: {TextOrDash(result.ControlModelText)}"); + 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}"); @@ -76,7 +76,7 @@ private static string BuildG25A21Evidence(DynamicReportCommandBoundStimulusWitne 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: {FormatDuration(candidate.ObservedActiveMilliseconds)}"); + 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}"); } @@ -104,11 +104,11 @@ private static string BuildG25A21Evidence(DynamicReportCommandBoundStimulusWitne return builder.ToString(); } - private static string FormatDuration(double? milliseconds) + private static string G25A21FormatDuration(double? milliseconds) => milliseconds.HasValue ? milliseconds.Value.ToString("0.0", CultureInfo.InvariantCulture) : "-"; - private static string TextOrDash(string? value) + private static string G25A21TextOrDash(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); } From f3100b3588cc9da78569ce1a9479b14e4437165b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 16:18:43 +0700 Subject: [PATCH 06/13] G2.5-A2.1 install explicit witness hotkey behavior --- App.xaml.cs | 1 + 1 file changed, 1 insertion(+) 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(); From e9c35d179469b72a7f8235494640637e54205b7a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 16:57:05 +0700 Subject: [PATCH 07/13] G2.5-A2.1 observe command intent without touching control path --- .../DynamicReportCommandIntentObservation.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 Services/DynamicReportCommandIntentObservation.cs 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); + } + } +} From ee0359a45bb470f244094b360ee70e1847a05c65 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 16:57:15 +0700 Subject: [PATCH 08/13] G2.5-A2.1 expose read-only control dialog context --- ControlCommandWindow.A21Witness.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 ControlCommandWindow.A21Witness.cs diff --git a/ControlCommandWindow.A21Witness.cs b/ControlCommandWindow.A21Witness.cs new file mode 100644 index 00000000..2a662abe --- /dev/null +++ b/ControlCommandWindow.A21Witness.cs @@ -0,0 +1,13 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Read-only A2.1 adapter. This partial adds no command behavior and does not alter +/// SendCommand_Click, ExecuteControlAsync, SBOw, Operate or CommandTermination flow. +/// +public partial class ControlCommandWindow +{ + internal SignalDefinition A21WitnessSignal => _signal; + internal Iec61850MonitorDevice A21WitnessDevice => _device; +} From 0534d90c50be4933d7928b8932d654c757ab341f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 16:58:16 +0700 Subject: [PATCH 09/13] G2.5-A2.1 capture both ARSAS control UI paths --- ...ortCommandBoundStimulusWitnessServiceV2.cs | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 Services/DynamicReportCommandBoundStimulusWitnessServiceV2.cs 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(); + } +} From 7468285fc74f19c0ce99dba5eef2a8f7f7806588 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 16:58:40 +0700 Subject: [PATCH 10/13] G2.5-A2.1 observe ControlCommandWindow without control changes --- DynamicReportCommandBoundWitnessUiBehavior.cs | 46 ++++++++++++++++--- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/DynamicReportCommandBoundWitnessUiBehavior.cs b/DynamicReportCommandBoundWitnessUiBehavior.cs index c88c7596..e083c4a4 100644 --- a/DynamicReportCommandBoundWitnessUiBehavior.cs +++ b/DynamicReportCommandBoundWitnessUiBehavior.cs @@ -1,5 +1,6 @@ using System.Threading; using System.Windows; +using System.Windows.Controls; using System.Windows.Input; using ArIED61850Tester.Services; @@ -20,6 +21,37 @@ public static void Install() 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) @@ -59,9 +91,9 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) 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 opens one isolated read-only MMS association, discovers live status points, and captures a PRE-COMMAND baseline from resolved ControlStatusReference feedback plus bounded related status points.\n\n" + - "After the status shows 'G2.5-A2.1 READY — ISSUE ONE ARSAS COMMAND', issue exactly ONE already-proven safe OPEN or CLOSE from the normal ARSAS Command Panel. Do NOT use an external/manual stimulus for this phase.\n\n" + - "A2.1 identifies the exact SignalDefinition whose existing ControlCommandBusy state becomes true, then immediately narrows read-only sampling to at most six points around that exact ControlStatusReference. The normal SBO/Operate/control path is NOT modified, delayed, wrapped or re-issued by A2.1.\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 control UI 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?", @@ -72,9 +104,9 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) if (answer != MessageBoxResult.Yes) return; - window.LastStatusText = $"G2.5-A2.1: opening isolated read-only MMS witness for {device.Name} and preparing pre-command baseline…"; + 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 DynamicReportCommandBoundStimulusWitnessService(); + 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 }; @@ -82,10 +114,10 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) } catch (Exception ex) { - window.LastStatusText = "G2.5-A2.1 stopped locally; production dynamic reporting remains OFF."; + window.LastStatusText = "G2.5-A2.1 V2 stopped locally; production dynamic reporting remains OFF."; MessageBox.Show( window, - "G2.5-A2.1 stopped. The witness did not change production reporting policy.\n\n" + ex, + "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); From 487e97053228cae250a51585d73c48edbfa44eef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 16:59:18 +0700 Subject: [PATCH 11/13] G2.5-A2.1 regress dedicated control dialog observer path --- ...amicReportCommandIntentObservationTests.cs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/ARSAS.Tests/DynamicReportCommandIntentObservationTests.cs 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."); + } +} From 6b01bd784c4a5744924503109c1633888cfb28e8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 17:01:12 +0700 Subject: [PATCH 12/13] G2.5-A2.1 clarify both safe ARSAS control UI paths --- DynamicReportCommandBoundWitnessUiBehavior.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DynamicReportCommandBoundWitnessUiBehavior.cs b/DynamicReportCommandBoundWitnessUiBehavior.cs index e083c4a4..2f56b4a3 100644 --- a/DynamicReportCommandBoundWitnessUiBehavior.cs +++ b/DynamicReportCommandBoundWitnessUiBehavior.cs @@ -92,7 +92,7 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) $"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 control UI you normally use. Do NOT use an external/manual stimulus for this phase.\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" + From 0219b9aa2e2f9ca276018193c17fe7fb3b9b0495 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 17:05:43 +0700 Subject: [PATCH 13/13] G2.5-A2.1 keep observer adapter source-only --- ControlCommandWindow.A21Witness.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ControlCommandWindow.A21Witness.cs b/ControlCommandWindow.A21Witness.cs index 2a662abe..4d2bb31b 100644 --- a/ControlCommandWindow.A21Witness.cs +++ b/ControlCommandWindow.A21Witness.cs @@ -3,8 +3,8 @@ namespace ArIED61850Tester; /// -/// Read-only A2.1 adapter. This partial adds no command behavior and does not alter -/// SendCommand_Click, ExecuteControlAsync, SBOw, Operate or CommandTermination flow. +/// 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 {