From c1972be4265ff82534f6e6c9775a805284d90583 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:48:42 +0700 Subject: [PATCH 01/48] fix: select only RCB-backed static DataSets --- ...Iec61850StaticDataSetAuthoritySelection.cs | 135 ++++++++++++------ 1 file changed, 91 insertions(+), 44 deletions(-) diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index 63844047..c97002b7 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -6,11 +6,16 @@ namespace ArIED61850Tester.Services; /// /// Builds the exact Static DataSet selection used by the report-only workflow. /// -/// A DataSetReference on a browsed/runtime alias is not sufficient authority: several +/// Static report-only mode is RCB-backed by definition. A static DataSet that is not +/// referenced by any configured BRCB/URCB is valid engineering inventory, but it is not +/// a live acquisition source and therefore must not inflate the monitor with permanently +/// unavailable rows. Selection is limited to exact DataSet memberships referenced by a +/// configured ReportControl in either the opened SCL design model or fresh live discovery. +/// +/// A DataSetReference on a browsed/runtime alias is also not sufficient authority: several /// aliases can point at the same static FCDA/FCD member (for example cVal/instCVal or -/// structured measurement descendants). Static mode must select one presentation row -/// for each engine-authoritative DataSet membership, preserving the literal member -/// identity that appears in SCL/ARIEC. +/// structured measurement descendants). Static mode selects one presentation row for each +/// engine-authoritative membership, preserving the literal member identity from SCL/ARIEC. /// public static class Iec61850StaticDataSetAuthoritySelection { @@ -22,60 +27,102 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) if (model is null) return new HashSet(ReferenceEqualityComparer.Instance); + var reportBackedDataSets = BuildReportBackedDataSetReferences(device); + if (reportBackedDataSets.Count == 0) + return new HashSet(ReferenceEqualityComparer.Instance); + var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model); var selected = new HashSet(ReferenceEqualityComparer.Instance); var signals = device.Signals.ToArray(); foreach (var descriptor in mandatory) { - var membership = descriptor.DataSetMemberships + // A descriptor may carry more than one membership. Do not arbitrarily take the + // first DataSet: choose only literal memberships that are backed by configured + // report-control authority. + var memberships = descriptor.DataSetMemberships + .Where(item => reportBackedDataSets.Contains(NormalizeLiteral(item.DataSetReference))) .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.MemberIndex) - .FirstOrDefault(); - if (membership is null) - continue; - - var memberReference = FirstNonEmpty( - membership.CanonicalMemberReference, - membership.OriginalMemberReference, - descriptor.DesignReference, - descriptor.ObservedReference, - descriptor.PrimaryValueReference); - if (string.IsNullOrWhiteSpace(memberReference) || - string.IsNullOrWhiteSpace(membership.DataSetReference)) - continue; - - var candidates = signals - .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) - .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) - .Where(signal => LiteralEquals(signal.DisplayReference, memberReference)) .ToArray(); - if (candidates.Length == 0) - continue; - - // Prefer the runtime row already bound to ARIEC's resolved primary value. - // For an unresolved structured member, the inventory-created exact static row - // wins over a generic browsed alias. Never choose by fuzzy/prefix matching. - var chosen = candidates - .OrderByDescending(signal => - !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) && - LiteralEquals(signal.ObjectReference, descriptor.PrimaryValueReference)) - .ThenByDescending(signal => - (signal.Source ?? string.Empty).Contains( - "mandatory static DataSet member", - StringComparison.OrdinalIgnoreCase)) - .ThenByDescending(signal => - string.Equals(signal.Category, "DataSet", StringComparison.OrdinalIgnoreCase)) - .ThenByDescending(signal => - string.Equals(signal.Confidence, "High", StringComparison.OrdinalIgnoreCase)) - .First(); - - selected.Add(chosen); + + foreach (var membership in memberships) + { + var memberReference = FirstNonEmpty( + membership.CanonicalMemberReference, + membership.OriginalMemberReference, + descriptor.DesignReference, + descriptor.ObservedReference, + descriptor.PrimaryValueReference); + if (string.IsNullOrWhiteSpace(memberReference) || + string.IsNullOrWhiteSpace(membership.DataSetReference)) + { + continue; + } + + var candidates = signals + .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) + .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) + .Where(signal => LiteralEquals(signal.DisplayReference, memberReference)) + .ToArray(); + if (candidates.Length == 0) + continue; + + // Prefer the runtime row already bound to ARIEC's resolved primary value. + // For an unresolved structured member, the inventory-created exact static row + // wins over a generic browsed alias. Never choose by fuzzy/prefix matching. + var chosen = candidates + .OrderByDescending(signal => + !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) && + LiteralEquals(signal.ObjectReference, descriptor.PrimaryValueReference)) + .ThenByDescending(signal => + (signal.Source ?? string.Empty).Contains( + "mandatory static DataSet member", + StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(signal => + string.Equals(signal.Category, "DataSet", StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(signal => + string.Equals(signal.Confidence, "High", StringComparison.OrdinalIgnoreCase)) + .First(); + + selected.Add(chosen); + } } return selected; } + /// + /// Returns the literal DataSet references that have configured report-control authority. + /// The union is deliberate: fast SCL reconnect can have a richer design model than the + /// partial live model, while a full discovery can reveal additional valid live RCB + /// bindings. Neither source is allowed to erase the other's exact configured evidence. + /// + public static IReadOnlySet BuildReportBackedDataSetReferences(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + AddReportBackedDataSets(device.SclWorkspace?.DesignModel, result); + AddReportBackedDataSets(device.LiveDiscoveryModel, result); + return result; + } + + private static void AddReportBackedDataSets( + LiveIedModelDiscoveryDocument? model, + HashSet target) + { + if (model is null) + return; + + foreach (var report in model.ReportControls) + { + var dataSetReference = NormalizeLiteral(report.DataSetReference); + if (!string.IsNullOrWhiteSpace(dataSetReference)) + target.Add(dataSetReference); + } + } + private static bool LiteralEquals(string? left, string? right) => string.Equals(NormalizeLiteral(left), NormalizeLiteral(right), StringComparison.OrdinalIgnoreCase); From cc60008ecd16eb73ab7b5b0e148d5666fc2128c9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:48:57 +0700 Subject: [PATCH 02/48] fix: resolve indexed static RCB instances safely --- Services/Iec61850StaticRcbReferenceMatcher.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 Services/Iec61850StaticRcbReferenceMatcher.cs diff --git a/Services/Iec61850StaticRcbReferenceMatcher.cs b/Services/Iec61850StaticRcbReferenceMatcher.cs new file mode 100644 index 00000000..8adc6a7c --- /dev/null +++ b/Services/Iec61850StaticRcbReferenceMatcher.cs @@ -0,0 +1,74 @@ +namespace ArIED61850Tester.Services; + +/// +/// Matches one configured SCL ReportControl declaration to the concrete RCB object exposed +/// by the live MMS server. +/// +/// IEC 61850 SCL may describe one indexed ReportControl family while the server exposes +/// concrete instances such as Buffer01 / Buffer02. Exact identity always wins. The only +/// accepted fallback is a decimal indexed instance of the same literal RCB family; arbitrary +/// same-DataSet RCBs are never treated as substitutes. +/// +public static class Iec61850StaticRcbReferenceMatcher +{ + public static bool IsExact(string? configuredReference, string? liveReference) + => string.Equals( + Normalize(configuredReference), + Normalize(liveReference), + StringComparison.OrdinalIgnoreCase); + + public static bool IsConfiguredOrIndexedInstance(string? configuredReference, string? liveReference) + { + var configured = Normalize(configuredReference); + var live = Normalize(liveReference); + if (string.IsNullOrWhiteSpace(configured) || string.IsNullOrWhiteSpace(live)) + return false; + if (string.Equals(configured, live, StringComparison.OrdinalIgnoreCase)) + return true; + + SplitLeaf(configured, out var configuredParent, out var configuredLeaf); + SplitLeaf(live, out var liveParent, out var liveLeaf); + if (!string.Equals(configuredParent, liveParent, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(configuredLeaf) || + string.IsNullOrWhiteSpace(liveLeaf)) + { + return false; + } + + // If the configured declaration already ends in a digit, treat it as a concrete + // object and require exact identity. This avoids accidental prefix matches such as + // Buffer0 -> Buffer01. + if (char.IsDigit(configuredLeaf[^1]) || + !liveLeaf.StartsWith(configuredLeaf, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var suffix = liveLeaf[configuredLeaf.Length..]; + return suffix.Length > 0 && suffix.All(char.IsDigit); + } + + public static int MatchRank(string? configuredReference, string? liveReference) + { + if (IsExact(configuredReference, liveReference)) + return 0; + return IsConfiguredOrIndexedInstance(configuredReference, liveReference) ? 1 : int.MaxValue; + } + + public static string Normalize(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); + + private static void SplitLeaf(string reference, out string parent, out string leaf) + { + var separator = reference.LastIndexOf('.'); + if (separator < 0) + { + parent = string.Empty; + leaf = reference; + return; + } + + parent = reference[..separator]; + leaf = reference[(separator + 1)..]; + } +} From 20e30e7105b1977ba30af2f2fd3718c9e2730539 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:50:30 +0700 Subject: [PATCH 03/48] fix: preserve SCL RCB authority and resolve indexed live instances --- ...veIec61850Client.StaticDataSetReporting.cs | 101 +++++++++++++++--- 1 file changed, 84 insertions(+), 17 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index 68cdcd9b..8523f261 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -1,3 +1,4 @@ +using AR.Iec61850.Discovery; using ArIED61850Tester.Models; using ArMms = AR.Iec61850.Mms; @@ -9,9 +10,10 @@ namespace ArIED61850Tester.Services; /// Static mode already has complete configuration authority in the opened/live SCL model: /// exact DataSet membership and configured RCB -> DataSet bindings. Do not run those facts /// through the adaptive Hybrid acquisition planner. The live association is used only to -/// verify that the exact configured RCB and exact DataSet directory exist, then ARIEC's -/// persistent monitor installs the InformationReport receiver, enables RptEna and requests -/// GI. No dynamic DataSet write and no cyclic process-value MMS read is permitted here. +/// verify that the configured RCB (or a concrete indexed instance of that configured family) +/// and exact DataSet directory exist, then ARIEC's persistent monitor installs the +/// InformationReport receiver, enables RptEna and requests GI. No dynamic DataSet write and +/// no cyclic process-value MMS read is permitted here. /// public sealed partial class NativeIec61850Client { @@ -30,8 +32,8 @@ public async Task BuildStaticDataSetReportPlan _deterministicStaticSubscriptions.Clear(); ResetSemanticReportProjectionContext(); - var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; - if (model is null) + var projectionModel = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + if (projectionModel is null) { return StaticPlanningUnavailable( points, @@ -45,7 +47,22 @@ public async Task BuildStaticDataSetReportPlan $"Static DataSet report-only requires an initiated MMS association. Current state: {_session.State}."); } - SetSemanticReportProjectionAuthority(model); + SetSemanticReportProjectionAuthority(projectionModel); + + // Keep SCL design configuration authoritative even when a partial live model exists. + // Fast/cached reconnects can populate LiveDiscoveryModel without reproducing every + // configured ReportControl. Selecting `LiveDiscoveryModel ?? DesignModel` for RCB + // configuration silently erased valid SCL evidence (field symptom: SCL saw Buffer02 + // for Digital, while runtime planning reported staticBRCB=0). The union below keeps + // exact design authority and augments it with any report controls learned live. + var configurationModels = new[] + { + device.SclWorkspace?.DesignModel, + device.LiveDiscoveryModel + } + .Where(model => model is not null) + .Cast() + .ToArray(); // Fresh report discovery is verification, not permission policy. In particular we // deliberately do NOT classify a configured BRCB through the Hybrid availability @@ -76,8 +93,13 @@ public async Task BuildStaticDataSetReportPlan { cancellationToken.ThrowIfCancellationRequested(); - var configuredReports = model.ReportControls + var configuredReports = configurationModels + .SelectMany(configurationModel => configurationModel.ReportControls) .Where(report => SameStaticReference(report.DataSetReference, dataSetGroup.Key)) + .GroupBy( + report => $"{NormalizeStaticReference(report.Reference)}|{NormalizeStaticReference(report.DataSetReference)}", + StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) .OrderByDescending(report => report.Buffered) .ThenBy(report => report.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -85,7 +107,7 @@ public async Task BuildStaticDataSetReportPlan if (configuredReports.Length == 0) { warnings.Add( - $"{dataSetGroup.First().DataSetReference}: no configured BRCB/URCB in the SCL model; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); + $"{dataSetGroup.First().DataSetReference}: no configured BRCB/URCB in the SCL/live model union; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); continue; } @@ -96,19 +118,56 @@ public async Task BuildStaticDataSetReportPlan } var configured = configuredReports[0]; + var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); + + // SCL can represent an indexed ReportControl family while the MMS server exposes + // concrete instances (for example Buffer -> Buffer01/Buffer02). Exact identity + // remains rank 0. The only fallback accepted here is a decimal indexed instance + // of that same literal RCB family and the same DataSet; arbitrary same-DataSet + // RCB substitution is forbidden. var liveCandidates = discovery.ReportInventory.ReportControls - .Where(candidate => SameStaticReference(candidate.Reference, configured.Reference)) + .Select(candidate => new + { + Candidate = candidate, + Rank = Iec61850StaticRcbReferenceMatcher.MatchRank(configured.Reference, candidate.Reference) + }) + .Where(item => item.Rank != int.MaxValue) + .Where(item => + string.IsNullOrWhiteSpace(item.Candidate.DataSetReference) || + SameStaticReference(item.Candidate.DataSetReference, dataSetReference)) + .OrderBy(item => item.Rank) + .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); - if (liveCandidates.Length != 1) + if (liveCandidates.Length == 0) { + var sameDataSet = discovery.ReportInventory.ReportControls + .Where(candidate => + !string.IsNullOrWhiteSpace(candidate.DataSetReference) && + SameStaticReference(candidate.DataSetReference, dataSetReference)) + .Select(candidate => candidate.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase) + .Take(8) + .ToArray(); + var observed = sameDataSet.Length == 0 + ? "none" + : string.Join(", ", sameDataSet); warnings.Add( - $"{configured.Reference}: configured RCB was proven by SCL but exact live MMS discovery returned {liveCandidates.Length} matching RCB object(s). Static mode refused to guess and did not poll process values."); + $"{configured.Reference}: configured RCB was proven by SCL but live MMS discovery exposed no exact/indexed-family instance. Same-DataSet live RCBs: {observed}. Static mode refused arbitrary substitution and did not poll process values."); continue; } - var liveRcb = CloneReportControlForPlanning(liveCandidates[0]); - var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); + var bestRank = liveCandidates[0].Rank; + var bestCandidates = liveCandidates.Where(item => item.Rank == bestRank).ToArray(); + if (bestCandidates.Length > 1) + { + warnings.Add( + $"{configured.Reference}: configured indexed RCB family matched {bestCandidates.Length} concrete live instance(s); selected {bestCandidates[0].Candidate.Reference} by literal instance order. No unrelated RCB was substituted."); + } + + var liveSource = bestCandidates[0].Candidate; + var liveRcb = CloneReportControlForPlanning(liveSource); if (!string.IsNullOrWhiteSpace(liveRcb.DataSetReference) && !SameStaticReference(liveRcb.DataSetReference, dataSetReference)) @@ -149,13 +208,16 @@ public async Task BuildStaticDataSetReportPlan if (bindings.Count == 0) continue; + var concreteReportReference = string.IsNullOrWhiteSpace(liveRcb.Reference) + ? configured.Reference + : liveRcb.Reference; var plan = new ReportControlPlan { RelayId = device.DeviceId, RelayName = device.Name, RelayIpAddress = device.IpAddress, IedName = device.Name, - ReportControlReference = configured.Reference, + ReportControlReference = concreteReportReference, DataSetReference = dataSetReference, Mode = "Static DataSet • deterministic configured RCB", AllowDynamicDataSetWrites = false, @@ -171,7 +233,12 @@ public async Task BuildStaticDataSetReportPlan }; var subscriptionWarnings = new List(); - if (string.IsNullOrWhiteSpace(liveCandidates[0].DataSetReference)) + if (!Iec61850StaticRcbReferenceMatcher.IsExact(configured.Reference, concreteReportReference)) + { + subscriptionWarnings.Add( + $"SCL ReportControl family {configured.Reference} resolved to concrete live indexed instance {concreteReportReference}."); + } + if (string.IsNullOrWhiteSpace(liveSource.DataSetReference)) { subscriptionWarnings.Add( "Live RCB DatSet text was not returned; exact SCL RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority."); @@ -187,7 +254,7 @@ public async Task BuildStaticDataSetReportPlan DynamicPoints = Array.Empty(), Steps = new[] { - $"Verify exact configured RCB {configured.Reference} exists on the live association.", + $"Verify configured RCB {configured.Reference} as live object {concreteReportReference}.", $"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).", "Install InformationReport receiver before enabling the RCB.", "Write RptEna=true, then request GI=true.", @@ -391,7 +458,7 @@ private static bool SameStaticReference(string? left, string? right) StringComparison.OrdinalIgnoreCase); private static string NormalizeStaticReference(string? reference) - => (reference ?? string.Empty).Trim().Replace('$', '.'); + => Iec61850StaticRcbReferenceMatcher.Normalize(reference); private static int ParseStaticInteger(string? value) => int.TryParse(value, out var parsed) && parsed > 0 ? parsed : 0; From 2830f3a4a1aae9027e4e97903e9999479ff03439 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:51:04 +0700 Subject: [PATCH 04/48] test: guard RCB-backed static selection and indexed instances --- ...ticDataSetReportOnlyModeRegressionTests.cs | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 50a41a62..138e3f78 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -40,7 +40,7 @@ public void RuntimeContract_StaticDataSetMode_DoesNotScheduleCyclicMmsProcessPol } [Fact] - public void SharedSclStaticSelection_UsesExactAriecMembershipRows_NotEveryDatasetTaggedAlias() + public void SharedSclStaticSelection_UsesOnlyExactRcbBackedAriecMembershipRows() { var source = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); var authority = File.ReadAllText(FindRepoFile("Services/Iec61850StaticDataSetAuthoritySelection.cs")); @@ -54,12 +54,56 @@ public void SharedSclStaticSelection_UsesExactAriecMembershipRows_NotEveryDatase Assert.DoesNotContain("fallback remains available", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)", authority, StringComparison.Ordinal); + Assert.Contains("BuildReportBackedDataSetReferences(device)", authority, StringComparison.Ordinal); + Assert.Contains("reportBackedDataSets.Contains", authority, StringComparison.Ordinal); + Assert.Contains("device.SclWorkspace?.DesignModel", authority, StringComparison.Ordinal); + Assert.Contains("device.LiveDiscoveryModel", authority, StringComparison.Ordinal); Assert.Contains("LiteralEquals(signal.DataSetReference, membership.DataSetReference)", authority, StringComparison.Ordinal); Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", authority, StringComparison.Ordinal); Assert.DoesNotContain("StartsWith(memberReference", authority, StringComparison.Ordinal); Assert.DoesNotContain("Contains(memberReference", authority, StringComparison.Ordinal); } + [Theory] + [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Buffer01", true)] + [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Buffer02", true)] + [InlineData("IEDApplication/LLN0$BR$Buffer02", "IEDApplication/LLN0$BR$Buffer02", true)] + [InlineData("IEDApplication/LLN0$BR$Buffer02", "IEDApplication/LLN0$BR$Buffer01", false)] + [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Other01", false)] + [InlineData("IEDApplication/LLN0$BR$Buffer0", "IEDApplication/LLN0$BR$Buffer01", false)] + public void StaticRcbMatcher_AcceptsOnlyExactOrDecimalIndexedFamilyInstances( + string configured, + string live, + bool expected) + { + Assert.Equal(expected, Iec61850StaticRcbReferenceMatcher.IsConfiguredOrIndexedInstance(configured, live)); + } + + [Fact] + public void StaticRcbMatcher_ExactIdentityAlwaysRanksBeforeIndexedFamily() + { + const string configured = "IEDApplication/LLN0$BR$Buffer"; + Assert.Equal(0, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, configured)); + Assert.Equal(1, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, "IEDApplication/LLN0$BR$Buffer01")); + Assert.Equal(int.MaxValue, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, "IEDApplication/LLN0$BR$Other01")); + } + + [Fact] + public void DeterministicStaticPlanner_PreservesSclConfigurationAndConcreteLiveRcbAuthority() + { + var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.StaticDataSetReporting.cs")); + + Assert.Contains("device.SclWorkspace?.DesignModel", source, StringComparison.Ordinal); + Assert.Contains("device.LiveDiscoveryModel", source, StringComparison.Ordinal); + Assert.Contains("configurationModels", source, StringComparison.Ordinal); + Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank", source, StringComparison.Ordinal); + Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); + Assert.Contains("Install InformationReport receiver before enabling the RCB", source, StringComparison.Ordinal); + Assert.Contains("Write RptEna=true, then request GI=true", source, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal); + Assert.DoesNotContain("PollingPointKeys = points.Select", source, StringComparison.Ordinal); + } + [Fact] public void StaticDataSetRegressionGuard_PreservesFa16ReportOnlyDirection() { @@ -103,8 +147,6 @@ public void SharedSclFat_IsConsumerOfExistingAcquisitionSession_NotASecondMonito Assert.Contains("if (!reuseSharedSclAcquisition)", source, StringComparison.Ordinal); Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal); - // The legacy helper may remain for workbook-only FAT, but the shared SCL branch - // must be structurally separate and must start through the normal Engineering owner. var sharedBranchStart = source.IndexOf("if (reuseSharedSclAcquisition)", StringComparison.Ordinal); var legacyBranchStart = source.IndexOf("Compatibility path for non-shared/legacy FAT only", StringComparison.Ordinal); Assert.True(sharedBranchStart >= 0 && legacyBranchStart > sharedBranchStart); From 02c51cb3aa7a5900b134f6168b31f296c69caba6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:52:31 +0700 Subject: [PATCH 05/48] fix: union SCL and live static membership authority --- ...Iec61850StaticDataSetAuthoritySelection.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index c97002b7..4140e552 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -23,15 +23,28 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) { ArgumentNullException.ThrowIfNull(device); - var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; - if (model is null) + // Do not let a partial live model erase richer opened-SCL membership evidence. This + // is the same authority rule used for configured RCBs: design + live are additive, + // while the live MMS association is still required later to verify/arm acquisition. + var authorityModels = new[] + { + device.SclWorkspace?.DesignModel, + device.LiveDiscoveryModel + } + .Where(model => model is not null) + .Cast() + .Distinct() + .ToArray(); + if (authorityModels.Length == 0) return new HashSet(ReferenceEqualityComparer.Instance); var reportBackedDataSets = BuildReportBackedDataSetReferences(device); if (reportBackedDataSets.Count == 0) return new HashSet(ReferenceEqualityComparer.Instance); - var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model); + var mandatory = authorityModels + .SelectMany(Iec61850DataSetSignalInventoryProjection.GetMandatorySignals) + .ToArray(); var selected = new HashSet(ReferenceEqualityComparer.Instance); var signals = device.Signals.ToArray(); From f9633fbf569941efe2b0214be466a613e69ae501 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:55:45 +0700 Subject: [PATCH 06/48] fix: make static authority projection compile explicitly --- Services/Iec61850StaticDataSetAuthoritySelection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index 4140e552..363fc82b 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -43,7 +43,7 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) return new HashSet(ReferenceEqualityComparer.Instance); var mandatory = authorityModels - .SelectMany(Iec61850DataSetSignalInventoryProjection.GetMandatorySignals) + .SelectMany(model => Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)) .ToArray(); var selected = new HashSet(ReferenceEqualityComparer.Instance); var signals = device.Signals.ToArray(); From 3e1141eab6e34bef6308134fa0ca833517f53322 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:56:24 +0700 Subject: [PATCH 07/48] fix: keep opened SCL DataSet inventory authoritative across reconnect --- Services/Iec61850DataSetSignalInventoryService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Services/Iec61850DataSetSignalInventoryService.cs b/Services/Iec61850DataSetSignalInventoryService.cs index a4476b3c..705079fa 100644 --- a/Services/Iec61850DataSetSignalInventoryService.cs +++ b/Services/Iec61850DataSetSignalInventoryService.cs @@ -26,11 +26,11 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( { ArgumentNullException.ThrowIfNull(device); - // Signal Selection is also opened directly from an offline CID/SCD workspace. - // In that workflow LiveDiscoveryModel is intentionally null; the SCL design model - // is the authoritative inventory and must not be ignored. Prefer the live model - // only after a real association/discovery has produced one. - var authoritativeModel = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + // When an SCL workspace is open, its configured DataSet membership remains the + // engineering authority across cached/fast reconnects. A partial live discovery + // model must not erase FCDA/FCD rows that were proven by the opened CID/SCD. For an + // online-only IED with no SCL workspace, live discovery remains the authority. + var authoritativeModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; if (authoritativeModel is null) return EmptyResult(); From 944e0e4069447f38c5d403aa141c308f2aa29d15 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:01:14 +0700 Subject: [PATCH 08/48] test: preserve SCL DataSet authority across fast reconnect --- .../OfflineDataSetSignalSelectionRegressionTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index b9d397a6..03be0935 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -6,14 +6,18 @@ namespace ARSAS.Tests; public sealed class OfflineDataSetSignalSelectionRegressionTests { [Fact] - public void DeviceInventoryMerge_FallsBackToOfflineSclDesignModel() + public void DeviceInventoryMerge_PreservesOpenedSclAuthorityAcrossFastReconnect() { var source = File.ReadAllText(FindRepoFile("Services/Iec61850DataSetSignalInventoryService.cs")); Assert.Contains( - "device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel", + "device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", source, StringComparison.Ordinal); + Assert.Contains( + "partial live discovery", + source, + StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain( "if (device.LiveDiscoveryModel is null)\n return EmptyResult();", source, From 7e1faac0197a7d3e2ce8647744f1179ff2a4f61f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:01:32 +0700 Subject: [PATCH 09/48] test: update deterministic static RCB authority guards --- .../DeterministicStaticReportPathRegressionTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index af8ab324..01c2e359 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -8,7 +8,8 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("Deterministic Static DataSet configured-RCB path", source, StringComparison.Ordinal); - Assert.Contains("model.ReportControls", source, StringComparison.Ordinal); + Assert.Contains("configurationModels", source, StringComparison.Ordinal); + Assert.Contains("configurationModel.ReportControls", source, StringComparison.Ordinal); Assert.Contains("discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); Assert.Contains("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); Assert.Contains("MmsReportSubscriptionPlanStatus.ReadyRequiresWrite", source, StringComparison.Ordinal); @@ -24,17 +25,19 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() } [Fact] - public void StaticPath_RequiresExactConfiguredRcbAndOrderedLiveDataSetDirectory() + public void StaticPath_RequiresConfiguredRcbFamilyAndOrderedLiveDataSetDirectory() { var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("SameStaticReference(report.DataSetReference, dataSetGroup.Key)", source, StringComparison.Ordinal); - Assert.Contains("SameStaticReference(candidate.Reference, configured.Reference)", source, StringComparison.Ordinal); + Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank(configured.Reference, candidate.Reference)", source, StringComparison.Ordinal); + Assert.Contains("arbitrary same-DataSet", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("live DataSet directory could not prove an ordered non-empty member list", source, StringComparison.Ordinal); Assert.Contains("directory.Members", source, StringComparison.Ordinal); Assert.Contains("No MMS process polling was substituted", source, StringComparison.Ordinal); Assert.Contains("SCL binds", source, StringComparison.Ordinal); Assert.Contains("live DatSet reports", source, StringComparison.Ordinal); + Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); } [Fact] From 29fee6f7087ecaf39c71a984263d256026480b15 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:47:09 +0700 Subject: [PATCH 10/48] fix(static-dataset): pin receiver-first ARIEC report activation --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 5d8014d0..7a514e3b 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc", - "sourcePullRequest": 111, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling." + "commit": "4e19516b9ec80ba7fb6d3573014a2399a1188451", + "sourcePullRequest": 112, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling. PR #112 closes the StaticDataSet startup race: the persistent InformationReport receiver is registered before RptEna=true and GI=true can trigger a fast relay report; failed startup keeps the receiver registered while best-effort disabling RptEna and releasing URCB reservation before unregistering, and this receiver-first static path never defines, deletes, or rebinds a DataSet." } From 689925b4e23c9dd08e0e6331ef9cf75bb074afdb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:47:38 +0700 Subject: [PATCH 11/48] test(static-dataset): guard receiver-first engine pin --- .../OfflineDataSetSignalSelectionRegressionTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index 03be0935..ae5c0281 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -82,7 +82,7 @@ public void EngineLock_PreservesReportProjectionAndP62BHistoryAcrossLaterEngineP Assert.Equal("masarray/ARIEC61850", root.GetProperty("repository").GetString()); Assert.Equal("main", root.GetProperty("ref").GetString()); Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty); - Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 89); + Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 112); Assert.Contains("one descriptor per static DataSet member", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("generic Boolean status structures", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase); @@ -104,6 +104,10 @@ public void EngineLock_PreservesReportProjectionAndP62BHistoryAcrossLaterEngineP Assert.Contains("instMag/mag", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("instCVal/cVal", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("ambiguous structures remain raw", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #112", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("receiver is registered before RptEna=true and GI=true", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("before unregistering", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("never defines, deletes, or rebinds a DataSet", source, StringComparison.OrdinalIgnoreCase); } [Fact] From 8e620be9e0d0e6d44712f6dcd4b49360c6b494c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:52:47 +0700 Subject: [PATCH 12/48] fix(static-dataset): choose non-occupied indexed RCB instance --- ...veIec61850Client.StaticDataSetReporting.cs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index 8523f261..55955dae 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -125,7 +125,7 @@ public async Task BuildStaticDataSetReportPlan // remains rank 0. The only fallback accepted here is a decimal indexed instance // of that same literal RCB family and the same DataSet; arbitrary same-DataSet // RCB substitution is forbidden. - var liveCandidates = discovery.ReportInventory.ReportControls + var matchedLiveCandidates = discovery.ReportInventory.ReportControls .Select(candidate => new { Candidate = candidate, @@ -139,8 +139,32 @@ public async Task BuildStaticDataSetReportPlan .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); + // An indexed family can expose several concrete RCBs. Literal instance order is + // not an activation policy: Buffer01 may already be enabled/reserved while + // Buffer02 is the usable instance. Reject only explicit occupancy; unknown state + // remains eligible because some relays omit reservation metadata. Among equally + // matched candidates prefer an explicitly disabled RCB, then stable literal order. + var liveCandidates = matchedLiveCandidates + .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)) + .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)) + .OrderBy(item => item.Rank) + .ThenByDescending(item => ArMms.MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)) + .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (liveCandidates.Length == 0) { + if (matchedLiveCandidates.Length > 0) + { + var occupied = string.Join( + ", ", + matchedLiveCandidates.Take(8).Select(item => + $"{item.Candidate.Reference}[RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]")); + warnings.Add( + $"{configured.Reference}: exact/indexed-family RCB objects were found, but every concrete instance was explicitly enabled or reserved ({occupied}). Static mode will not steal an occupied RCB and did not poll process values."); + continue; + } + var sameDataSet = discovery.ReportInventory.ReportControls .Where(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference) && @@ -163,7 +187,7 @@ public async Task BuildStaticDataSetReportPlan if (bestCandidates.Length > 1) { warnings.Add( - $"{configured.Reference}: configured indexed RCB family matched {bestCandidates.Length} concrete live instance(s); selected {bestCandidates[0].Candidate.Reference} by literal instance order. No unrelated RCB was substituted."); + $"{configured.Reference}: configured indexed RCB family matched {bestCandidates.Length} non-occupied concrete live instance(s); selected {bestCandidates[0].Candidate.Reference} by explicit-disabled preference then literal instance order. No unrelated RCB was substituted."); } var liveSource = bestCandidates[0].Candidate; From 163ae04d745539696eea1c5e397d05a1edf295b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:52:58 +0700 Subject: [PATCH 13/48] fix(static): make SCL report controls authoritative --- ...Iec61850StaticDataSetAuthoritySelection.cs | 46 ++++++++----------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index 363fc82b..5296d816 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -7,10 +7,11 @@ namespace ArIED61850Tester.Services; /// Builds the exact Static DataSet selection used by the report-only workflow. /// /// Static report-only mode is RCB-backed by definition. A static DataSet that is not -/// referenced by any configured BRCB/URCB is valid engineering inventory, but it is not -/// a live acquisition source and therefore must not inflate the monitor with permanently -/// unavailable rows. Selection is limited to exact DataSet memberships referenced by a -/// configured ReportControl in either the opened SCL design model or fresh live discovery. +/// referenced by an authoritative configured BRCB/URCB is valid engineering inventory, +/// but it is not a live acquisition source and therefore must not inflate the monitor with +/// permanently unavailable rows. When an SCL workspace is open, its ReportControl bindings +/// are the configuration authority; live discovery is verification only. For online-only +/// operation with no SCL workspace, live discovery becomes the configuration authority. /// /// A DataSetReference on a browsed/runtime alias is also not sufficient authority: several /// aliases can point at the same static FCDA/FCD member (for example cVal/instCVal or @@ -23,36 +24,26 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) { ArgumentNullException.ThrowIfNull(device); - // Do not let a partial live model erase richer opened-SCL membership evidence. This - // is the same authority rule used for configured RCBs: design + live are additive, - // while the live MMS association is still required later to verify/arm acquisition. - var authorityModels = new[] - { - device.SclWorkspace?.DesignModel, - device.LiveDiscoveryModel - } - .Where(model => model is not null) - .Cast() - .Distinct() - .ToArray(); - if (authorityModels.Length == 0) + // Opened SCL is the engineering authority. A partial or richer live model may verify + // the configuration later, but it must not introduce extra static memberships that + // were never configured in the opened CID/SCD. Online-only mode falls back to live. + var authorityModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + if (authorityModel is null) return new HashSet(ReferenceEqualityComparer.Instance); var reportBackedDataSets = BuildReportBackedDataSetReferences(device); if (reportBackedDataSets.Count == 0) return new HashSet(ReferenceEqualityComparer.Instance); - var mandatory = authorityModels - .SelectMany(model => Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)) - .ToArray(); + var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(authorityModel); var selected = new HashSet(ReferenceEqualityComparer.Instance); var signals = device.Signals.ToArray(); foreach (var descriptor in mandatory) { // A descriptor may carry more than one membership. Do not arbitrarily take the - // first DataSet: choose only literal memberships that are backed by configured - // report-control authority. + // first DataSet: choose only literal memberships that are backed by authoritative + // report-control configuration. var memberships = descriptor.DataSetMemberships .Where(item => reportBackedDataSets.Contains(NormalizeLiteral(item.DataSetReference))) .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) @@ -106,18 +97,17 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) } /// - /// Returns the literal DataSet references that have configured report-control authority. - /// The union is deliberate: fast SCL reconnect can have a richer design model than the - /// partial live model, while a full discovery can reveal additional valid live RCB - /// bindings. Neither source is allowed to erase the other's exact configured evidence. + /// Returns the literal DataSet references that have authoritative ReportControl backing. + /// Opened SCL configuration wins absolutely over extra live ReportControls. Live discovery + /// is used as configuration authority only when no SCL design model is open. /// public static IReadOnlySet BuildReportBackedDataSetReferences(Iec61850MonitorDevice device) { ArgumentNullException.ThrowIfNull(device); var result = new HashSet(StringComparer.OrdinalIgnoreCase); - AddReportBackedDataSets(device.SclWorkspace?.DesignModel, result); - AddReportBackedDataSets(device.LiveDiscoveryModel, result); + var configurationModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + AddReportBackedDataSets(configurationModel, result); return result; } From 2831ddb018e92cbfa49d208bb9a648cef07915ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:53:11 +0700 Subject: [PATCH 14/48] test(static-dataset): guard non-occupied indexed RCB selection --- ...DeterministicStaticReportPathRegressionTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index 01c2e359..3fe8d394 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -40,6 +40,20 @@ public void StaticPath_RequiresConfiguredRcbFamilyAndOrderedLiveDataSetDirectory Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); } + [Fact] + public void IndexedRcbFamily_PrefersNonOccupiedConcreteInstance() + { + var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); + + Assert.Contains("matchedLiveCandidates", source, StringComparison.Ordinal); + Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)", source, StringComparison.Ordinal); + Assert.Contains("MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)", source, StringComparison.Ordinal); + Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)", source, StringComparison.Ordinal); + Assert.Contains("every concrete instance was explicitly enabled or reserved", source, StringComparison.Ordinal); + Assert.Contains("Static mode will not steal an occupied RCB", source, StringComparison.Ordinal); + Assert.Contains("explicit-disabled preference then literal instance order", source, StringComparison.Ordinal); + } + [Fact] public void HybridEntryPoints_RouteStaticModeToDeterministicPath() { From 42477c08564df6cd739ed4773528619b3421a1f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:53:32 +0700 Subject: [PATCH 15/48] chore(static-dataset): keep physical candidate on proven PR111 engine --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 7a514e3b..5d8014d0 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "4e19516b9ec80ba7fb6d3573014a2399a1188451", - "sourcePullRequest": 112, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling. PR #112 closes the StaticDataSet startup race: the persistent InformationReport receiver is registered before RptEna=true and GI=true can trigger a fast relay report; failed startup keeps the receiver registered while best-effort disabling RptEna and releasing URCB reservation before unregistering, and this receiver-first static path never defines, deletes, or rebinds a DataSet." + "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc", + "sourcePullRequest": 111, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling." } From 68cfc8dda9f6f6e9c14353a7e26c51b54c421b14 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:54:01 +0700 Subject: [PATCH 16/48] test(static-dataset): keep engine lineage guard on PR111 candidate --- .../OfflineDataSetSignalSelectionRegressionTests.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index ae5c0281..03be0935 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -82,7 +82,7 @@ public void EngineLock_PreservesReportProjectionAndP62BHistoryAcrossLaterEngineP Assert.Equal("masarray/ARIEC61850", root.GetProperty("repository").GetString()); Assert.Equal("main", root.GetProperty("ref").GetString()); Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty); - Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 112); + Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 89); Assert.Contains("one descriptor per static DataSet member", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("generic Boolean status structures", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase); @@ -104,10 +104,6 @@ public void EngineLock_PreservesReportProjectionAndP62BHistoryAcrossLaterEngineP Assert.Contains("instMag/mag", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("instCVal/cVal", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("ambiguous structures remain raw", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("PR #112", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("receiver is registered before RptEna=true and GI=true", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("before unregistering", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("never defines, deletes, or rebinds a DataSet", source, StringComparison.OrdinalIgnoreCase); } [Fact] From 5bfddf9500090028f4657f9457d98281f87d603e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:55:24 +0700 Subject: [PATCH 17/48] fix(static): keep SCL RCB authority across all candidates --- ...veIec61850Client.StaticDataSetReporting.cs | 121 +++++++++--------- 1 file changed, 61 insertions(+), 60 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index 55955dae..23052403 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -32,7 +32,9 @@ public async Task BuildStaticDataSetReportPlan _deterministicStaticSubscriptions.Clear(); ResetSemanticReportProjectionContext(); - var projectionModel = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + // Opened SCL is the semantic and configuration authority. A live model is authoritative + // only for online-only operation where no CID/SCD workspace is open. + var projectionModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; if (projectionModel is null) { return StaticPlanningUnavailable( @@ -49,25 +51,20 @@ public async Task BuildStaticDataSetReportPlan SetSemanticReportProjectionAuthority(projectionModel); - // Keep SCL design configuration authoritative even when a partial live model exists. - // Fast/cached reconnects can populate LiveDiscoveryModel without reproducing every - // configured ReportControl. Selecting `LiveDiscoveryModel ?? DesignModel` for RCB - // configuration silently erased valid SCL evidence (field symptom: SCL saw Buffer02 - // for Digital, while runtime planning reported staticBRCB=0). The union below keeps - // exact design authority and augments it with any report controls learned live. - var configurationModels = new[] - { - device.SclWorkspace?.DesignModel, - device.LiveDiscoveryModel - } - .Where(model => model is not null) - .Cast() - .ToArray(); + // P0 authority rule: when an SCL workspace exists, only ReportControls from that + // design model may authorize static acquisition. Fresh live discovery is verification + // and concrete-instance evidence; it must never introduce a peer RCB that can displace + // an explicit SCL binding such as Digital -> Buffer02. + var configurationModel = projectionModel; + var configurationAuthorityLabel = device.SclWorkspace?.DesignModel is not null + ? "opened SCL design model" + : "live discovery model (online-only)"; // Fresh report discovery is verification, not permission policy. In particular we // deliberately do NOT classify a configured BRCB through the Hybrid availability // confidence gate. Some perfectly usable servers expose RptEna and DatSet but omit // enough reservation metadata for that adaptive gate to call the RCB 'Available'. + // Explicit enabled/reserved evidence is still used to avoid stealing an occupied RCB. var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false); if (discovery is null) { @@ -93,8 +90,8 @@ public async Task BuildStaticDataSetReportPlan { cancellationToken.ThrowIfCancellationRequested(); - var configuredReports = configurationModels - .SelectMany(configurationModel => configurationModel.ReportControls) + var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); + var configuredReports = configurationModel.ReportControls .Where(report => SameStaticReference(report.DataSetReference, dataSetGroup.Key)) .GroupBy( report => $"{NormalizeStaticReference(report.Reference)}|{NormalizeStaticReference(report.DataSetReference)}", @@ -107,35 +104,32 @@ public async Task BuildStaticDataSetReportPlan if (configuredReports.Length == 0) { warnings.Add( - $"{dataSetGroup.First().DataSetReference}: no configured BRCB/URCB in the SCL/live model union; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); + $"{dataSetReference}: no configured BRCB/URCB in the authoritative {configurationAuthorityLabel}; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); continue; } - if (configuredReports.Length > 1) - { - warnings.Add( - $"{dataSetGroup.First().DataSetReference}: {configuredReports.Length} configured RCBs reference this DataSet; deterministic Static mode selected {configuredReports[0].Reference} (BRCB preferred, then literal reference order)." ); - } - - var configured = configuredReports[0]; - var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); - - // SCL can represent an indexed ReportControl family while the MMS server exposes - // concrete instances (for example Buffer -> Buffer01/Buffer02). Exact identity - // remains rank 0. The only fallback accepted here is a decimal indexed instance - // of that same literal RCB family and the same DataSet; arbitrary same-DataSet - // RCB substitution is forbidden. - var matchedLiveCandidates = discovery.ReportInventory.ReportControls - .Select(candidate => new - { - Candidate = candidate, - Rank = Iec61850StaticRcbReferenceMatcher.MatchRank(configured.Reference, candidate.Reference) - }) - .Where(item => item.Rank != int.MaxValue) - .Where(item => - string.IsNullOrWhiteSpace(item.Candidate.DataSetReference) || - SameStaticReference(item.Candidate.DataSetReference, dataSetReference)) + // Evaluate every authoritative configured RCB. This is intentionally not + // configuredReports[0]: if several SCL ReportControls legitimately reference the + // same DataSet, one missing/occupied RCB must not hide another configured option. + // A configured family may resolve only to decimal indexed instances of that same + // literal family; arbitrary same-DataSet substitution remains forbidden. + var matchedLiveCandidates = configuredReports + .SelectMany(configured => discovery.ReportInventory.ReportControls + .Select(candidate => new + { + Configured = configured, + Candidate = candidate, + Rank = Iec61850StaticRcbReferenceMatcher.MatchRank( + configured.Reference, + candidate.Reference) + }) + .Where(item => item.Rank != int.MaxValue) + .Where(item => + string.IsNullOrWhiteSpace(item.Candidate.DataSetReference) || + SameStaticReference(item.Candidate.DataSetReference, dataSetReference))) .OrderBy(item => item.Rank) + .ThenByDescending(item => item.Configured.Buffered) + .ThenBy(item => item.Configured.Reference, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -143,12 +137,15 @@ public async Task BuildStaticDataSetReportPlan // not an activation policy: Buffer01 may already be enabled/reserved while // Buffer02 is the usable instance. Reject only explicit occupancy; unknown state // remains eligible because some relays omit reservation metadata. Among equally - // matched candidates prefer an explicitly disabled RCB, then stable literal order. + // matched candidates prefer exact identity, then BRCB, explicitly disabled state, + // and finally stable literal order. var liveCandidates = matchedLiveCandidates .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)) .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)) .OrderBy(item => item.Rank) + .ThenByDescending(item => item.Configured.Buffered) .ThenByDescending(item => ArMms.MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)) + .ThenBy(item => item.Configured.Reference, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -159,12 +156,16 @@ public async Task BuildStaticDataSetReportPlan var occupied = string.Join( ", ", matchedLiveCandidates.Take(8).Select(item => - $"{item.Candidate.Reference}[RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]")); + $"{item.Configured.Reference}->{item.Candidate.Reference}[RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]")); warnings.Add( - $"{configured.Reference}: exact/indexed-family RCB objects were found, but every concrete instance was explicitly enabled or reserved ({occupied}). Static mode will not steal an occupied RCB and did not poll process values."); + $"{dataSetReference}: exact/indexed-family RCB objects were found for authoritative configuration, but every concrete instance was explicitly enabled or reserved ({occupied}). Static mode will not steal an occupied RCB and did not poll process values."); continue; } + var configuredNames = string.Join(", ", configuredReports + .Select(report => report.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .Take(8)); var sameDataSet = discovery.ReportInventory.ReportControls .Where(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference) && @@ -178,32 +179,32 @@ public async Task BuildStaticDataSetReportPlan ? "none" : string.Join(", ", sameDataSet); warnings.Add( - $"{configured.Reference}: configured RCB was proven by SCL but live MMS discovery exposed no exact/indexed-family instance. Same-DataSet live RCBs: {observed}. Static mode refused arbitrary substitution and did not poll process values."); + $"{dataSetReference}: authoritative configured RCB(s) [{configuredNames}] had no exact/indexed-family live instance. Same-DataSet live RCBs: {observed}. Static mode refused arbitrary substitution and did not poll process values."); continue; } - var bestRank = liveCandidates[0].Rank; - var bestCandidates = liveCandidates.Where(item => item.Rank == bestRank).ToArray(); - if (bestCandidates.Length > 1) + var selected = liveCandidates[0]; + var configured = selected.Configured; + var liveSource = selected.Candidate; + var liveRcb = CloneReportControlForPlanning(liveSource); + + if (configuredReports.Length > 1 || liveCandidates.Length > 1) { warnings.Add( - $"{configured.Reference}: configured indexed RCB family matched {bestCandidates.Length} non-occupied concrete live instance(s); selected {bestCandidates[0].Candidate.Reference} by explicit-disabled preference then literal instance order. No unrelated RCB was substituted."); + $"{dataSetReference}: evaluated {configuredReports.Length} authoritative configured RCB(s) and {liveCandidates.Length} non-occupied exact/indexed live match(es); selected {configured.Reference} -> {liveSource.Reference}. {configurationAuthorityLabel} remained authoritative."); } - var liveSource = bestCandidates[0].Candidate; - var liveRcb = CloneReportControlForPlanning(liveSource); - if (!string.IsNullOrWhiteSpace(liveRcb.DataSetReference) && !SameStaticReference(liveRcb.DataSetReference, dataSetReference)) { warnings.Add( - $"{configured.Reference}: SCL binds {dataSetReference}, but live DatSet reports {liveRcb.DataSetReference}. Static mode refused the mismatch instead of guessing or polling."); + $"{configured.Reference}: authoritative configuration binds {dataSetReference}, but live DatSet reports {liveRcb.DataSetReference}. Static mode refused the mismatch instead of guessing or polling."); continue; } - // Missing live DatSet text is not treated as a reason to discard correct SCL - // configuration. The exact DataSet directory below is still required and is the - // ordered mapping authority for InformationReport values. + // Missing live DatSet text is not treated as a reason to discard correct + // authoritative configuration. The exact DataSet directory below is still required + // and is the ordered mapping authority for InformationReport values. if (string.IsNullOrWhiteSpace(liveRcb.DataSetReference)) liveRcb.DataSetReference = dataSetReference; @@ -260,12 +261,12 @@ public async Task BuildStaticDataSetReportPlan if (!Iec61850StaticRcbReferenceMatcher.IsExact(configured.Reference, concreteReportReference)) { subscriptionWarnings.Add( - $"SCL ReportControl family {configured.Reference} resolved to concrete live indexed instance {concreteReportReference}."); + $"Configured ReportControl family {configured.Reference} resolved to concrete live indexed instance {concreteReportReference}."); } if (string.IsNullOrWhiteSpace(liveSource.DataSetReference)) { subscriptionWarnings.Add( - "Live RCB DatSet text was not returned; exact SCL RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority."); + "Live RCB DatSet text was not returned; exact authoritative RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority."); } var subscription = new ArMms.MmsReportSubscriptionPlan @@ -278,7 +279,7 @@ public async Task BuildStaticDataSetReportPlan DynamicPoints = Array.Empty(), Steps = new[] { - $"Verify configured RCB {configured.Reference} as live object {concreteReportReference}.", + $"Verify authoritative configured RCB {configured.Reference} as live object {concreteReportReference}.", $"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).", "Install InformationReport receiver before enabling the RCB.", "Write RptEna=true, then request GI=true.", From e0a2edf54cdfb3f2024a54f214e17a3b0c981965 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:55:59 +0700 Subject: [PATCH 18/48] test(static): guard SCL authority and candidate evaluation --- ...ministicStaticReportPathRegressionTests.cs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index 3fe8d394..dd51cfc0 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -8,7 +8,7 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("Deterministic Static DataSet configured-RCB path", source, StringComparison.Ordinal); - Assert.Contains("configurationModels", source, StringComparison.Ordinal); + Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal); Assert.Contains("configurationModel.ReportControls", source, StringComparison.Ordinal); Assert.Contains("discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); Assert.Contains("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); @@ -18,6 +18,7 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() Assert.Contains("PollingPointKeys = Array.Empty()", source, StringComparison.Ordinal); Assert.Contains("PollingFallbackSignalCount = 0", source, StringComparison.Ordinal); + Assert.DoesNotContain("configurationModels", source, StringComparison.Ordinal); Assert.DoesNotContain("MmsCapabilityAwareHybridReportAcquisitionPlanner", source, StringComparison.Ordinal); Assert.DoesNotContain("BuildDynamicPlan", source, StringComparison.Ordinal); Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); @@ -30,16 +31,31 @@ public void StaticPath_RequiresConfiguredRcbFamilyAndOrderedLiveDataSetDirectory var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("SameStaticReference(report.DataSetReference, dataSetGroup.Key)", source, StringComparison.Ordinal); - Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank(configured.Reference, candidate.Reference)", source, StringComparison.Ordinal); + Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank", source, StringComparison.Ordinal); Assert.Contains("arbitrary same-DataSet", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("live DataSet directory could not prove an ordered non-empty member list", source, StringComparison.Ordinal); Assert.Contains("directory.Members", source, StringComparison.Ordinal); Assert.Contains("No MMS process polling was substituted", source, StringComparison.Ordinal); - Assert.Contains("SCL binds", source, StringComparison.Ordinal); + Assert.Contains("authoritative configuration binds", source, StringComparison.Ordinal); Assert.Contains("live DatSet reports", source, StringComparison.Ordinal); Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); } + [Fact] + public void StaticPath_SclAuthorityCannotBeDisplacedByLivePeerRcb() + { + var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); + + Assert.Contains("device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", source, StringComparison.Ordinal); + Assert.Contains("only ReportControls from that", source, StringComparison.Ordinal); + Assert.Contains("design model may authorize static acquisition", source, StringComparison.Ordinal); + Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal); + Assert.Contains("SelectMany(configured => discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); + Assert.DoesNotContain("SelectMany(configurationModel => configurationModel.ReportControls)", source, StringComparison.Ordinal); + Assert.DoesNotContain("configuredReports[0]", source, StringComparison.Ordinal); + Assert.Contains("remained authoritative", source, StringComparison.Ordinal); + } + [Fact] public void IndexedRcbFamily_PrefersNonOccupiedConcreteInstance() { @@ -51,7 +67,7 @@ public void IndexedRcbFamily_PrefersNonOccupiedConcreteInstance() Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)", source, StringComparison.Ordinal); Assert.Contains("every concrete instance was explicitly enabled or reserved", source, StringComparison.Ordinal); Assert.Contains("Static mode will not steal an occupied RCB", source, StringComparison.Ordinal); - Assert.Contains("explicit-disabled preference then literal instance order", source, StringComparison.Ordinal); + Assert.Contains("prefer exact identity, then BRCB, explicitly disabled state", source, StringComparison.Ordinal); } [Fact] From 1cfaa05ad8e45ea4862b76fbfb7c8b01f2678d9c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:56:31 +0700 Subject: [PATCH 19/48] test(static): enforce design-first RCB authority --- ...taticDataSetReportOnlyModeRegressionTests.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 138e3f78..07b0c330 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -53,11 +53,14 @@ public void SharedSclStaticSelection_UsesOnlyExactRcbBackedAriecMembershipRows() Assert.DoesNotContain("UseStaticDataSetWithMmsFallback", source, StringComparison.Ordinal); Assert.DoesNotContain("fallback remains available", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)", authority, StringComparison.Ordinal); + Assert.Contains("var authorityModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", authority, StringComparison.Ordinal); + Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(authorityModel)", authority, StringComparison.Ordinal); Assert.Contains("BuildReportBackedDataSetReferences(device)", authority, StringComparison.Ordinal); Assert.Contains("reportBackedDataSets.Contains", authority, StringComparison.Ordinal); - Assert.Contains("device.SclWorkspace?.DesignModel", authority, StringComparison.Ordinal); - Assert.Contains("device.LiveDiscoveryModel", authority, StringComparison.Ordinal); + Assert.Contains("var configurationModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", authority, StringComparison.Ordinal); + Assert.Contains("AddReportBackedDataSets(configurationModel, result)", authority, StringComparison.Ordinal); + Assert.DoesNotContain("AddReportBackedDataSets(device.SclWorkspace?.DesignModel, result)", authority, StringComparison.Ordinal); + Assert.DoesNotContain("AddReportBackedDataSets(device.LiveDiscoveryModel, result)", authority, StringComparison.Ordinal); Assert.Contains("LiteralEquals(signal.DataSetReference, membership.DataSetReference)", authority, StringComparison.Ordinal); Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", authority, StringComparison.Ordinal); Assert.DoesNotContain("StartsWith(memberReference", authority, StringComparison.Ordinal); @@ -93,13 +96,15 @@ public void DeterministicStaticPlanner_PreservesSclConfigurationAndConcreteLiveR { var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.StaticDataSetReporting.cs")); - Assert.Contains("device.SclWorkspace?.DesignModel", source, StringComparison.Ordinal); - Assert.Contains("device.LiveDiscoveryModel", source, StringComparison.Ordinal); - Assert.Contains("configurationModels", source, StringComparison.Ordinal); + Assert.Contains("device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", source, StringComparison.Ordinal); + Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal); Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank", source, StringComparison.Ordinal); + Assert.Contains("SelectMany(configured => discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); Assert.Contains("Install InformationReport receiver before enabling the RCB", source, StringComparison.Ordinal); Assert.Contains("Write RptEna=true, then request GI=true", source, StringComparison.Ordinal); + Assert.DoesNotContain("configurationModels", source, StringComparison.Ordinal); + Assert.DoesNotContain("configuredReports[0]", source, StringComparison.Ordinal); Assert.DoesNotContain("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal); Assert.DoesNotContain("PollingPointKeys = points.Select", source, StringComparison.Ordinal); } From c955d008d4a8dc34a0209ef15ba57fdbdd07f8c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:59:29 +0700 Subject: [PATCH 20/48] fix(static): include owner evidence in RCB selection --- ...veIec61850Client.StaticDataSetReporting.cs | 71 +++++++++++++++---- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index 23052403..c2239e42 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -61,10 +61,10 @@ public async Task BuildStaticDataSetReportPlan : "live discovery model (online-only)"; // Fresh report discovery is verification, not permission policy. In particular we - // deliberately do NOT classify a configured BRCB through the Hybrid availability - // confidence gate. Some perfectly usable servers expose RptEna and DatSet but omit - // enough reservation metadata for that adaptive gate to call the RCB 'Available'. - // Explicit enabled/reserved evidence is still used to avoid stealing an occupied RCB. + // deliberately do NOT require the adaptive Hybrid availability gate to classify a + // configured BRCB as Available. Some perfectly usable servers omit enough reservation + // metadata to remain Unknown. Explicit enabled/reserved/owner evidence is still used + // to avoid stealing an occupied RCB. var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false); if (discovery is null) { @@ -75,6 +75,11 @@ public async Task BuildStaticDataSetReportPlan : LastErrorMessage); } + var callerOwnedRcbReferences = _reportMonitorSessions.Values + .Select(session => NormalizeStaticReference(session.ReportControl.Reference)) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var reportPlans = new List(); var warnings = new List(); var coveredPointKeys = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -133,16 +138,37 @@ public async Task BuildStaticDataSetReportPlan .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); + // Use ARIEC's operational evaluator so Owner is part of the same occupancy + // decision as RptEna/Resv/ResvTms. Directory evidence is intentionally omitted at + // this stage: Unknown is still eligible and the exact DataSet directory is verified + // before the plan is armed. If a currently active session already owns the RCB, + // preserve that fact as UsedByCaller instead of misclassifying it as foreign use. + var evaluatedLiveCandidates = matchedLiveCandidates + .Select(item => new + { + item.Configured, + item.Candidate, + item.Rank, + Availability = ArMms.MmsRcbAvailabilityEvaluator.Evaluate( + item.Candidate, + dataSetDirectory: null, + callerOwned: callerOwnedRcbReferences.Contains( + NormalizeStaticReference(item.Candidate.Reference))) + }) + .ToArray(); + // An indexed family can expose several concrete RCBs. Literal instance order is - // not an activation policy: Buffer01 may already be enabled/reserved while - // Buffer02 is the usable instance. Reject only explicit occupancy; unknown state - // remains eligible because some relays omit reservation metadata. Among equally - // matched candidates prefer exact identity, then BRCB, explicitly disabled state, - // and finally stable literal order. - var liveCandidates = matchedLiveCandidates + // not an activation policy: Buffer01 may already be enabled/reserved/owned while + // Buffer02 is the usable instance. Reject only explicit operational blockers; + // Unknown remains eligible because some relays omit reservation metadata. Among + // equally matched candidates prefer exact identity, caller-owned/known-safe state, + // BRCB, explicitly disabled state, and finally stable literal order. + var liveCandidates = evaluatedLiveCandidates + .Where(item => StaticRcbAvailabilityRank(item.Availability.Availability) != int.MaxValue) .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)) .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)) .OrderBy(item => item.Rank) + .ThenBy(item => StaticRcbAvailabilityRank(item.Availability.Availability)) .ThenByDescending(item => item.Configured.Buffered) .ThenByDescending(item => ArMms.MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)) .ThenBy(item => item.Configured.Reference, StringComparer.OrdinalIgnoreCase) @@ -151,14 +177,14 @@ public async Task BuildStaticDataSetReportPlan if (liveCandidates.Length == 0) { - if (matchedLiveCandidates.Length > 0) + if (evaluatedLiveCandidates.Length > 0) { var occupied = string.Join( ", ", - matchedLiveCandidates.Take(8).Select(item => - $"{item.Configured.Reference}->{item.Candidate.Reference}[RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]")); + evaluatedLiveCandidates.Take(8).Select(item => + $"{item.Configured.Reference}->{item.Candidate.Reference}[availability={item.Availability.Availability}, RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]")); warnings.Add( - $"{dataSetReference}: exact/indexed-family RCB objects were found for authoritative configuration, but every concrete instance was explicitly enabled or reserved ({occupied}). Static mode will not steal an occupied RCB and did not poll process values."); + $"{dataSetReference}: exact/indexed-family RCB objects were found for authoritative configuration, but every concrete instance was explicitly unavailable/in-use ({occupied}). Static mode will not steal an occupied RCB and did not poll process values."); continue; } @@ -186,12 +212,13 @@ public async Task BuildStaticDataSetReportPlan var selected = liveCandidates[0]; var configured = selected.Configured; var liveSource = selected.Candidate; + var liveAvailability = selected.Availability; var liveRcb = CloneReportControlForPlanning(liveSource); if (configuredReports.Length > 1 || liveCandidates.Length > 1) { warnings.Add( - $"{dataSetReference}: evaluated {configuredReports.Length} authoritative configured RCB(s) and {liveCandidates.Length} non-occupied exact/indexed live match(es); selected {configured.Reference} -> {liveSource.Reference}. {configurationAuthorityLabel} remained authoritative."); + $"{dataSetReference}: evaluated {configuredReports.Length} authoritative configured RCB(s) and {liveCandidates.Length} non-occupied exact/indexed live match(es); selected {configured.Reference} -> {liveSource.Reference} ({liveAvailability.Availability}). {configurationAuthorityLabel} remained authoritative."); } if (!string.IsNullOrWhiteSpace(liveRcb.DataSetReference) && @@ -263,6 +290,11 @@ public async Task BuildStaticDataSetReportPlan subscriptionWarnings.Add( $"Configured ReportControl family {configured.Reference} resolved to concrete live indexed instance {concreteReportReference}."); } + if (liveAvailability.Availability == ArMms.MmsRcbOperationalAvailability.Unknown) + { + subscriptionWarnings.Add( + $"Live RCB availability for {concreteReportReference} remains Unknown ({liveAvailability.Reason}). Static mode permits this because identity/configuration are authoritative and no explicit in-use evidence was observed."); + } if (string.IsNullOrWhiteSpace(liveSource.DataSetReference)) { subscriptionWarnings.Add( @@ -476,6 +508,15 @@ private static NativeHybridReportPlanningResult StaticPlanningUnavailable( UncoveredSignalCount = points.Count }; + private static int StaticRcbAvailabilityRank(ArMms.MmsRcbOperationalAvailability availability) + => availability switch + { + ArMms.MmsRcbOperationalAvailability.UsedByCaller => 0, + ArMms.MmsRcbOperationalAvailability.Available => 1, + ArMms.MmsRcbOperationalAvailability.Unknown => 2, + _ => int.MaxValue + }; + private static bool SameStaticReference(string? left, string? right) => string.Equals( NormalizeStaticReference(left), From 9fd5d84ecf1b689c391f7b9c4ccab43ae6c8dc09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:00:30 +0700 Subject: [PATCH 21/48] test(static): cover owner-aware RCB occupancy --- .../DeterministicStaticReportPathRegressionTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index dd51cfc0..c5e570f8 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -62,12 +62,17 @@ public void IndexedRcbFamily_PrefersNonOccupiedConcreteInstance() var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("matchedLiveCandidates", source, StringComparison.Ordinal); + Assert.Contains("evaluatedLiveCandidates", source, StringComparison.Ordinal); + Assert.Contains("MmsRcbAvailabilityEvaluator.Evaluate", source, StringComparison.Ordinal); + Assert.Contains("callerOwnedRcbReferences", source, StringComparison.Ordinal); Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)", source, StringComparison.Ordinal); Assert.Contains("MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)", source, StringComparison.Ordinal); Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)", source, StringComparison.Ordinal); - Assert.Contains("every concrete instance was explicitly enabled or reserved", source, StringComparison.Ordinal); + Assert.Contains("StaticRcbAvailabilityRank", source, StringComparison.Ordinal); + Assert.Contains("Owner={item.Candidate.Owner}", source, StringComparison.Ordinal); + Assert.Contains("every concrete instance was explicitly unavailable/in-use", source, StringComparison.Ordinal); Assert.Contains("Static mode will not steal an occupied RCB", source, StringComparison.Ordinal); - Assert.Contains("prefer exact identity, then BRCB, explicitly disabled state", source, StringComparison.Ordinal); + Assert.Contains("prefer exact identity, caller-owned/known-safe state", source, StringComparison.Ordinal); } [Fact] From 3ce8b3af1ac4d87fe3439247860f42f66e88bf08 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:11:54 +0700 Subject: [PATCH 22/48] Fix static planner regression guard false positive --- .../ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index c5e570f8..ac7637b0 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -52,7 +52,7 @@ public void StaticPath_SclAuthorityCannotBeDisplacedByLivePeerRcb() Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal); Assert.Contains("SelectMany(configured => discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); Assert.DoesNotContain("SelectMany(configurationModel => configurationModel.ReportControls)", source, StringComparison.Ordinal); - Assert.DoesNotContain("configuredReports[0]", source, StringComparison.Ordinal); + Assert.DoesNotContain("var configured = configuredReports[0]", source, StringComparison.Ordinal); Assert.Contains("remained authoritative", source, StringComparison.Ordinal); } From 64587c3621c77893d4db1e5d1f081a6500dd9c37 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:12:32 +0700 Subject: [PATCH 23/48] Tighten static planner no-first-RCB regression guard --- tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 07b0c330..0e9e804b 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -104,7 +104,7 @@ public void DeterministicStaticPlanner_PreservesSclConfigurationAndConcreteLiveR Assert.Contains("Install InformationReport receiver before enabling the RCB", source, StringComparison.Ordinal); Assert.Contains("Write RptEna=true, then request GI=true", source, StringComparison.Ordinal); Assert.DoesNotContain("configurationModels", source, StringComparison.Ordinal); - Assert.DoesNotContain("configuredReports[0]", source, StringComparison.Ordinal); + Assert.DoesNotContain("var configured = configuredReports[0]", source, StringComparison.Ordinal); Assert.DoesNotContain("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal); Assert.DoesNotContain("PollingPointKeys = points.Select", source, StringComparison.Ordinal); } From 00a5dc8ee4ae3114e3b4a287aff54eb2fa092915 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:13:44 +0700 Subject: [PATCH 24/48] Fix P0 regression guard comment --- .../NativeIec61850Client.StaticDataSetReporting.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index c2239e42..32d5173a 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -113,11 +113,11 @@ public async Task BuildStaticDataSetReportPlan continue; } - // Evaluate every authoritative configured RCB. This is intentionally not - // configuredReports[0]: if several SCL ReportControls legitimately reference the - // same DataSet, one missing/occupied RCB must not hide another configured option. - // A configured family may resolve only to decimal indexed instances of that same - // literal family; arbitrary same-DataSet substitution remains forbidden. + // Evaluate every authoritative configured RCB instead of selecting only the first. + // If several SCL ReportControls legitimately reference the same DataSet, one + // missing/occupied RCB must not hide another configured option. A configured family + // may resolve only to decimal indexed instances of that same literal family; + // arbitrary same-DataSet substitution remains forbidden. var matchedLiveCandidates = configuredReports .SelectMany(configured => discovery.ReportInventory.ReportControls .Select(candidate => new From be61b069d5bf261770bb13bbe54067a3e4f7b524 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:30:09 +0700 Subject: [PATCH 25/48] Pin ARSAS to PR111 semantic projection hardening --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 5d8014d0..94c589a8 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc", + "commit": "0d7525bd330900917fb9f6d15a46059dc3d7a70a", "sourcePullRequest": 111, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling." + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values." } From aee3c34df3ca3126f4294575b4282a39387ba427 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:32:47 +0700 Subject: [PATCH 26/48] Align TotPF regression with PR111 P1 engine pin --- tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs index 3594d1ac..a82711b9 100644 --- a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs +++ b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs @@ -50,7 +50,10 @@ public void StaticReport_TotPf_UsesExactSemanticEngineAuthorityWithoutMmsFallbac var projection = Read("Services/StaticDataSetReportProjectionAccumulator.cs"); Assert.Contains("\"sourcePullRequest\": 111", engineLock, StringComparison.Ordinal); - Assert.Contains("69bfe70e2c779c7e8268af087bd1a3a38986c0fc", engineLock, StringComparison.Ordinal); + Assert.Contains("0d7525bd330900917fb9f6d15a46059dc3d7a70a", engineLock, StringComparison.Ordinal); + Assert.Contains("P1 hardening", engineLock, StringComparison.Ordinal); + Assert.Contains("report-value position", engineLock, StringComparison.Ordinal); + Assert.Contains("omits MemberReference", engineLock, StringComparison.Ordinal); Assert.Contains("MmsSemanticReportValueProjector.Project", semanticBridge, StringComparison.Ordinal); Assert.Contains("session.StaticReportProjection.Project", runtime, StringComparison.Ordinal); Assert.Contains("MMS process fallback is disabled", runtime, StringComparison.Ordinal); From 27c5007aaa89eb66ee73718f95fc4388037d32b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 16:11:17 +0700 Subject: [PATCH 27/48] Use client-compatible BRCB activation for static reports --- Services/NativeIec61850Client.StaticDataSetReporting.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index 32d5173a..fbc918f4 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -314,7 +314,7 @@ public async Task BuildStaticDataSetReportPlan $"Verify authoritative configured RCB {configured.Reference} as live object {concreteReportReference}.", $"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).", "Install InformationReport receiver before enabling the RCB.", - "Write RptEna=true, then request GI=true.", + "Use client-compatible BRCB reservation when ResvTms is exposed, enable RptEna, then request GI after receiver registration.", "Map report values by ordered DataSet member index; never substitute cyclic MMS process reads." }, Warnings = subscriptionWarnings @@ -424,7 +424,7 @@ public async Task StartStaticDataSetReportMonito var coveredReferences = ExtractSubscriptionMemberReferences(subscription.Members); var attempt = await RunMmsOperationAsync( - () => _session.StartPersistentReportMonitorWithAttemptEvidenceAsync( + () => _session.StartPersistentReportMonitorClientCompatibleAsync( subscription, triggerGeneralInterrogation: true, deleteDynamicDataSetOnStop: false, From 1abdafb67a25341a4721fac8549087f06bfc752f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 16:11:45 +0700 Subject: [PATCH 28/48] Pin ARIEC client-compatible BRCB activation --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 94c589a8..2d50a239 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "0d7525bd330900917fb9f6d15a46059dc3d7a70a", + "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", "sourcePullRequest": 111, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values." + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values. Physical BRCB compatibility hardening at 11ab2304482600c19ba979f4fc9021ddb46b9af9 adds a client-compatible persistent activation wrapper: when ResvTms is exposed it attempts an explicit 60-second BRCB reservation with implicit-RptEna fallback, keeps cleanup/release deterministic, and requests GI only after the persistent report session is registered." } From 13ef03f6112e6ce20c276da201327d48fa04213a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 16:34:23 +0700 Subject: [PATCH 29/48] Test: sync ARIEC client-compatible activation pin --- tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs index a82711b9..a2cbfddc 100644 --- a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs +++ b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs @@ -50,10 +50,11 @@ public void StaticReport_TotPf_UsesExactSemanticEngineAuthorityWithoutMmsFallbac var projection = Read("Services/StaticDataSetReportProjectionAccumulator.cs"); Assert.Contains("\"sourcePullRequest\": 111", engineLock, StringComparison.Ordinal); - Assert.Contains("0d7525bd330900917fb9f6d15a46059dc3d7a70a", engineLock, StringComparison.Ordinal); + Assert.Contains("11ab2304482600c19ba979f4fc9021ddb46b9af9", engineLock, StringComparison.Ordinal); Assert.Contains("P1 hardening", engineLock, StringComparison.Ordinal); Assert.Contains("report-value position", engineLock, StringComparison.Ordinal); Assert.Contains("omits MemberReference", engineLock, StringComparison.Ordinal); + Assert.Contains("client-compatible", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("MmsSemanticReportValueProjector.Project", semanticBridge, StringComparison.Ordinal); Assert.Contains("session.StaticReportProjection.Project", runtime, StringComparison.Ordinal); Assert.Contains("MMS process fallback is disabled", runtime, StringComparison.Ordinal); From 2544feef4abfaa2370f841ed0627a14314a691ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 16:35:58 +0700 Subject: [PATCH 30/48] Bench: add progressive static RCB plus MX fallback patch --- scripts/apply-progressive-static-bench.ps1 | 183 +++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 scripts/apply-progressive-static-bench.ps1 diff --git a/scripts/apply-progressive-static-bench.ps1 b/scripts/apply-progressive-static-bench.ps1 new file mode 100644 index 00000000..963217ba --- /dev/null +++ b/scripts/apply-progressive-static-bench.ps1 @@ -0,0 +1,183 @@ +param( + [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot) +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Replace-Exact { + param( + [string]$Text, + [string]$Old, + [string]$New, + [string]$Label + ) + + if (-not $Text.Contains($Old, [System.StringComparison]::Ordinal)) { + throw "Progressive Static bench patch anchor was not found: $Label" + } + + return $Text.Replace($Old, $New, [System.StringComparison]::Ordinal) +} + +$utf8 = New-Object System.Text.UTF8Encoding($false) +$selectionPath = Join-Path $ProjectRoot 'Services\Iec61850StaticDataSetAuthoritySelection.cs' +$runtimePath = Join-Path $ProjectRoot 'Services\Iec61850MonitorRuntime.cs' + +$selection = [System.IO.File]::ReadAllText($selectionPath) +$runtime = [System.IO.File]::ReadAllText($runtimePath) + +# Progressive bench policy: keep every exact static DataSet membership visible to the +# monitor. RCB-backed memberships still become report plans; uncovered MX measurements +# are handled later by the runtime's bounded MMS fallback. +$selection = Replace-Exact $selection @' + var reportBackedDataSets = BuildReportBackedDataSetReferences(device); + if (reportBackedDataSets.Count == 0) + return new HashSet(ReferenceEqualityComparer.Instance); + +'@ '' 'remove report-backed-only DataSet selection gate' + +$selection = Replace-Exact $selection @' + .Where(item => reportBackedDataSets.Contains(NormalizeLiteral(item.DataSetReference))) +'@ '' 'allow every exact static DataSet membership' + +$selection = Replace-Exact $selection @' + // A descriptor may carry more than one membership. Do not arbitrarily take the + // first DataSet: choose only literal memberships that are backed by authoritative + // report-control configuration. +'@ @' + // A descriptor may carry more than one membership. Progressive Static keeps each + // literal membership visible: configured RCBs remain primary, while uncovered MX + // measurements may use bounded MMS polling. No fuzzy membership is introduced. +'@ 'update progressive selection comment' + +# Static mode formerly disabled the poll queue globally. The bench policy schedules only +# uncovered MX/measurement points. Any point already bound to an active RCB remains excluded +# from the cyclic scheduler, preserving event-driven Digital/status semantics. +$runtime = Replace-Exact $runtime @' + session.PollQueue.Clear(); + if (session.StaticDataSetReportOnly) + { + foreach (var state in session.States.Values) + state.NextPollUtc = DateTime.MaxValue; + return; + } + + var nowUtc = DateTime.UtcNow; + var index = 0; +'@ @' + session.PollQueue.Clear(); + if (session.StaticDataSetReportOnly) + { + var staticNowUtc = DateTime.UtcNow; + var staticIndex = 0; + foreach (var point in session.Points.Values) + { + var state = session.States[point.PointKey]; + var reportAssigned = session.PointPlanIds.ContainsKey(point.PointKey); + var measurementFallback = IsAnalogPoint(point) || + point.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase); + + if (reportAssigned || !measurementFallback) + { + state.NextPollUtc = DateTime.MaxValue; + continue; + } + + var dueUtc = staggerForRecovery + ? staticNowUtc.AddMilliseconds(SmartReconnectPolicy.GetRecoveryStaggerDelayMs(staticIndex++)) + : staticNowUtc; + state.NextPollUtc = dueUtc; + state.AcquisitionLabel = "Static DataSet: MMS polling fallback"; + state.SourceMode = state.AcquisitionLabel; + state.Reason = "no active configured RCB coverage for MX measurement"; + state.Status = "Queued / progressive MMS fallback"; + if (staggerForRecovery) + state.NextCompanionPollUtc = session.RecoveryWarmupUntilUtc; + session.PollQueue.Enqueue(point.PointKey, dueUtc.Ticks); + } + return; + } + + var nowUtc = DateTime.UtcNow; + var index = 0; +'@ 'schedule uncovered static MX points for MMS polling' + +# Make the live-grid source explicit when an uncovered static measurement is read by MMS. +$runtime = Replace-Exact $runtime @' + var sourceMode = "MMS polling"; + var reason = "cyclic"; + var status = "Live / polling"; + + if (reportAssigned) +'@ @' + var sourceMode = "MMS polling"; + var reason = "cyclic"; + var status = "Live / polling"; + + if (session.StaticDataSetReportOnly && !reportAssigned) + { + sourceMode = "Static DataSet: MMS polling fallback"; + reason = "uncovered MX measurement / bounded cyclic MMS"; + status = "Live / progressive MMS fallback"; + } + + if (reportAssigned) +'@ 'label static MX fallback reads' + +# Replace the strict-static summary with a truthful split between report-covered, +# MX polling fallback, and unresolved discrete points. +$runtime = Replace-Exact $runtime @' + if (session.StaticDataSetReportOnly) + { + session.Device.AcquisitionMode = staticReportCount > 0 + ? $"Static DataSet reporting • RCB {staticReportCount} • unresolved {unassignedCount}" + : $"Static DataSet reporting unavailable • unresolved {unassignedCount}"; + session.Device.Detail = staticReportCount > 0 + ? $"{session.Points.Count} DataSet-derived point(s): configured RCB reporting is the process-value authority; {unassignedCount} point(s) are unresolved/unavailable. Cyclic MMS process polling is disabled." + : $"{session.Points.Count} DataSet-derived point(s): no configured RCB could be armed. Values remain unavailable; MMS process fallback is disabled by Static DataSet mode."; + session.Device.RefreshComputed(); + Log("INFO", session.Device.Name, + $"Static DataSet acquisition ready: static report plan(s)={staticReportCount}, report-covered={session.PointPlanIds.Count}, unresolved={unassignedCount}, cyclic MMS process polling=0."); + return; + } +'@ @' + if (session.StaticDataSetReportOnly) + { + var measurementFallbackCount = session.Points.Values.Count(point => + !session.PointPlanIds.ContainsKey(point.PointKey) && + (IsAnalogPoint(point) || point.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase))); + var unresolvedDiscreteCount = Math.Max(0, unassignedCount - measurementFallbackCount); + + session.Device.AcquisitionMode = staticReportCount > 0 + ? $"Progressive Static • RCB {staticReportCount} • MX fallback {measurementFallbackCount} • unresolved {unresolvedDiscreteCount}" + : $"Progressive Static • MX fallback {measurementFallbackCount} • unresolved {unresolvedDiscreteCount}"; + session.Device.Detail = + $"{session.Points.Count} static DataSet-derived point(s): configured RCB reporting stays primary; " + + $"{measurementFallbackCount} uncovered MX/measurement point(s) use bounded MMS polling; " + + $"{unresolvedDiscreteCount} uncovered discrete point(s) remain fail-closed. Dynamic DataSet writes remain disabled."; + session.Device.RefreshComputed(); + Log("INFO", session.Device.Name, + $"Progressive Static acquisition ready: static report plan(s)={staticReportCount}, report-covered={session.PointPlanIds.Count}, MX MMS fallback={measurementFallbackCount}, unresolved-discrete={unresolvedDiscreteCount}, dynamic DataSet writes=0."); + return; + } +'@ 'report progressive static acquisition summary' + +$runtime = $runtime.Replace( + 'Static DataSet report-only start:', + 'Progressive Static DataSet start:', + [System.StringComparison]::Ordinal) +$runtime = $runtime.Replace( + 'Static DataSet report-only mode: arming configured RCBs immediately; no cyclic MMS initial-image scheduler is active.', + 'Progressive Static mode: arming configured RCBs first; uncovered MX measurements will enter bounded MMS polling only after report planning.', + [System.StringComparison]::Ordinal) +$runtime = $runtime.Replace( + 'Engine fallback candidates are diagnostic only and are not scheduled as MMS process polling.', + 'Configured-RCB planning remains authoritative; runtime schedules MMS only for uncovered MX/measurement points.', + [System.StringComparison]::Ordinal) + +[System.IO.File]::WriteAllText($selectionPath, $selection, $utf8) +[System.IO.File]::WriteAllText($runtimePath, $runtime, $utf8) + +Write-Host 'Progressive Static bench patch applied.' +Write-Host 'Policy: configured RCB first; uncovered MX/measurement -> bounded MMS polling; uncovered discrete -> fail-closed; dynamic DataSet writes -> disabled.' From cd94a3caa8d0e57936b49b17488cfe68d4b00f85 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 16:36:32 +0700 Subject: [PATCH 31/48] CI: build Progressive Static IED bench executable --- .../workflows/progressive-static-bench.yml | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 .github/workflows/progressive-static-bench.yml diff --git a/.github/workflows/progressive-static-bench.yml b/.github/workflows/progressive-static-bench.yml new file mode 100644 index 00000000..86441aa2 --- /dev/null +++ b/.github/workflows/progressive-static-bench.yml @@ -0,0 +1,114 @@ +name: Build Progressive Static IED Bench + +on: + push: + branches: + - fix/static-dataset-rcb-backed-acquisition + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-progressive-static: + name: Build Progressive Static portable EXE + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Resolve immutable ARIEC engine pin + shell: powershell + run: | + $lock = Get-Content .\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json + if ($lock.repository -notmatch '^[^/]+/[^/]+$' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { + throw 'Invalid ARIEC61850 integration lock.' + } + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Checkout exact ARIEC engine revision + shell: powershell + run: | + $engineRoot = Join-Path $env:RUNNER_TEMP 'ARIEC61850' + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" $engineRoot + git -C $engineRoot fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT + git -C $engineRoot checkout --quiet --detach $env:ARIEC61850_COMMIT + $actual = (git -C $engineRoot rev-parse HEAD).Trim() + if ($actual -ne $env:ARIEC61850_COMMIT) { + throw "Engine pin mismatch: expected $env:ARIEC61850_COMMIT, got $actual" + } + $engineProject = Join-Path $engineRoot 'src\AR.Iec61850\AR.Iec61850.csproj' + $npcapProject = Join-Path $engineRoot 'src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj' + "ENGINE_PROJECT=$engineProject" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "NPCAP_PROJECT=$npcapProject" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Apply Progressive Static bench policy + shell: powershell + run: | + .\scripts\apply-progressive-static-bench.ps1 -ProjectRoot $env:GITHUB_WORKSPACE + $runtime = Get-Content .\Services\Iec61850MonitorRuntime.cs -Raw + $selection = Get-Content .\Services\Iec61850StaticDataSetAuthoritySelection.cs -Raw + if ($runtime -notmatch 'Progressive Static acquisition ready' -or + $runtime -notmatch 'Static DataSet: MMS polling fallback' -or + $runtime -notmatch 'uncovered MX measurement' -or + $selection -match 'reportBackedDataSets.Contains') { + throw 'Progressive Static bench source verification failed.' + } + + - name: Build patched bench source + shell: powershell + run: | + dotnet restore .\ArIED61850Tester.csproj ` + -p:ArIec61850Project="$env:ENGINE_PROJECT" ` + -p:ArIec61850NpcapProject="$env:NPCAP_PROJECT" + if ($LASTEXITCODE -ne 0) { throw "restore failed: $LASTEXITCODE" } + + dotnet build .\ArIED61850Tester.csproj -c Release --no-restore ` + -p:ArIec61850Project="$env:ENGINE_PROJECT" ` + -p:ArIec61850NpcapProject="$env:NPCAP_PROJECT" + if ($LASTEXITCODE -ne 0) { throw "build failed: $LASTEXITCODE" } + + - name: Publish portable Progressive Static EXE + shell: powershell + run: | + .\scripts\publish-windows-portable.ps1 ` + -Version '1.6.33-progressive-static' ` + -Runtime 'win-x64' ` + -SingleFile $true ` + -SelfContained $true ` + -EngineProject $env:ENGINE_PROJECT ` + -NpcapProject $env:NPCAP_PROJECT + + $source = '.\dist\ARSAS-1.6.33-progressive-static-win-x64-portable.exe' + $target = '.\dist\ARSAS-1.6.33-Progressive-Static-IED-Test.exe' + if (!(Test-Path $source -PathType Leaf)) { + throw "Portable EXE was not produced: $source" + } + Move-Item $source $target -Force + $hash = (Get-FileHash $target -Algorithm SHA256).Hash.ToLowerInvariant() + $head = (git rev-parse HEAD).Trim() + @( + "ARSAS head: $head", + "ARIEC engine: $env:ARIEC61850_COMMIT", + 'Bench policy: configured RCB first; uncovered MX/measurement -> bounded MMS polling; uncovered discrete -> fail-closed; dynamic DataSet writes -> disabled.', + "SHA256: $hash" + ) | Set-Content .\dist\Progressive-Static-build-info.txt -Encoding utf8 + Write-Host "SHA256=$hash" + + - name: Upload Progressive Static IED test artifact + uses: actions/upload-artifact@v4 + with: + name: ARSAS-Progressive-Static-IED-Test + path: | + dist/ARSAS-1.6.33-Progressive-Static-IED-Test.exe + dist/Progressive-Static-build-info.txt + if-no-files-found: error + retention-days: 14 From b754c98f30134a6bb51fa339a72baa4306458dcd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 17:06:00 +0700 Subject: [PATCH 32/48] Fix dual-role static Pos status projection --- ...850StaticControlStatusProjectionService.cs | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 Services/Iec61850StaticControlStatusProjectionService.cs diff --git a/Services/Iec61850StaticControlStatusProjectionService.cs b/Services/Iec61850StaticControlStatusProjectionService.cs new file mode 100644 index 00000000..3fb80544 --- /dev/null +++ b/Services/Iec61850StaticControlStatusProjectionService.cs @@ -0,0 +1,209 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +public sealed record Iec61850StaticControlStatusProjectionResult( + IReadOnlyList AddedSignals, + int LinkedControlCount) +{ + public int AddedCount => AddedSignals.Count; +} + +/// +/// Materializes the reportable status facet of an IEC 61850 position control object. +/// +/// A static FCDA such as CSWI1.Pos or XCBR1.Pos is dual-role: the DO itself is the +/// control object, while its ST primary value (Pos.stVal) is process feedback that belongs +/// in Live Signal Values. ARSAS must not force one SignalDefinition to serve both roles, +/// because the normal runtime boundary intentionally rejects control objects. +/// +/// This bridge is deliberately narrow and engine-authoritative. It only projects a status +/// row when ARIEC proves an exact static DataSet membership with FC=ST and an exact resolved +/// primary .stVal leaf, and an existing exact control object matches that same membership. +/// No prefix/fuzzy matching and no Oper/SBO/CtlVal reconstruction is permitted here. +/// +public static class Iec61850StaticControlStatusProjectionService +{ + private static readonly HashSet PositionLogicalNodeClasses = new(StringComparer.OrdinalIgnoreCase) + { + "CSWI", "XCBR", "XSWI" + }; + + public static Iec61850StaticControlStatusProjectionResult EnsureProjections(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + var authorityModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + if (authorityModel is null) + return EmptyResult(); + + var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(authorityModel); + if (mandatory.Count == 0) + return EmptyResult(); + + var added = new List(); + var linkedControls = new HashSet(ReferenceEqualityComparer.Instance); + + foreach (var descriptor in mandatory) + { + if (!IsExactStaticPositionStatusDescriptor(descriptor)) + continue; + + foreach (var membership in descriptor.DataSetMemberships + .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.MemberIndex)) + { + var memberReference = FirstNonEmpty( + membership.CanonicalMemberReference, + membership.OriginalMemberReference, + descriptor.DesignReference, + descriptor.ObservedReference); + if (string.IsNullOrWhiteSpace(memberReference) || + string.IsNullOrWhiteSpace(membership.DataSetReference) || + !IsExactPositionMemberReference(memberReference)) + { + continue; + } + + var control = FindExactControlCompanion( + device.Signals, + membership.DataSetReference, + memberReference); + if (control is null) + continue; + + LinkControlToMembership(control, descriptor, membership, memberReference); + linkedControls.Add(control); + + var primaryValueReference = descriptor.PrimaryValueReference.Trim(); + var existingStatus = device.Signals.FirstOrDefault(signal => + !signal.IsControlSignal && + LiteralEquals(signal.DataSetReference, membership.DataSetReference) && + LiteralEquals(signal.DisplayReference, memberReference) && + LiteralEquals(signal.ObjectReference, primaryValueReference)); + if (existingStatus is not null) + continue; + + var report = descriptor.ReportMemberships.FirstOrDefault(); + var status = new SignalDefinition + { + Name = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, memberReference), + ObjectReference = primaryValueReference, + DisplayReference = memberReference, + FunctionalConstraint = "ST", + DataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown"), + Category = "Position", + Confidence = "High", + DataSetReference = membership.DataSetReference, + ReportControlReference = report?.ReportControlReference ?? string.Empty, + QualityReference = descriptor.QualityReference, + TimestampReference = descriptor.TimestampReference, + Source = "ARIEC61850 static DataSet • dual-role control status projection", + IsSelected = false, + IsReportCapable = true, + ReportCoverage = report is null + ? "Static DataSet position status" + : "Static report/DataSet position status", + ReportCoverageReason = + $"Exact ARIEC static FCDA {memberReference} ({membership.DataSetReference}[{membership.MemberIndex}]) " + + $"resolves to ST primary value {primaryValueReference}; the control DO remains a separate command companion.", + ProbeStatus = "Not probed", + Value = "-", + Quality = "Unknown", + DeviceTimestamp = "-" + }; + + device.Signals.Add(status); + added.Add(status); + } + } + + return new Iec61850StaticControlStatusProjectionResult(added, linkedControls.Count); + } + + private static bool IsExactStaticPositionStatusDescriptor(Iec61850SignalDescriptor descriptor) + { + if (!descriptor.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference)) + { + return false; + } + + var primary = NormalizeReference(descriptor.PrimaryValueReference); + return primary.EndsWith(".stval", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsExactPositionMemberReference(string memberReference) + { + if (!SignalDefinition.IsControlObjectReference(memberReference)) + return false; + + var normalized = NormalizeReference(memberReference); + if (!normalized.EndsWith(".pos", StringComparison.OrdinalIgnoreCase)) + return false; + + var slash = normalized.IndexOf('/'); + if (slash < 0 || slash == normalized.Length - 1) + return false; + + var afterSlash = normalized[(slash + 1)..]; + var logicalNode = afterSlash.Split('.', 2)[0]; + var logicalNodeClass = SignalDefinition.DetectLogicalNodeClass(logicalNode); + return PositionLogicalNodeClasses.Contains(logicalNodeClass); + } + + private static SignalDefinition? FindExactControlCompanion( + IEnumerable signals, + string dataSetReference, + string memberReference) + { + return signals + .Where(signal => signal.IsControlSignal && signal.IsValidControlObject) + .Where(signal => + string.IsNullOrWhiteSpace(signal.DataSetReference) || + LiteralEquals(signal.DataSetReference, dataSetReference)) + .Where(signal => + LiteralEquals(signal.DisplayReference, memberReference) || + LiteralEquals(signal.ObjectReference, memberReference)) + .OrderByDescending(signal => LiteralEquals(signal.DisplayReference, memberReference)) + .FirstOrDefault(); + } + + private static void LinkControlToMembership( + SignalDefinition control, + Iec61850SignalDescriptor descriptor, + Iec61850SignalDataSetMembership membership, + string memberReference) + { + // Exact FCDA authority only. These fields let StaticDataSetAuthoritySelection select + // the command companion without making that control object publishable as a process row. + control.DisplayReference = memberReference; + control.DataSetReference = membership.DataSetReference; + control.IsReportCapable = true; + + var report = descriptor.ReportMemberships.FirstOrDefault(); + if (report is not null && string.IsNullOrWhiteSpace(control.ReportControlReference)) + control.ReportControlReference = report.ReportControlReference; + + if (string.IsNullOrWhiteSpace(control.QualityReference)) + control.QualityReference = descriptor.QualityReference; + if (string.IsNullOrWhiteSpace(control.TimestampReference)) + control.TimestampReference = descriptor.TimestampReference; + } + + private static Iec61850StaticControlStatusProjectionResult EmptyResult() + => new(Array.Empty(), 0); + + private static bool LiteralEquals(string? left, string? right) + => string.Equals( + (left ?? string.Empty).Trim(), + (right ?? string.Empty).Trim(), + StringComparison.OrdinalIgnoreCase); + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; +} From 3fc52a882e49298f73da0741c31f97c049f11535 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 17:06:21 +0700 Subject: [PATCH 33/48] Register dual-role static control status rows --- MainWindow.DataSetSignalInventory.cs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/MainWindow.DataSetSignalInventory.cs b/MainWindow.DataSetSignalInventory.cs index ca1413dd..e0e70621 100644 --- a/MainWindow.DataSetSignalInventory.cs +++ b/MainWindow.DataSetSignalInventory.cs @@ -12,7 +12,14 @@ internal void RegisterRecoveredDataSetSignals( ArgumentNullException.ThrowIfNull(device); ArgumentNullException.ThrowIfNull(merge); - foreach (var signal in merge.AddedSignals) + // A static FCDA such as CSWI/XCBR.Pos is dual-role: the control DO must remain a + // command object, while ARIEC's exact ST primary leaf must also exist as a normal + // report-backed runtime row. Materialize that status facet before Static DataSet + // authority selection so control semantics never have to weaken CanPublishToRuntime. + var controlStatusProjection = + Iec61850StaticControlStatusProjectionService.EnsureProjections(device); + + foreach (var signal in merge.AddedSignals.Concat(controlStatusProjection.AddedSignals)) { // The wizard can recover a row after the normal discovery collection has // already been registered with MainWindow. Bring that row under the same @@ -22,8 +29,13 @@ internal void RegisterRecoveredDataSetSignals( _signalOwners[signal] = device; } - if (merge.AddedCount == 0 && merge.EnrichedExistingCount == 0) + if (merge.AddedCount == 0 && + merge.EnrichedExistingCount == 0 && + controlStatusProjection.AddedCount == 0 && + controlStatusProjection.LinkedControlCount == 0) + { return; + } device.RecountSelectedSignals(); device.RefreshComputed(); @@ -36,5 +48,13 @@ internal void RegisterRecoveredDataSetSignals( device.Name, $"ARIEC DataSet authority restored {merge.AddedCount} mandatory primary signal(s) to the selection inventory; user selection was not changed."); } + + if (controlStatusProjection.AddedCount > 0 || controlStatusProjection.LinkedControlCount > 0) + { + AddLog( + "INFO", + device.Name, + $"Static DataSet dual-role control projection: restored {controlStatusProjection.AddedCount} exact ST status row(s) and linked {controlStatusProjection.LinkedControlCount} exact control object(s); control service leaves remain excluded from Live Signal Values."); + } } } From 6085e8d5cf08ed112dfe64bba6008f99867f9a31 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 17:06:46 +0700 Subject: [PATCH 34/48] Keep exact static Pos control companions selected --- ...Iec61850StaticDataSetAuthoritySelection.cs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index 5296d816..0d6e5337 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -15,8 +15,11 @@ namespace ArIED61850Tester.Services; /// /// A DataSetReference on a browsed/runtime alias is also not sufficient authority: several /// aliases can point at the same static FCDA/FCD member (for example cVal/instCVal or -/// structured measurement descendants). Static mode selects one presentation row for each -/// engine-authoritative membership, preserving the literal member identity from SCL/ARIEC. +/// structured measurement descendants). Static mode selects one presentation/runtime row +/// for each engine-authoritative membership, preserving the literal member identity from +/// SCL/ARIEC. If that exact membership is also a control DO, the exact control companion is +/// selected in addition to the runtime status projection so Command Panel inspection can run; +/// the control object itself still never passes the process-value runtime boundary. /// public static class Iec61850StaticDataSetAuthoritySelection { @@ -90,6 +93,19 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) .First(); selected.Add(chosen); + + // A dual-role FCDA such as CSWI1.Pos/XCBR1.Pos needs two consumers of the + // same exact engineering identity: its resolved ST primary leaf is the live + // process row above, while the DO itself remains the command target. Select + // only literal exact control companions; runtime admission continues to reject + // IsControlSignal, so Oper/SBO/CtlVal can never become process rows here. + foreach (var control in signals + .Where(signal => signal.IsControlSignal && signal.IsValidControlObject) + .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) + .Where(signal => LiteralEquals(signal.DisplayReference, memberReference))) + { + selected.Add(control); + } } } From 943333af48149d8c0053dddd495461f0dca2fec9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 17:07:12 +0700 Subject: [PATCH 35/48] Add Q0 Pos dual-role static regression guards --- ...icDataSetDualRoleControlRegressionTests.cs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs diff --git a/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs new file mode 100644 index 00000000..f7fd21d2 --- /dev/null +++ b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs @@ -0,0 +1,137 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class StaticDataSetDualRoleControlRegressionTests +{ + [Fact] + public void PositionControlAndStatusRemainSeparateRuntimeFacets() + { + var control = new SignalDefinition + { + Name = "Pos", + ObjectReference = "AA1E1F06R4Q0/CSWI1.Pos", + DisplayReference = "AA1E1F06R4Q0/CSWI1.Pos", + FunctionalConstraint = "ST", + DataType = "Dbpos", + Category = "Control", + DataSetReference = "AA1E1F06R4Application/LLN0.Digital", + IsControlSignal = true + }; + var status = new SignalDefinition + { + Name = "Pos", + ObjectReference = "AA1E1F06R4Q0/CSWI1.Pos.stVal", + DisplayReference = "AA1E1F06R4Q0/CSWI1.Pos", + FunctionalConstraint = "ST", + DataType = "Dbpos", + Category = "Position", + DataSetReference = "AA1E1F06R4Application/LLN0.Digital" + }; + + Assert.True(control.IsValidControlObject); + Assert.True(control.IsPositionControl); + Assert.False(control.CanPublishAsSignal); + Assert.True(status.CanPublishAsSignal); + Assert.True(status.CanPublishToRuntime); + Assert.False(status.IsControlSignal); + Assert.False(SignalDefinition.IsControlObjectReference("AA1E1F06R4Q0/CSWI1.Pos.Oper")); + Assert.False(SignalDefinition.IsControlObjectReference("AA1E1F06R4Q0/CSWI1.Pos.SBOw")); + } + + [Fact] + public void StaticPositionProjection_RequiresExactAriecStAuthority() + { + var source = File.ReadAllText(FindRepoFile( + "Services/Iec61850StaticControlStatusProjectionService.cs")); + + Assert.Contains("descriptor.FunctionalConstraint.Equals(\"ST\"", source, StringComparison.Ordinal); + Assert.Contains("descriptor.PrimaryValueReference", source, StringComparison.Ordinal); + Assert.Contains("primary.EndsWith(\".stval\"", source, StringComparison.Ordinal); + Assert.Contains("\"CSWI\", \"XCBR\", \"XSWI\"", source, StringComparison.Ordinal); + Assert.Contains("signal.IsControlSignal && signal.IsValidControlObject", source, StringComparison.Ordinal); + Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", source, StringComparison.Ordinal); + Assert.Contains("LiteralEquals(signal.ObjectReference, memberReference)", source, StringComparison.Ordinal); + Assert.Contains("ObjectReference = primaryValueReference", source, StringComparison.Ordinal); + Assert.Contains("FunctionalConstraint = \"ST\"", source, StringComparison.Ordinal); + Assert.Contains("Category = \"Position\"", source, StringComparison.Ordinal); + Assert.Contains("dual-role control status projection", source, StringComparison.Ordinal); + Assert.Contains("No prefix/fuzzy matching", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartsWith(memberReference", source, StringComparison.Ordinal); + } + + [Fact] + public void StaticAuthority_SelectsExactControlCompanionButRuntimeStillRejectsRawControlObjects() + { + var authority = File.ReadAllText(FindRepoFile( + "Services/Iec61850StaticDataSetAuthoritySelection.cs")); + var runtime = File.ReadAllText(FindRepoFile( + "Services/Iec61850MonitorRuntime.cs")); + + Assert.Contains( + ".Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime)", + authority, + StringComparison.Ordinal); + Assert.Contains( + ".Where(signal => signal.IsControlSignal && signal.IsValidControlObject)", + authority, + StringComparison.Ordinal); + Assert.Contains( + "LiteralEquals(signal.DataSetReference, membership.DataSetReference)", + authority, + StringComparison.Ordinal); + Assert.Contains( + "LiteralEquals(signal.DisplayReference, memberReference)", + authority, + StringComparison.Ordinal); + Assert.Contains("selected.Add(control)", authority, StringComparison.Ordinal); + + // The raw control DO is selected only so ctlModel inspection / Command Panel can use + // it. Monitoring still admits only the separate non-control ST status projection. + Assert.Contains( + ".Where(signal => signal.IsSelected && signal.CanPublishToRuntime)", + runtime, + StringComparison.Ordinal); + Assert.DoesNotContain( + "signal.IsSelected && (signal.CanPublishToRuntime || signal.IsControlSignal)", + runtime, + StringComparison.Ordinal); + } + + [Fact] + public void SharedStaticWorkflow_MaterializesDualRoleStatusBeforeAuthoritySelection() + { + var registration = File.ReadAllText(FindRepoFile("MainWindow.DataSetSignalInventory.cs")); + var shared = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); + + Assert.Contains( + "Iec61850StaticControlStatusProjectionService.EnsureProjections(device)", + registration, + StringComparison.Ordinal); + Assert.Contains( + "merge.AddedSignals.Concat(controlStatusProjection.AddedSignals)", + registration, + StringComparison.Ordinal); + Assert.Contains("control service leaves remain excluded", registration, StringComparison.OrdinalIgnoreCase); + + var registerIndex = shared.IndexOf("RegisterRecoveredDataSetSignals(device, merge)", StringComparison.Ordinal); + var authorityIndex = shared.IndexOf("Iec61850StaticDataSetAuthoritySelection.Build(device)", StringComparison.Ordinal); + Assert.True(registerIndex >= 0 && authorityIndex > registerIndex); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 10f38c7841e180cef5953af9dccb754e5789cccf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 17:09:58 +0700 Subject: [PATCH 36/48] Sync static RCB regression wording --- tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 0e9e804b..8537a526 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -102,7 +102,7 @@ public void DeterministicStaticPlanner_PreservesSclConfigurationAndConcreteLiveR Assert.Contains("SelectMany(configured => discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); Assert.Contains("Install InformationReport receiver before enabling the RCB", source, StringComparison.Ordinal); - Assert.Contains("Write RptEna=true, then request GI=true", source, StringComparison.Ordinal); + Assert.Contains("enable RptEna, then request GI after receiver registration", source, StringComparison.Ordinal); Assert.DoesNotContain("configurationModels", source, StringComparison.Ordinal); Assert.DoesNotContain("var configured = configuredReports[0]", source, StringComparison.Ordinal); Assert.DoesNotContain("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal); From b20c12a58340c855967ed9862cc1179a21b4dfec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 17:34:54 +0700 Subject: [PATCH 37/48] Generalize static DataSet control companion projection by CDC --- ...850StaticControlStatusProjectionService.cs | 302 +++++++++++++----- 1 file changed, 216 insertions(+), 86 deletions(-) diff --git a/Services/Iec61850StaticControlStatusProjectionService.cs b/Services/Iec61850StaticControlStatusProjectionService.cs index 3fb80544..06b306d4 100644 --- a/Services/Iec61850StaticControlStatusProjectionService.cs +++ b/Services/Iec61850StaticControlStatusProjectionService.cs @@ -8,26 +8,46 @@ public sealed record Iec61850StaticControlStatusProjectionResult( int LinkedControlCount) { public int AddedCount => AddedSignals.Count; + public int AddedControlCount => AddedSignals.Count(signal => signal.IsControlSignal); + public int AddedRuntimeFeedbackCount => AddedSignals.Count(signal => !signal.IsControlSignal); } /// -/// Materializes the reportable status facet of an IEC 61850 position control object. +/// Preserves the two distinct facets of an IEC 61850 control DataObject that is carried by +/// an authoritative static DataSet. /// -/// A static FCDA such as CSWI1.Pos or XCBR1.Pos is dual-role: the DO itself is the -/// control object, while its ST primary value (Pos.stVal) is process feedback that belongs -/// in Live Signal Values. ARSAS must not force one SignalDefinition to serve both roles, -/// because the normal runtime boundary intentionally rejects control objects. +/// The exact SCL/ARIEC CDC + DataObjectReference identify the command target. That control +/// companion is retained even when no scalar feedback attribute can be resolved yet. When +/// ARIEC also proves one exact ST/MX PrimaryValueReference, a separate non-control runtime +/// feedback row may be materialized. This keeps command discovery independent from process +/// acquisition without weakening SignalDefinition.CanPublishToRuntime. /// -/// This bridge is deliberately narrow and engine-authoritative. It only projects a status -/// row when ARIEC proves an exact static DataSet membership with FC=ST and an exact resolved -/// primary .stVal leaf, and an existing exact control object matches that same membership. -/// No prefix/fuzzy matching and no Oper/SBO/CtlVal reconstruction is permitted here. +/// No control is inferred from object names. No Oper/SBO/SBOw/Cancel/ctlVal/ctlModel path is +/// reconstructed. Every companion is rooted in an exact DataSet membership plus a standard +/// controllable CDC, and every runtime feedback reference comes directly from ARIEC semantic +/// authority. Unsupported/ambiguous feedback therefore fails closed while the proven control +/// object remains available for live ctlModel inspection. /// public static class Iec61850StaticControlStatusProjectionService { - private static readonly HashSet PositionLogicalNodeClasses = new(StringComparer.OrdinalIgnoreCase) + // IEC 61850 controllable CDC families handled by ARSAS' command surface. Keep status-only + // CDCs (SPS/DPS/INS/ENS, etc.) out: their presence in a DataSet is not command authority. + private static readonly HashSet ControllableCdcs = new(StringComparer.OrdinalIgnoreCase) { - "CSWI", "XCBR", "XSWI" + "SPC", // single point control + "DPC", // double point / switch position control + "INC", // integer step control + "ISC", // integer status/control variant used by regulating devices + "APC", // analog process control + "BAC", // binary controlled analog + "BSC", // binary controlled step position + "ENC" // enumerated control + }; + + private static readonly HashSet ControlServicePathSegments = new(StringComparer.OrdinalIgnoreCase) + { + "ctlModel", "ctlVal", "ctlNum", "stSeld", "SBO", "SBOw", "Oper", "Cancel", + "origin", "T", "Test", "Check", "operTm", "sboClass", "sboTimeout", "operTimeout" }; public static Iec61850StaticControlStatusProjectionResult EnsureProjections(Iec61850MonitorDevice device) @@ -47,140 +67,206 @@ public static Iec61850StaticControlStatusProjectionResult EnsureProjections(Iec6 foreach (var descriptor in mandatory) { - if (!IsExactStaticPositionStatusDescriptor(descriptor)) - continue; - foreach (var membership in descriptor.DataSetMemberships .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.MemberIndex)) { + var cdc = FirstNonEmpty(descriptor.Cdc, membership.Cdc).ToUpperInvariant(); + if (!ControllableCdcs.Contains(cdc)) + continue; + var memberReference = FirstNonEmpty( membership.CanonicalMemberReference, membership.OriginalMemberReference, descriptor.DesignReference, descriptor.ObservedReference); + var dataObjectReference = (descriptor.DataObjectReference ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(memberReference) || string.IsNullOrWhiteSpace(membership.DataSetReference) || - !IsExactPositionMemberReference(memberReference)) + string.IsNullOrWhiteSpace(dataObjectReference) || + !SignalDefinition.IsControlObjectReference(dataObjectReference)) { continue; } + var exactFeedbackReference = ResolveExactFeedbackReference(descriptor, membership); + + // Do not attach a command target to q/t or other companion-only FCDA rows. + // The member must either be the control DO/FCD itself or the exact primary + // feedback FCDA that ARIEC resolved for that same DataObject. + if (!IsControlBearingMembership(memberReference, dataObjectReference, exactFeedbackReference)) + continue; + var control = FindExactControlCompanion( device.Signals, membership.DataSetReference, - memberReference); + memberReference, + dataObjectReference); if (control is null) - continue; - - LinkControlToMembership(control, descriptor, membership, memberReference); + { + control = CreateControlCompanion( + descriptor, + membership, + cdc, + memberReference, + dataObjectReference, + exactFeedbackReference); + device.Signals.Add(control); + added.Add(control); + } + else + { + LinkControlToMembership( + control, + descriptor, + membership, + cdc, + memberReference, + exactFeedbackReference); + } linkedControls.Add(control); - var primaryValueReference = descriptor.PrimaryValueReference.Trim(); - var existingStatus = device.Signals.FirstOrDefault(signal => + if (string.IsNullOrWhiteSpace(exactFeedbackReference)) + continue; + + var existingFeedback = device.Signals.FirstOrDefault(signal => !signal.IsControlSignal && LiteralEquals(signal.DataSetReference, membership.DataSetReference) && LiteralEquals(signal.DisplayReference, memberReference) && - LiteralEquals(signal.ObjectReference, primaryValueReference)); - if (existingStatus is not null) + LiteralEquals(signal.ObjectReference, exactFeedbackReference)); + if (existingFeedback is not null) continue; - var report = descriptor.ReportMemberships.FirstOrDefault(); - var status = new SignalDefinition - { - Name = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, memberReference), - ObjectReference = primaryValueReference, - DisplayReference = memberReference, - FunctionalConstraint = "ST", - DataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown"), - Category = "Position", - Confidence = "High", - DataSetReference = membership.DataSetReference, - ReportControlReference = report?.ReportControlReference ?? string.Empty, - QualityReference = descriptor.QualityReference, - TimestampReference = descriptor.TimestampReference, - Source = "ARIEC61850 static DataSet • dual-role control status projection", - IsSelected = false, - IsReportCapable = true, - ReportCoverage = report is null - ? "Static DataSet position status" - : "Static report/DataSet position status", - ReportCoverageReason = - $"Exact ARIEC static FCDA {memberReference} ({membership.DataSetReference}[{membership.MemberIndex}]) " + - $"resolves to ST primary value {primaryValueReference}; the control DO remains a separate command companion.", - ProbeStatus = "Not probed", - Value = "-", - Quality = "Unknown", - DeviceTimestamp = "-" - }; - - device.Signals.Add(status); - added.Add(status); + var feedback = CreateRuntimeFeedback( + descriptor, + membership, + cdc, + memberReference, + exactFeedbackReference); + + // PrimaryValueReference is semantic authority, but Live Signal admission has + // its own process-value contract. If the exact leaf is not a supported runtime + // value shape, retain the control companion and fail closed on feedback. + if (!feedback.CanPublishAsSignal) + continue; + + device.Signals.Add(feedback); + added.Add(feedback); } } return new Iec61850StaticControlStatusProjectionResult(added, linkedControls.Count); } - private static bool IsExactStaticPositionStatusDescriptor(Iec61850SignalDescriptor descriptor) + internal static bool IsControllableCdc(string? cdc) + => ControllableCdcs.Contains((cdc ?? string.Empty).Trim()); + + private static string ResolveExactFeedbackReference( + Iec61850SignalDescriptor descriptor, + Iec61850SignalDataSetMembership membership) { - if (!descriptor.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) || - string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference)) - { - return false; - } + var reference = (descriptor.PrimaryValueReference ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(reference)) + return string.Empty; - var primary = NormalizeReference(descriptor.PrimaryValueReference); - return primary.EndsWith(".stval", StringComparison.OrdinalIgnoreCase); - } + var fc = FirstNonEmpty(descriptor.FunctionalConstraint, membership.FunctionalConstraint).ToUpperInvariant(); + if (fc is not ("ST" or "MX")) + return string.Empty; - private static bool IsExactPositionMemberReference(string memberReference) - { - if (!SignalDefinition.IsControlObjectReference(memberReference)) - return false; + var segments = NormalizeReference(reference) + .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Any(segment => ControlServicePathSegments.Contains(segment))) + return string.Empty; - var normalized = NormalizeReference(memberReference); - if (!normalized.EndsWith(".pos", StringComparison.OrdinalIgnoreCase)) - return false; + return reference; + } - var slash = normalized.IndexOf('/'); - if (slash < 0 || slash == normalized.Length - 1) - return false; + private static bool IsControlBearingMembership( + string memberReference, + string dataObjectReference, + string exactFeedbackReference) + { + if (LiteralEquals(memberReference, dataObjectReference)) + return true; - var afterSlash = normalized[(slash + 1)..]; - var logicalNode = afterSlash.Split('.', 2)[0]; - var logicalNodeClass = SignalDefinition.DetectLogicalNodeClass(logicalNode); - return PositionLogicalNodeClasses.Contains(logicalNodeClass); + return !string.IsNullOrWhiteSpace(exactFeedbackReference) && + LiteralEquals(memberReference, exactFeedbackReference); } private static SignalDefinition? FindExactControlCompanion( IEnumerable signals, string dataSetReference, - string memberReference) + string memberReference, + string dataObjectReference) { return signals .Where(signal => signal.IsControlSignal && signal.IsValidControlObject) + .Where(signal => LiteralEquals(signal.ObjectReference, dataObjectReference)) .Where(signal => string.IsNullOrWhiteSpace(signal.DataSetReference) || LiteralEquals(signal.DataSetReference, dataSetReference)) .Where(signal => + string.IsNullOrWhiteSpace(signal.DisplayReference) || LiteralEquals(signal.DisplayReference, memberReference) || - LiteralEquals(signal.ObjectReference, memberReference)) - .OrderByDescending(signal => LiteralEquals(signal.DisplayReference, memberReference)) + LiteralEquals(signal.DisplayReference, dataObjectReference)) + .OrderByDescending(signal => LiteralEquals(signal.DataSetReference, dataSetReference)) + .ThenByDescending(signal => LiteralEquals(signal.DisplayReference, memberReference)) .FirstOrDefault(); } + private static SignalDefinition CreateControlCompanion( + Iec61850SignalDescriptor descriptor, + Iec61850SignalDataSetMembership membership, + string cdc, + string memberReference, + string dataObjectReference, + string exactFeedbackReference) + { + var report = descriptor.ReportMemberships.FirstOrDefault(); + return new SignalDefinition + { + Name = FirstNonEmpty(descriptor.DataObject, dataObjectReference), + ObjectReference = dataObjectReference, + DisplayReference = memberReference, + FunctionalConstraint = "CO", + DataType = cdc, + Category = "Control", + Confidence = "High", + DataSetReference = membership.DataSetReference, + ReportControlReference = report?.ReportControlReference ?? string.Empty, + QualityReference = descriptor.QualityReference, + TimestampReference = descriptor.TimestampReference, + Source = "ARIEC61850 static DataSet • exact CDC/DataObject control companion", + IsControlSignal = true, + ControlCdc = cdc, + ControlStatusReference = exactFeedbackReference, + IsSelected = false, + IsReportCapable = true, + ReportCoverage = "Static DataSet control companion", + ReportCoverageReason = + $"Exact SCL/ARIEC DataSet member {memberReference} ({membership.DataSetReference}[{membership.MemberIndex}]) " + + $"has controllable CDC={cdc} and exact command DataObject {dataObjectReference}. Actual command actions remain disabled until live ctlModel inspection proves Direct/SBO operation.", + ProbeStatus = "Control model pending", + Value = "-", + Quality = "Unknown", + DeviceTimestamp = "-" + }; + } + private static void LinkControlToMembership( SignalDefinition control, Iec61850SignalDescriptor descriptor, Iec61850SignalDataSetMembership membership, - string memberReference) + string cdc, + string memberReference, + string exactFeedbackReference) { - // Exact FCDA authority only. These fields let StaticDataSetAuthoritySelection select - // the command companion without making that control object publishable as a process row. control.DisplayReference = memberReference; control.DataSetReference = membership.DataSetReference; control.IsReportCapable = true; + control.ControlCdc = cdc; + if (!string.IsNullOrWhiteSpace(exactFeedbackReference)) + control.ControlStatusReference = exactFeedbackReference; var report = descriptor.ReportMemberships.FirstOrDefault(); if (report is not null && string.IsNullOrWhiteSpace(control.ReportControlReference)) @@ -192,17 +278,61 @@ private static void LinkControlToMembership( control.TimestampReference = descriptor.TimestampReference; } + private static SignalDefinition CreateRuntimeFeedback( + Iec61850SignalDescriptor descriptor, + Iec61850SignalDataSetMembership membership, + string cdc, + string memberReference, + string exactFeedbackReference) + { + var report = descriptor.ReportMemberships.FirstOrDefault(); + var fc = FirstNonEmpty(descriptor.FunctionalConstraint, membership.FunctionalConstraint).ToUpperInvariant(); + var category = cdc.Equals("DPC", StringComparison.OrdinalIgnoreCase) + ? "Position" + : fc.Equals("MX", StringComparison.OrdinalIgnoreCase) + ? "Measurement" + : "Status"; + + return new SignalDefinition + { + Name = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, memberReference), + ObjectReference = exactFeedbackReference, + DisplayReference = memberReference, + FunctionalConstraint = fc, + DataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown"), + Category = category, + Confidence = "High", + DataSetReference = membership.DataSetReference, + ReportControlReference = report?.ReportControlReference ?? string.Empty, + QualityReference = descriptor.QualityReference, + TimestampReference = descriptor.TimestampReference, + Source = "ARIEC61850 static DataSet • exact control feedback projection", + IsSelected = false, + IsReportCapable = true, + ReportCoverage = report is null + ? "Static DataSet control feedback" + : "Static report/DataSet control feedback", + ReportCoverageReason = + $"Exact SCL/ARIEC control member {memberReference} ({membership.DataSetReference}[{membership.MemberIndex}]) " + + $"resolves to {fc} primary feedback {exactFeedbackReference}; command and process facets remain separate.", + ProbeStatus = "Not probed", + Value = "-", + Quality = "Unknown", + DeviceTimestamp = "-" + }; + } + private static Iec61850StaticControlStatusProjectionResult EmptyResult() => new(Array.Empty(), 0); private static bool LiteralEquals(string? left, string? right) => string.Equals( - (left ?? string.Empty).Trim(), - (right ?? string.Empty).Trim(), + NormalizeReference(left), + NormalizeReference(right), StringComparison.OrdinalIgnoreCase); private static string NormalizeReference(string? reference) - => (reference ?? string.Empty).Trim().Replace('$', '.'); + => (reference ?? string.Empty).Trim().Replace('\\', '/').Replace('$', '.'); private static string FirstNonEmpty(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; From af3470376ca84ed5e4e3466c9e5cbd36d2067ddd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 17:35:22 +0700 Subject: [PATCH 38/48] Retain exact static controls independently of feedback rows --- ...Iec61850StaticDataSetAuthoritySelection.cs | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index 0d6e5337..8f4b43f0 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -14,12 +14,11 @@ namespace ArIED61850Tester.Services; /// operation with no SCL workspace, live discovery becomes the configuration authority. /// /// A DataSetReference on a browsed/runtime alias is also not sufficient authority: several -/// aliases can point at the same static FCDA/FCD member (for example cVal/instCVal or -/// structured measurement descendants). Static mode selects one presentation/runtime row -/// for each engine-authoritative membership, preserving the literal member identity from -/// SCL/ARIEC. If that exact membership is also a control DO, the exact control companion is -/// selected in addition to the runtime status projection so Command Panel inspection can run; -/// the control object itself still never passes the process-value runtime boundary. +/// aliases can point at the same static FCDA/FCD member. Static mode selects one process row +/// per engine-authoritative membership when a publishable feedback leaf exists. An exact +/// control companion for that same membership is selected independently: command discovery +/// must not disappear merely because scalar feedback is unresolved. Raw control objects are +/// still rejected by the process-value runtime boundary. /// public static class Iec61850StaticDataSetAuthoritySelection { @@ -27,9 +26,6 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) { ArgumentNullException.ThrowIfNull(device); - // Opened SCL is the engineering authority. A partial or richer live model may verify - // the configuration later, but it must not introduce extra static memberships that - // were never configured in the opened CID/SCD. Online-only mode falls back to live. var authorityModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; if (authorityModel is null) return new HashSet(ReferenceEqualityComparer.Instance); @@ -44,9 +40,6 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) foreach (var descriptor in mandatory) { - // A descriptor may carry more than one membership. Do not arbitrarily take the - // first DataSet: choose only literal memberships that are backed by authoritative - // report-control configuration. var memberships = descriptor.DataSetMemberships .Where(item => reportBackedDataSets.Contains(NormalizeLiteral(item.DataSetReference))) .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) @@ -67,6 +60,18 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) continue; } + // Command retention is independent from scalar process projection. The + // projection service has already rooted these companions in exact SCL/ARIEC + // CDC + DataObject authority. Selecting them here enables ctlModel inspection + // and Command Panel projection even when PrimaryValueReference is unresolved. + foreach (var control in signals + .Where(signal => signal.IsControlSignal && signal.IsValidControlObject) + .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) + .Where(signal => LiteralEquals(signal.DisplayReference, memberReference))) + { + selected.Add(control); + } + var candidates = signals .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) @@ -93,19 +98,6 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) .First(); selected.Add(chosen); - - // A dual-role FCDA such as CSWI1.Pos/XCBR1.Pos needs two consumers of the - // same exact engineering identity: its resolved ST primary leaf is the live - // process row above, while the DO itself remains the command target. Select - // only literal exact control companions; runtime admission continues to reject - // IsControlSignal, so Oper/SBO/CtlVal can never become process rows here. - foreach (var control in signals - .Where(signal => signal.IsControlSignal && signal.IsValidControlObject) - .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) - .Where(signal => LiteralEquals(signal.DisplayReference, memberReference))) - { - selected.Add(control); - } } } From d2c2615f56f5df5d153ff5224e042eb045b2e190 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 17:35:43 +0700 Subject: [PATCH 39/48] Report generalized static control projection evidence --- MainWindow.DataSetSignalInventory.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/MainWindow.DataSetSignalInventory.cs b/MainWindow.DataSetSignalInventory.cs index e0e70621..c236b8d3 100644 --- a/MainWindow.DataSetSignalInventory.cs +++ b/MainWindow.DataSetSignalInventory.cs @@ -12,10 +12,11 @@ internal void RegisterRecoveredDataSetSignals( ArgumentNullException.ThrowIfNull(device); ArgumentNullException.ThrowIfNull(merge); - // A static FCDA such as CSWI/XCBR.Pos is dual-role: the control DO must remain a - // command object, while ARIEC's exact ST primary leaf must also exist as a normal - // report-backed runtime row. Materialize that status facet before Static DataSet - // authority selection so control semantics never have to weaken CanPublishToRuntime. + // A controllable static DataSet member is dual-role. Exact SCL/ARIEC CDC and + // DataObjectReference preserve the command companion independently from scalar + // feedback resolution; an exact ST/MX PrimaryValueReference may additionally become + // a normal report-backed runtime row. This separation keeps command discovery broad + // without weakening CanPublishToRuntime or admitting control service leaves. var controlStatusProjection = Iec61850StaticControlStatusProjectionService.EnsureProjections(device); @@ -54,7 +55,10 @@ internal void RegisterRecoveredDataSetSignals( AddLog( "INFO", device.Name, - $"Static DataSet dual-role control projection: restored {controlStatusProjection.AddedCount} exact ST status row(s) and linked {controlStatusProjection.LinkedControlCount} exact control object(s); control service leaves remain excluded from Live Signal Values."); + $"Static DataSet control projection: materialized {controlStatusProjection.AddedControlCount} exact control companion(s), " + + $"{controlStatusProjection.AddedRuntimeFeedbackCount} exact runtime feedback row(s), and linked " + + $"{controlStatusProjection.LinkedControlCount} controllable DataSet member(s). Commands remain gated by live ctlModel; " + + "Oper/SBO/SBOw/Cancel/ctlVal/ctlModel service leaves remain excluded from Live Signal Values."); } } } From 4ef6f4dbced248cc530a70b7d1c6b283820263b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 17:37:03 +0700 Subject: [PATCH 40/48] Guard generalized static DataSet control retention --- ...icDataSetDualRoleControlRegressionTests.cs | 134 +++++++++++++----- 1 file changed, 98 insertions(+), 36 deletions(-) diff --git a/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs index f7fd21d2..af08912e 100644 --- a/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs @@ -1,5 +1,4 @@ using ArIED61850Tester.Models; -using ArIED61850Tester.Services; namespace ARSAS.Tests; @@ -13,11 +12,12 @@ public void PositionControlAndStatusRemainSeparateRuntimeFacets() Name = "Pos", ObjectReference = "AA1E1F06R4Q0/CSWI1.Pos", DisplayReference = "AA1E1F06R4Q0/CSWI1.Pos", - FunctionalConstraint = "ST", - DataType = "Dbpos", + FunctionalConstraint = "CO", + DataType = "DPC", Category = "Control", DataSetReference = "AA1E1F06R4Application/LLN0.Digital", - IsControlSignal = true + IsControlSignal = true, + ControlCdc = "DPC" }; var status = new SignalDefinition { @@ -38,57 +38,117 @@ public void PositionControlAndStatusRemainSeparateRuntimeFacets() Assert.False(status.IsControlSignal); Assert.False(SignalDefinition.IsControlObjectReference("AA1E1F06R4Q0/CSWI1.Pos.Oper")); Assert.False(SignalDefinition.IsControlObjectReference("AA1E1F06R4Q0/CSWI1.Pos.SBOw")); + Assert.False(SignalDefinition.IsControlObjectReference("AA1E1F06R4Q0/CSWI1.Pos.ctlVal")); + Assert.False(SignalDefinition.IsControlObjectReference("AA1E1F06R4Q0/CSWI1.Pos.Cancel")); + } + + [Theory] + [InlineData("SPC", "IEDCTRL/GGIO1.Enable", "boolean")] + [InlineData("DPC", "IEDQ0/CSWI1.Pos", "position")] + [InlineData("INC", "IEDCTRL/ATCC1.TapCmd", "regulating")] + [InlineData("ISC", "IEDCTRL/ATCC1.StepCmd", "regulating")] + [InlineData("APC", "IEDCTRL/AVCO1.VRef", "setpoint")] + [InlineData("BAC", "IEDCTRL/GAPC1.AnCtl", "setpoint")] + [InlineData("BSC", "IEDCTRL/ATCC1.TapPos", "setpoint")] + public void ExistingCommandSurface_CoversStandardControlFamilies( + string cdc, + string reference, + string expectedKind) + { + var signal = new SignalDefinition + { + Name = reference[(reference.LastIndexOf('.') + 1)..], + ObjectReference = reference, + DisplayReference = reference, + FunctionalConstraint = "CO", + DataType = cdc, + Category = "Control", + IsControlSignal = true, + ControlCdc = cdc + }; + + Assert.True(signal.IsValidControlObject); + Assert.False(signal.CanPublishAsSignal); + Assert.False(signal.IsGenericControl); + + switch (expectedKind) + { + case "boolean": + Assert.True(signal.IsBooleanControl); + break; + case "position": + Assert.True(signal.IsPositionControl); + break; + case "regulating": + Assert.True(signal.IsRaiseLowerControl); + break; + case "setpoint": + Assert.True(signal.IsSetPointControl); + break; + } } [Fact] - public void StaticPositionProjection_RequiresExactAriecStAuthority() + public void StaticControlProjection_UsesExactCdcAndDataObjectAuthority_NotObjectNameGuessing() { var source = File.ReadAllText(FindRepoFile( "Services/Iec61850StaticControlStatusProjectionService.cs")); - Assert.Contains("descriptor.FunctionalConstraint.Equals(\"ST\"", source, StringComparison.Ordinal); - Assert.Contains("descriptor.PrimaryValueReference", source, StringComparison.Ordinal); - Assert.Contains("primary.EndsWith(\".stval\"", source, StringComparison.Ordinal); - Assert.Contains("\"CSWI\", \"XCBR\", \"XSWI\"", source, StringComparison.Ordinal); - Assert.Contains("signal.IsControlSignal && signal.IsValidControlObject", source, StringComparison.Ordinal); - Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", source, StringComparison.Ordinal); - Assert.Contains("LiteralEquals(signal.ObjectReference, memberReference)", source, StringComparison.Ordinal); - Assert.Contains("ObjectReference = primaryValueReference", source, StringComparison.Ordinal); - Assert.Contains("FunctionalConstraint = \"ST\"", source, StringComparison.Ordinal); - Assert.Contains("Category = \"Position\"", source, StringComparison.Ordinal); - Assert.Contains("dual-role control status projection", source, StringComparison.Ordinal); - Assert.Contains("No prefix/fuzzy matching", source, StringComparison.Ordinal); + foreach (var cdc in new[] { "SPC", "DPC", "INC", "ISC", "APC", "BAC", "BSC", "ENC" }) + Assert.Contains($"\"{cdc}\"", source, StringComparison.Ordinal); + + Assert.Contains("FirstNonEmpty(descriptor.Cdc, membership.Cdc)", source, StringComparison.Ordinal); + Assert.Contains("descriptor.DataObjectReference", source, StringComparison.Ordinal); + Assert.Contains("CreateControlCompanion", source, StringComparison.Ordinal); + Assert.Contains("ControlCdc = cdc", source, StringComparison.Ordinal); + Assert.Contains("FunctionalConstraint = \"CO\"", source, StringComparison.Ordinal); + Assert.Contains("ControlStatusReference = exactFeedbackReference", source, StringComparison.Ordinal); + Assert.Contains("Actual command actions remain disabled until live ctlModel inspection", source, StringComparison.Ordinal); + Assert.DoesNotContain("PositionLogicalNodeClasses", source, StringComparison.Ordinal); + Assert.DoesNotContain("IsExactPositionMemberReference", source, StringComparison.Ordinal); Assert.DoesNotContain("StartsWith(memberReference", source, StringComparison.Ordinal); } [Fact] - public void StaticAuthority_SelectsExactControlCompanionButRuntimeStillRejectsRawControlObjects() + public void StaticControlProjection_RetainsControlWhenScalarFeedbackIsUnresolved() { + var projection = File.ReadAllText(FindRepoFile( + "Services/Iec61850StaticControlStatusProjectionService.cs")); var authority = File.ReadAllText(FindRepoFile( "Services/Iec61850StaticDataSetAuthoritySelection.cs")); - var runtime = File.ReadAllText(FindRepoFile( - "Services/Iec61850MonitorRuntime.cs")); - Assert.Contains( - ".Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime)", - authority, - StringComparison.Ordinal); - Assert.Contains( + Assert.Contains("if (control is null)", projection, StringComparison.Ordinal); + Assert.Contains("device.Signals.Add(control)", projection, StringComparison.Ordinal); + Assert.Contains("if (string.IsNullOrWhiteSpace(exactFeedbackReference))", projection, StringComparison.Ordinal); + + var controlSelection = authority.IndexOf( ".Where(signal => signal.IsControlSignal && signal.IsValidControlObject)", - authority, StringComparison.Ordinal); - Assert.Contains( - "LiteralEquals(signal.DataSetReference, membership.DataSetReference)", - authority, + var runtimeCandidates = authority.IndexOf( + "var candidates = signals", StringComparison.Ordinal); - Assert.Contains( - "LiteralEquals(signal.DisplayReference, memberReference)", - authority, + var noRuntimeCandidate = authority.IndexOf( + "if (candidates.Length == 0)", StringComparison.Ordinal); + + Assert.True(controlSelection >= 0); + Assert.True(runtimeCandidates > controlSelection); + Assert.True(noRuntimeCandidate > controlSelection); Assert.Contains("selected.Add(control)", authority, StringComparison.Ordinal); + } + + [Fact] + public void ServiceLeavesNeverBecomeProcessSignalsOrCommandTargets() + { + var projection = File.ReadAllText(FindRepoFile( + "Services/Iec61850StaticControlStatusProjectionService.cs")); + var runtime = File.ReadAllText(FindRepoFile( + "Services/Iec61850MonitorRuntime.cs")); + + foreach (var leaf in new[] { "ctlModel", "ctlVal", "SBO", "SBOw", "Oper", "Cancel", "origin", "Check", "Test" }) + Assert.Contains($"\"{leaf}\"", projection, StringComparison.Ordinal); - // The raw control DO is selected only so ctlModel inspection / Command Panel can use - // it. Monitoring still admits only the separate non-control ST status projection. + Assert.Contains("ControlServicePathSegments.Contains(segment)", projection, StringComparison.Ordinal); Assert.Contains( ".Where(signal => signal.IsSelected && signal.CanPublishToRuntime)", runtime, @@ -100,7 +160,7 @@ public void StaticAuthority_SelectsExactControlCompanionButRuntimeStillRejectsRa } [Fact] - public void SharedStaticWorkflow_MaterializesDualRoleStatusBeforeAuthoritySelection() + public void SharedStaticWorkflow_MaterializesControlsBeforeAuthoritySelection() { var registration = File.ReadAllText(FindRepoFile("MainWindow.DataSetSignalInventory.cs")); var shared = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); @@ -113,7 +173,9 @@ public void SharedStaticWorkflow_MaterializesDualRoleStatusBeforeAuthoritySelect "merge.AddedSignals.Concat(controlStatusProjection.AddedSignals)", registration, StringComparison.Ordinal); - Assert.Contains("control service leaves remain excluded", registration, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AddedControlCount", registration, StringComparison.Ordinal); + Assert.Contains("AddedRuntimeFeedbackCount", registration, StringComparison.Ordinal); + Assert.Contains("Commands remain gated by live ctlModel", registration, StringComparison.Ordinal); var registerIndex = shared.IndexOf("RegisterRecoveredDataSetSignals(device, merge)", StringComparison.Ordinal); var authorityIndex = shared.IndexOf("Iec61850StaticDataSetAuthoritySelection.Build(device)", StringComparison.Ordinal); From 64828861609927c7add47b4e26882682436cd383 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 18:07:53 +0700 Subject: [PATCH 41/48] Add shared Engineering/FAT command bridge --- MainWindow.IoFatCommandBridge.cs | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 MainWindow.IoFatCommandBridge.cs diff --git a/MainWindow.IoFatCommandBridge.cs b/MainWindow.IoFatCommandBridge.cs new file mode 100644 index 00000000..1fe1f795 --- /dev/null +++ b/MainWindow.IoFatCommandBridge.cs @@ -0,0 +1,60 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + /// + /// Resolves the Engineering monitor device that owns one FAT IED plan. + /// FAT control must never create a second MMS/control stack: the exact same + /// SignalDefinition instances, ctlModel inspection, control service, wire evidence, + /// and process-feedback correlation used by Engineering remain authoritative. + /// + internal Iec61850MonitorDevice? ResolveIoFatCommandDevice(IoTestIedPlan? ied) + { + if (ied is null) + return null; + + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device is null) + return null; + + // CommandSignals contains only controls whose live ctlModel has proved that + // operation is allowed and whose UI command semantics are supported. Keep an + // explicit owner mapping so a FAT command cannot accidentally fall back to the + // Engineering tab's currently selected IED in a multi-IED workspace. + device.RefreshCommandSignalProjection(); + foreach (var signal in device.Signals.Where(signal => signal.IsControlSignal && signal.IsValidControlObject)) + _signalOwners[signal] = device; + + return device; + } + + internal async Task RefreshIoFatCommandValuesAsync(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + // Preload is the same serialized live ctlModel authority used by the Engineering + // Command Panel. StatusOnly stays read-only and never enters CommandSignals. + await PreloadControlModelsAsync(); + device.RefreshCommandSignalProjection(); + + foreach (var signal in device.Signals.Where(signal => signal.IsControlSignal && signal.IsValidControlObject)) + _signalOwners[signal] = device; + + if (device.IsConnected && device.CommandSignals.Count > 0) + await RefreshControlValuesAsync(device, force: true); + + device.RefreshCommandSignalProjection(); + } + + internal Task ExecuteIoFatControlClaimAsync(SignalDefinition signal, ControlCommandClaim claim) + { + ArgumentNullException.ThrowIfNull(signal); + ArgumentNullException.ThrowIfNull(claim); + return ExecuteClaimedControlAsync(signal, claim); + } +} From 2dadaab82163b75411135fe1e48507795894213f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 18:09:02 +0700 Subject: [PATCH 42/48] Add command panel to FAT workspace --- IoListTestingWindow.CommandPanel.cs | 537 ++++++++++++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 IoListTestingWindow.CommandPanel.cs diff --git a/IoListTestingWindow.CommandPanel.cs b/IoListTestingWindow.CommandPanel.cs new file mode 100644 index 00000000..12ddd4ea --- /dev/null +++ b/IoListTestingWindow.CommandPanel.cs @@ -0,0 +1,537 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Media; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + private Border? _fatCommandPanelShell; + private StackPanel? _fatCommandRows; + private TextBlock? _fatCommandSummary; + private Iec61850MonitorDevice? _fatCommandDevice; + private readonly HashSet _fatCommandSubscribedSignals = new(); + private bool _fatCommandPanelLifecycleInstalled; + + protected override void OnInitialized(EventArgs e) + { + base.OnInitialized(e); + if (_fatCommandPanelLifecycleInstalled) + return; + + _fatCommandPanelLifecycleInstalled = true; + Loaded += FatCommandPanelWindow_Loaded; + Closed += FatCommandPanelWindow_Closed; + PropertyChanged += FatCommandPanelWindow_PropertyChanged; + } + + private async void FatCommandPanelWindow_Loaded(object sender, RoutedEventArgs e) + { + InstallFatCommandPanel(); + await RefreshFatCommandPanelAsync(); + } + + private void FatCommandPanelWindow_Closed(object? sender, EventArgs e) + { + PropertyChanged -= FatCommandPanelWindow_PropertyChanged; + DetachFatCommandDevice(); + } + + private void FatCommandPanelWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedIed)) + return; + + Dispatcher.BeginInvoke(new Action(async () => await RefreshFatCommandPanelAsync())); + } + + private void InstallFatCommandPanel() + { + if (_fatCommandPanelShell != null) + return; + + var fatGrid = FindFatCommandVisualChildren(this) + .FirstOrDefault(grid => + BindingOperations.GetBinding(grid, ItemsControl.ItemsSourceProperty)?.Path?.Path == "SelectedIed.TestPoints") + ?? FindFatCommandVisualChildren(this).FirstOrDefault(); + if (fatGrid?.Parent is not Grid hostGrid) + return; + + // The signal table remains the flexible row. The command panel is a compact, + // independently scrolling operating surface below it, so large FAT workbooks do not + // lose their primary evidence area and large control inventories remain usable. + hostGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(10) }); + hostGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var header = new Grid(); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var heading = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + heading.Children.Add(new TextBlock + { + Text = "IED COMMAND PANEL", + FontSize = 10.5, + FontWeight = FontWeights.Bold, + Foreground = FatCommandBrush("#2F6FD6") + }); + _fatCommandSummary = new TextBlock + { + Text = "Select a connected IED to load SCL/DataSet control objects.", + Margin = new Thickness(0, 3, 0, 0), + FontSize = 10.5, + Foreground = FatCommandBrush("#697A90") + }; + heading.Children.Add(_fatCommandSummary); + header.Children.Add(heading); + + var refresh = FatCommandButton("Refresh values", "SoftButton"); + refresh.Padding = new Thickness(10, 6, 10, 6); + refresh.Click += async (_, _) => await RefreshFatCommandPanelAsync(); + Grid.SetColumn(refresh, 1); + header.Children.Add(refresh); + + _fatCommandRows = new StackPanel(); + var scroller = new ScrollViewer + { + Margin = new Thickness(0, 9, 0, 0), + MaxHeight = 174, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + Content = _fatCommandRows + }; + + var content = new StackPanel(); + content.Children.Add(header); + content.Children.Add(scroller); + + _fatCommandPanelShell = new Border + { + Background = FatCommandBrush("#F7FAFF"), + BorderBrush = FatCommandBrush("#D7E2F1"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(13), + Padding = new Thickness(11, 9, 11, 9), + MaxHeight = 238, + Child = content + }; + + Grid.SetRow(_fatCommandPanelShell, hostGrid.RowDefinitions.Count - 1); + hostGrid.Children.Add(_fatCommandPanelShell); + } + + private async Task RefreshFatCommandPanelAsync() + { + if (_fatCommandRows == null || _fatCommandSummary == null) + return; + if (Owner is not MainWindow engineeringWindow) + { + DetachFatCommandDevice(); + _fatCommandSummary.Text = "Engineering owner unavailable; control is disabled fail-closed."; + RebuildFatCommandRows(); + return; + } + + var device = engineeringWindow.ResolveIoFatCommandDevice(SelectedIed); + AttachFatCommandDevice(device); + RebuildFatCommandRows(); + if (device == null) + { + _fatCommandSummary.Text = "No shared Engineering IED is bound to the selected FAT device."; + return; + } + + if (!device.IsConnected) + { + _fatCommandSummary.Text = $"{device.Name} is not connected; command actions remain disabled."; + return; + } + + _fatCommandSummary.Text = $"{device.Name} · validating live ctlModel and command values…"; + try + { + await engineeringWindow.RefreshIoFatCommandValuesAsync(device); + AttachFatCommandDevice(device); + RebuildFatCommandRows(); + } + catch (OperationCanceledException) + { + _fatCommandSummary.Text = $"{device.Name} · command refresh cancelled."; + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ArgumentException) + { + _fatCommandSummary.Text = $"{device.Name} · command refresh unavailable: {ex.Message}"; + } + } + + private void AttachFatCommandDevice(Iec61850MonitorDevice? device) + { + if (ReferenceEquals(_fatCommandDevice, device)) + return; + + DetachFatCommandDevice(); + _fatCommandDevice = device; + if (_fatCommandDevice == null) + return; + + _fatCommandDevice.CommandSignals.CollectionChanged += FatCommandSignals_CollectionChanged; + } + + private void DetachFatCommandDevice() + { + if (_fatCommandDevice != null) + _fatCommandDevice.CommandSignals.CollectionChanged -= FatCommandSignals_CollectionChanged; + + foreach (var signal in _fatCommandSubscribedSignals) + signal.PropertyChanged -= FatCommandSignal_PropertyChanged; + _fatCommandSubscribedSignals.Clear(); + _fatCommandDevice = null; + } + + private void FatCommandSignals_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + => Dispatcher.BeginInvoke(new Action(RebuildFatCommandRows)); + + private void FatCommandSignal_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(SignalDefinition.ControlSetPointText) + or nameof(SignalDefinition.ControlInterlockCheck) + or nameof(SignalDefinition.ControlSynchroCheck) + or nameof(SignalDefinition.ControlTestMode)) + { + return; + } + + if (e.PropertyName is nameof(SignalDefinition.ControlCurrentValue) + or nameof(SignalDefinition.ControlLastResult) + or nameof(SignalDefinition.ControlConfirmationPending) + or nameof(SignalDefinition.ControlCommandBusy) + or nameof(SignalDefinition.ControlInspectionBusy) + or nameof(SignalDefinition.ControlModelText) + or nameof(SignalDefinition.ControlCdc) + or nameof(SignalDefinition.ControlSupportsOperate)) + { + Dispatcher.BeginInvoke(new Action(RebuildFatCommandRows)); + } + } + + private void RebuildFatCommandRows() + { + if (_fatCommandRows == null || _fatCommandSummary == null) + return; + + foreach (var signal in _fatCommandSubscribedSignals) + signal.PropertyChanged -= FatCommandSignal_PropertyChanged; + _fatCommandSubscribedSignals.Clear(); + _fatCommandRows.Children.Clear(); + + var device = _fatCommandDevice; + if (device == null) + { + _fatCommandRows.Children.Add(FatCommandEmptyText("No FAT command device selected.")); + return; + } + + var commands = device.CommandSignals.ToArray(); + _fatCommandSummary.Text = commands.Length == 0 + ? $"{device.Name} · no operable control is proven by live ctlModel. Status-only controls remain read-only." + : $"{device.Name} · {commands.Length} operable DataSet control(s) · shared Engineering command backend"; + + if (commands.Length == 0) + { + _fatCommandRows.Children.Add(FatCommandEmptyText( + "No command action is available. Controls appear only after live ctlModel proves Direct/SBO operation; StatusOnly and unsupported generic types stay fail-closed.")); + return; + } + + foreach (var signal in commands) + { + signal.PropertyChanged += FatCommandSignal_PropertyChanged; + _fatCommandSubscribedSignals.Add(signal); + _fatCommandRows.Children.Add(BuildFatCommandRow(signal)); + } + } + + private FrameworkElement BuildFatCommandRow(SignalDefinition signal) + { + var row = new Grid + { + Margin = new Thickness(0, 0, 0, 6), + Background = Brushes.White + }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(2.25, GridUnitType.Star), MinWidth = 210 }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.8, GridUnitType.Star), MinWidth = 82 }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.72, GridUnitType.Star), MinWidth = 70 }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.45, GridUnitType.Star), MinWidth = 145 }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.1, GridUnitType.Star), MinWidth = 150 }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.55, GridUnitType.Star), MinWidth = 190 }); + + var reference = FatCommandText(signal.ObjectReference, 11.0, FontWeights.SemiBold); + reference.FontFamily = new FontFamily("Cascadia Mono, Consolas"); + reference.ToolTip = signal.ObjectReference; + AddFatCommandCell(row, reference, 0); + + var current = FatCommandText(signal.ControlCurrentValue, 11.0, FontWeights.SemiBold); + current.ToolTip = signal.ControlLastResult; + AddFatCommandCell(row, current, 1); + + AddFatCommandCell(row, FatCommandText( + string.IsNullOrWhiteSpace(signal.ControlCdc) ? "—" : signal.ControlCdc, + 10.8, + FontWeights.SemiBold), 2); + AddFatCommandCell(row, FatCommandText(FatCommandModelText(signal.ControlModelText), 10.5, FontWeights.SemiBold), 3); + AddFatCommandCell(row, BuildFatCommandChecks(signal), 4); + AddFatCommandCell(row, BuildFatCommandActions(signal), 5); + + return new Border + { + Background = Brushes.White, + BorderBrush = FatCommandBrush("#E2E8F1"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(9), + Padding = new Thickness(8, 6, 8, 6), + Child = row + }; + } + + private FrameworkElement BuildFatCommandChecks(SignalDefinition signal) + { + var panel = new WrapPanel { VerticalAlignment = VerticalAlignment.Center }; + panel.Children.Add(FatCommandCheck("Interlock", signal, nameof(SignalDefinition.ControlInterlockCheck))); + panel.Children.Add(FatCommandCheck("Sync", signal, nameof(SignalDefinition.ControlSynchroCheck))); + panel.Children.Add(FatCommandCheck("Test", signal, nameof(SignalDefinition.ControlTestMode))); + return panel; + } + + private CheckBox FatCommandCheck(string text, SignalDefinition signal, string propertyName) + { + var check = new CheckBox + { + Content = text, + DataContext = signal, + Margin = new Thickness(0, 0, 7, 0), + FontSize = 9.6, + VerticalAlignment = VerticalAlignment.Center + }; + check.SetBinding(ToggleButton.IsCheckedProperty, new Binding(propertyName) + { + Source = signal, + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged + }); + return check; + } + + private FrameworkElement BuildFatCommandActions(SignalDefinition signal) + { + var panel = new WrapPanel { VerticalAlignment = VerticalAlignment.Center }; + if (signal.IsPositionControl) + { + if (signal.ControlConfirmationPending) + { + var confirm = FatCommandButton("Confirm", "PrimaryButton"); + confirm.Click += async (_, _) => await ConfirmFatPositionControlAsync(signal); + panel.Children.Add(confirm); + var cancel = FatCommandButton("Cancel", "SoftButton"); + cancel.Margin = new Thickness(6, 0, 0, 0); + cancel.Click += (_, _) => + { + signal.ClearControlConfirmation(); + RebuildFatCommandRows(); + }; + panel.Children.Add(cancel); + } + else + { + var open = FatCommandButton("Open", "CommandOpenButton"); + open.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + open.Click += (_, _) => StageFatPositionControl(signal, "Open [01]", "Open"); + panel.Children.Add(open); + + var close = FatCommandButton("Close", "CommandCloseButton"); + close.Margin = new Thickness(6, 0, 0, 0); + close.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + close.Click += (_, _) => StageFatPositionControl(signal, "Closed [10]", "Close"); + panel.Children.Add(close); + } + return panel; + } + + if (signal.IsRaiseOnlyControl) + { + panel.Children.Add(FatQuickCommandButton(signal, "Raise", "Raise", "PrimaryButton")); + return panel; + } + if (signal.IsLowerOnlyControl) + { + panel.Children.Add(FatQuickCommandButton(signal, "Lower", "Lower", "SoftButton")); + return panel; + } + if (signal.IsRaiseLowerControl) + { + panel.Children.Add(FatQuickCommandButton(signal, "Raise", "Raise", "PrimaryButton")); + var lower = FatQuickCommandButton(signal, "Lower", "Lower", "SoftButton"); + lower.Margin = new Thickness(6, 0, 0, 0); + panel.Children.Add(lower); + return panel; + } + if (signal.IsBooleanControl) + { + panel.Children.Add(FatQuickCommandButton(signal, "True", "True", "CommandCloseButton")); + var off = FatQuickCommandButton(signal, "False", "False", "CommandOpenButton"); + off.Margin = new Thickness(6, 0, 0, 0); + panel.Children.Add(off); + return panel; + } + if (signal.IsSetPointControl) + { + var target = new TextBox + { + Width = 88, + Height = 29, + Padding = new Thickness(6, 3, 6, 3), + Margin = new Thickness(0, 0, 6, 0), + ToolTip = "Target value" + }; + target.SetBinding(TextBox.TextProperty, new Binding(nameof(SignalDefinition.ControlSetPointText)) + { + Source = signal, + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged + }); + panel.Children.Add(target); + var set = FatCommandButton("Set", "PrimaryButton"); + set.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + set.Click += async (_, _) => await ExecuteFatQuickControlAsync(signal, signal.ControlSetPointText, "Set"); + panel.Children.Add(set); + return panel; + } + + panel.Children.Add(FatCommandEmptyText("No safe quick action")); + return panel; + } + + private Button FatQuickCommandButton(SignalDefinition signal, string label, string requestedValue, string styleKey) + { + var button = FatCommandButton(label, styleKey); + button.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + button.Click += async (_, _) => await ExecuteFatQuickControlAsync(signal, requestedValue, label); + return button; + } + + private void StageFatPositionControl(SignalDefinition signal, string requestedValue, string actionLabel) + { + if (!signal.TryStageControlConfirmation(requestedValue, actionLabel, out var rejectionReason)) + { + signal.ControlLastResult = $"Command rejected: {rejectionReason}."; + return; + } + + RebuildFatCommandRows(); + } + + private async Task ConfirmFatPositionControlAsync(SignalDefinition signal) + { + if (Owner is not MainWindow engineeringWindow) + return; + if (!signal.TryClaimControlConfirmation(out var claim, out var rejectionReason) || claim == null) + { + signal.ControlLastResult = $"Command rejected: {rejectionReason}."; + return; + } + + await engineeringWindow.ExecuteIoFatControlClaimAsync(signal, claim); + RebuildFatCommandRows(); + } + + private async Task ExecuteFatQuickControlAsync(SignalDefinition signal, string requestedValue, string actionLabel) + { + if (Owner is not MainWindow engineeringWindow) + return; + if (!signal.TryBeginDirectControlCommand(requestedValue, actionLabel, out var claim, out var rejectionReason) || claim == null) + { + signal.ControlLastResult = $"Command rejected: {rejectionReason}."; + return; + } + + await engineeringWindow.ExecuteIoFatControlClaimAsync(signal, claim); + RebuildFatCommandRows(); + } + + private Button FatCommandButton(string text, string styleKey) + { + var button = new Button + { + Content = text, + MinHeight = 29, + MinWidth = 58, + Padding = new Thickness(10, 5, 10, 5), + FontSize = 10.2, + VerticalAlignment = VerticalAlignment.Center + }; + if (TryFindResource(styleKey) is Style style) + button.Style = style; + return button; + } + + private static void AddFatCommandCell(Grid row, FrameworkElement child, int column) + { + child.Margin = column == 0 ? new Thickness(0) : new Thickness(8, 0, 0, 0); + child.VerticalAlignment = VerticalAlignment.Center; + Grid.SetColumn(child, column); + row.Children.Add(child); + } + + private static TextBlock FatCommandText(string text, double size, FontWeight weight) + => new() + { + Text = string.IsNullOrWhiteSpace(text) ? "—" : text, + FontSize = size, + FontWeight = weight, + Foreground = FatCommandBrush("#34465D"), + TextTrimming = TextTrimming.CharacterEllipsis, + VerticalAlignment = VerticalAlignment.Center + }; + + private static TextBlock FatCommandEmptyText(string text) + => new() + { + Text = text, + FontSize = 10.3, + Foreground = FatCommandBrush("#75859A"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(2, 4, 2, 4) + }; + + private static string FatCommandModelText(string? value) + { + var text = value?.Trim() ?? string.Empty; + if (text.Contains("select before operate", StringComparison.OrdinalIgnoreCase) || text.Contains("SBO", StringComparison.OrdinalIgnoreCase)) + return text.Contains("enhanced", StringComparison.OrdinalIgnoreCase) ? "SBO • Enhanced" : "SBO • Normal"; + if (text.Contains("direct", StringComparison.OrdinalIgnoreCase)) + return text.Contains("enhanced", StringComparison.OrdinalIgnoreCase) ? "Direct • Enhanced" : "Direct • Normal"; + if (text.Contains("status", StringComparison.OrdinalIgnoreCase)) + return "Status only"; + return string.IsNullOrWhiteSpace(text) ? "Reading…" : text; + } + + private static Brush FatCommandBrush(string value) + => new SolidColorBrush((Color)ColorConverter.ConvertFromString(value)); + + private static IEnumerable FindFatCommandVisualChildren(DependencyObject root) where T : DependencyObject + { + var count = VisualTreeHelper.GetChildrenCount(root); + for (var i = 0; i < count; i++) + { + var child = VisualTreeHelper.GetChild(root, i); + if (child is T match) + yield return match; + foreach (var descendant in FindFatCommandVisualChildren(child)) + yield return descendant; + } + } +} From 09e9194f4ddfe55d9832ab8e53424222f603ca8e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 18:09:25 +0700 Subject: [PATCH 43/48] Guard shared FAT command panel contract --- .../IoListFatCommandPanelRegressionTests.cs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs diff --git a/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs b/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs new file mode 100644 index 00000000..78c9c81b --- /dev/null +++ b/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs @@ -0,0 +1,90 @@ +namespace ARSAS.Tests; + +public sealed class IoListFatCommandPanelRegressionTests +{ + [Fact] + public void FatCommandPanel_UsesSharedEngineeringDeviceAndCommandCollection() + { + var bridge = File.ReadAllText(FindRepoFile("MainWindow.IoFatCommandBridge.cs")); + var panel = File.ReadAllText(FindRepoFile("IoListTestingWindow.CommandPanel.cs")); + + Assert.Contains("ResolveIoTestDevice(ied.LiveDeviceId)", bridge, StringComparison.Ordinal); + Assert.Contains("ResolveIoTestDevice(ied.IpAddress)", bridge, StringComparison.Ordinal); + Assert.Contains("ResolveIoTestDevice(ied.IedName)", bridge, StringComparison.Ordinal); + Assert.Contains("device.RefreshCommandSignalProjection()", bridge, StringComparison.Ordinal); + Assert.Contains("_signalOwners[signal] = device", bridge, StringComparison.Ordinal); + Assert.Contains("device.CommandSignals", panel, StringComparison.Ordinal); + Assert.Contains("shared Engineering command backend", panel, StringComparison.Ordinal); + + // FAT is only another operating surface. It must never construct a second MMS + // control client or bypass the Engineering control execution/evidence pipeline. + Assert.DoesNotContain("ExecuteControlAsync", panel, StringComparison.Ordinal); + Assert.DoesNotContain("_runtime.", panel, StringComparison.Ordinal); + Assert.Contains("ExecuteIoFatControlClaimAsync", panel, StringComparison.Ordinal); + Assert.Contains("return ExecuteClaimedControlAsync(signal, claim)", bridge, StringComparison.Ordinal); + } + + [Fact] + public void FatCommandPanel_PreservesEngineeringSafetyAndControlActions() + { + var panel = File.ReadAllText(FindRepoFile("IoListTestingWindow.CommandPanel.cs")); + + Assert.Contains("TryStageControlConfirmation", panel, StringComparison.Ordinal); + Assert.Contains("TryClaimControlConfirmation", panel, StringComparison.Ordinal); + Assert.Contains("TryBeginDirectControlCommand", panel, StringComparison.Ordinal); + Assert.Contains("\"Open [01]\"", panel, StringComparison.Ordinal); + Assert.Contains("\"Closed [10]\"", panel, StringComparison.Ordinal); + Assert.Contains("\"True\"", panel, StringComparison.Ordinal); + Assert.Contains("\"False\"", panel, StringComparison.Ordinal); + Assert.Contains("\"Raise\"", panel, StringComparison.Ordinal); + Assert.Contains("\"Lower\"", panel, StringComparison.Ordinal); + Assert.Contains("ControlSetPointText", panel, StringComparison.Ordinal); + Assert.Contains("ControlInterlockCheck", panel, StringComparison.Ordinal); + Assert.Contains("ControlSynchroCheck", panel, StringComparison.Ordinal); + Assert.Contains("ControlTestMode", panel, StringComparison.Ordinal); + } + + [Fact] + public void FatCommandPanel_RemainsFailClosedForStatusOnlyOrUnsupportedControls() + { + var bridge = File.ReadAllText(FindRepoFile("MainWindow.IoFatCommandBridge.cs")); + var panel = File.ReadAllText(FindRepoFile("IoListTestingWindow.CommandPanel.cs")); + var monitorModels = File.ReadAllText(FindRepoFile("Models/MonitorModels.cs")); + + Assert.Contains("live ctlModel", bridge, StringComparison.OrdinalIgnoreCase); + Assert.Contains("StatusOnly stays read-only", bridge, StringComparison.Ordinal); + Assert.Contains("device.CommandSignals", bridge, StringComparison.Ordinal); + Assert.Contains("ControlSupportsOperate", monitorModels, StringComparison.Ordinal); + Assert.Contains("IsGenericControl", monitorModels, StringComparison.Ordinal); + Assert.Contains("Status-only controls remain read-only", panel, StringComparison.Ordinal); + Assert.Contains("unsupported generic types stay fail-closed", panel, StringComparison.Ordinal); + } + + [Fact] + public void FatSignalGridAndCommandPanelRemainSeparateSurfaces() + { + var panel = File.ReadAllText(FindRepoFile("IoListTestingWindow.CommandPanel.cs")); + var runtime = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + + Assert.Contains("SelectedIed.TestPoints", panel, StringComparison.Ordinal); + Assert.Contains("IED COMMAND PANEL", panel, StringComparison.Ordinal); + Assert.Contains("CommandSignals", panel, StringComparison.Ordinal); + Assert.Contains("signal.IsSelected && signal.CanPublishToRuntime", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("CanPublishToRuntime || signal.IsControlSignal", runtime, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From a82b0d23cc79b9223698c129db1543c43283af54 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 18:14:49 +0700 Subject: [PATCH 44/48] Fix FAT command panel lifecycle hook --- IoListTestingWindow.CommandPanel.cs | 71 ++++++++++++++++------------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/IoListTestingWindow.CommandPanel.cs b/IoListTestingWindow.CommandPanel.cs index 12ddd4ea..034fc101 100644 --- a/IoListTestingWindow.CommandPanel.cs +++ b/IoListTestingWindow.CommandPanel.cs @@ -5,6 +5,7 @@ using System.Windows.Controls.Primitives; using System.Windows.Data; using System.Windows.Media; +using System.Windows.Threading; using ArIED61850Tester.Models; namespace ArIED61850Tester; @@ -18,27 +19,39 @@ public partial class IoListTestingWindow private readonly HashSet _fatCommandSubscribedSignals = new(); private bool _fatCommandPanelLifecycleInstalled; - protected override void OnInitialized(EventArgs e) - { - base.OnInitialized(e); - if (_fatCommandPanelLifecycleInstalled) - return; + // Register at class level rather than overriding OnInitialized. IoListTestingWindow + // already owns an initialization override in another partial; FAT command UI must be + // additive and must not compete with the existing workspace lifecycle. + private static readonly bool FatCommandPanelClassHandlerRegistered = RegisterFatCommandPanelClassHandler(); - _fatCommandPanelLifecycleInstalled = true; - Loaded += FatCommandPanelWindow_Loaded; - Closed += FatCommandPanelWindow_Closed; - PropertyChanged += FatCommandPanelWindow_PropertyChanged; + private static bool RegisterFatCommandPanelClassHandler() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(FatCommandPanelClassLoaded)); + return true; } - private async void FatCommandPanelWindow_Loaded(object sender, RoutedEventArgs e) + private static void FatCommandPanelClassLoaded(object sender, RoutedEventArgs e) { - InstallFatCommandPanel(); - await RefreshFatCommandPanelAsync(); + if (sender is not IoListTestingWindow window || window._fatCommandPanelLifecycleInstalled) + return; + + window._fatCommandPanelLifecycleInstalled = true; + window.PropertyChanged += window.FatCommandPanelWindow_PropertyChanged; + window.Closed += window.FatCommandPanelWindow_Closed; + window.Dispatcher.BeginInvoke(new Action(async () => + { + window.InstallFatCommandPanel(); + await window.RefreshFatCommandPanelAsync(); + }), DispatcherPriority.ContextIdle); } private void FatCommandPanelWindow_Closed(object? sender, EventArgs e) { PropertyChanged -= FatCommandPanelWindow_PropertyChanged; + Closed -= FatCommandPanelWindow_Closed; DetachFatCommandDevice(); } @@ -47,7 +60,7 @@ private void FatCommandPanelWindow_PropertyChanged(object? sender, PropertyChang if (e.PropertyName != nameof(SelectedIed)) return; - Dispatcher.BeginInvoke(new Action(async () => await RefreshFatCommandPanelAsync())); + Dispatcher.BeginInvoke(new Action(async () => await RefreshFatCommandPanelAsync()), DispatcherPriority.Background); } private void InstallFatCommandPanel() @@ -62,9 +75,8 @@ private void InstallFatCommandPanel() if (fatGrid?.Parent is not Grid hostGrid) return; - // The signal table remains the flexible row. The command panel is a compact, - // independently scrolling operating surface below it, so large FAT workbooks do not - // lose their primary evidence area and large control inventories remain usable. + // Keep the FAT evidence table as the flexible row. Controls get their own compact, + // independently scrolling surface below it so a large I/O list remains usable. hostGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(10) }); hostGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); @@ -102,7 +114,7 @@ private void InstallFatCommandPanel() Margin = new Thickness(0, 9, 0, 0), MaxHeight = 174, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, Content = _fatCommandRows }; @@ -129,6 +141,7 @@ private async Task RefreshFatCommandPanelAsync() { if (_fatCommandRows == null || _fatCommandSummary == null) return; + if (Owner is not MainWindow engineeringWindow) { DetachFatCommandDevice(); @@ -176,10 +189,8 @@ private void AttachFatCommandDevice(Iec61850MonitorDevice? device) DetachFatCommandDevice(); _fatCommandDevice = device; - if (_fatCommandDevice == null) - return; - - _fatCommandDevice.CommandSignals.CollectionChanged += FatCommandSignals_CollectionChanged; + if (_fatCommandDevice != null) + _fatCommandDevice.CommandSignals.CollectionChanged += FatCommandSignals_CollectionChanged; } private void DetachFatCommandDevice() @@ -194,7 +205,7 @@ private void DetachFatCommandDevice() } private void FatCommandSignals_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - => Dispatcher.BeginInvoke(new Action(RebuildFatCommandRows)); + => Dispatcher.BeginInvoke(new Action(RebuildFatCommandRows), DispatcherPriority.Background); private void FatCommandSignal_PropertyChanged(object? sender, PropertyChangedEventArgs e) { @@ -215,7 +226,7 @@ or nameof(SignalDefinition.ControlModelText) or nameof(SignalDefinition.ControlCdc) or nameof(SignalDefinition.ControlSupportsOperate)) { - Dispatcher.BeginInvoke(new Action(RebuildFatCommandRows)); + Dispatcher.BeginInvoke(new Action(RebuildFatCommandRows), DispatcherPriority.Background); } } @@ -258,11 +269,7 @@ private void RebuildFatCommandRows() private FrameworkElement BuildFatCommandRow(SignalDefinition signal) { - var row = new Grid - { - Margin = new Thickness(0, 0, 0, 6), - Background = Brushes.White - }; + var row = new Grid { MinWidth = 960 }; row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(2.25, GridUnitType.Star), MinWidth = 210 }); row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.8, GridUnitType.Star), MinWidth = 82 }); row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.72, GridUnitType.Star), MinWidth = 70 }); @@ -278,7 +285,6 @@ private FrameworkElement BuildFatCommandRow(SignalDefinition signal) var current = FatCommandText(signal.ControlCurrentValue, 11.0, FontWeights.SemiBold); current.ToolTip = signal.ControlLastResult; AddFatCommandCell(row, current, 1); - AddFatCommandCell(row, FatCommandText( string.IsNullOrWhiteSpace(signal.ControlCdc) ? "—" : signal.ControlCdc, 10.8, @@ -294,6 +300,7 @@ private FrameworkElement BuildFatCommandRow(SignalDefinition signal) BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(0, 0, 0, 6), Child = row }; } @@ -307,7 +314,7 @@ private FrameworkElement BuildFatCommandChecks(SignalDefinition signal) return panel; } - private CheckBox FatCommandCheck(string text, SignalDefinition signal, string propertyName) + private static CheckBox FatCommandCheck(string text, SignalDefinition signal, string propertyName) { var check = new CheckBox { @@ -329,6 +336,7 @@ private CheckBox FatCommandCheck(string text, SignalDefinition signal, string pr private FrameworkElement BuildFatCommandActions(SignalDefinition signal) { var panel = new WrapPanel { VerticalAlignment = VerticalAlignment.Center }; + if (signal.IsPositionControl) { if (signal.ControlConfirmationPending) @@ -336,6 +344,7 @@ private FrameworkElement BuildFatCommandActions(SignalDefinition signal) var confirm = FatCommandButton("Confirm", "PrimaryButton"); confirm.Click += async (_, _) => await ConfirmFatPositionControlAsync(signal); panel.Children.Add(confirm); + var cancel = FatCommandButton("Cancel", "SoftButton"); cancel.Margin = new Thickness(6, 0, 0, 0); cancel.Click += (_, _) => @@ -404,6 +413,7 @@ private FrameworkElement BuildFatCommandActions(SignalDefinition signal) UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }); panel.Children.Add(target); + var set = FatCommandButton("Set", "PrimaryButton"); set.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; set.Click += async (_, _) => await ExecuteFatQuickControlAsync(signal, signal.ControlSetPointText, "Set"); @@ -430,7 +440,6 @@ private void StageFatPositionControl(SignalDefinition signal, string requestedVa signal.ControlLastResult = $"Command rejected: {rejectionReason}."; return; } - RebuildFatCommandRows(); } From 4691e3b27075be5ab3705e630ae684d925336ee8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 19:24:29 +0700 Subject: [PATCH 45/48] Harden FAT command feedback and Stop UX --- IoListTestingWindow.FatFieldUx.cs | 303 ++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 IoListTestingWindow.FatFieldUx.cs diff --git a/IoListTestingWindow.FatFieldUx.cs b/IoListTestingWindow.FatFieldUx.cs new file mode 100644 index 00000000..4aff1d06 --- /dev/null +++ b/IoListTestingWindow.FatFieldUx.cs @@ -0,0 +1,303 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Bench-focused FAT UX hardening that is intentionally additive to the existing +/// report-only acquisition and command execution paths. No MMS/RCB/DataSet behavior +/// is changed here. +/// +public partial class IoListTestingWindow +{ + private bool _fatFieldUxInstalled; + private bool _fatStopLayoutInstalled; + private Popup? _fatCommandFailureShout; + private TextBlock? _fatCommandFailureShoutText; + private DispatcherTimer? _fatCommandFailureShoutTimer; + private Iec61850MonitorDevice? _fatFieldCommandDevice; + private readonly HashSet _fatFieldSubscribedSignals = new(); + private readonly HashSet _fatFieldDefaultsInitialized = new(); + + private static readonly bool FatFieldUxClassHandlerRegistered = RegisterFatFieldUxClassHandler(); + + private static bool RegisterFatFieldUxClassHandler() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(FatFieldUxClassLoaded)); + return true; + } + + private static void FatFieldUxClassLoaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._fatFieldUxInstalled) + return; + + window._fatFieldUxInstalled = true; + window.PropertyChanged += window.FatFieldUxWindow_PropertyChanged; + window.Closed += window.FatFieldUxWindow_Closed; + + // The existing CommandPanel partial installs itself at ContextIdle. Run after it + // so this additive layer can use the same panel shell and shared command device. + window.Dispatcher.BeginInvoke( + new Action(window.InstallFatFieldUx), + DispatcherPriority.ApplicationIdle); + } + + private void InstallFatFieldUx() + { + InstallFatStopLayout(); + InstallFatCommandFailureShout(); + RefreshFatFieldCommandSubscriptions(); + } + + private void FatFieldUxWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedIed)) + return; + + Dispatcher.BeginInvoke( + new Action(() => + { + InstallFatStopLayout(); + InstallFatCommandFailureShout(); + RefreshFatFieldCommandSubscriptions(); + }), + DispatcherPriority.ApplicationIdle); + } + + private void FatFieldUxWindow_Closed(object? sender, EventArgs e) + { + PropertyChanged -= FatFieldUxWindow_PropertyChanged; + Closed -= FatFieldUxWindow_Closed; + DetachFatFieldCommandDevice(); + _fatCommandFailureShoutTimer?.Stop(); + if (_fatCommandFailureShout != null) + _fatCommandFailureShout.IsOpen = false; + } + + /// + /// Keep the critical Stop action in its own reserved Grid column. The surrounding + /// action strip is compacted, but the existing Stop button instance is moved rather + /// than recreated so its command binding, click handler and lifecycle semantics stay + /// exactly the same. + /// + private void InstallFatStopLayout() + { + if (_fatStopLayoutInstalled) + return; + + var stop = FindFatCommandVisualChildren