From ff87fef0f8c2c8b65a8df9202e7c6c58950a81af Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 17:40:52 +0700 Subject: [PATCH 1/4] fix(report): fail closed on ambiguous monitor-point Report reference aliases --- Services/Iec61850MonitorRuntime.cs | 79 ++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 5e834f5e8..a9c412fb8 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -64,6 +64,9 @@ private sealed class DeviceSession public List ActiveReportPlanOrder { get; } = new(); public Dictionary PointPlanIds { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary ReportReferenceIndex { get; } = new(StringComparer.OrdinalIgnoreCase); + // Alias collisions are never resolved by first-insertion order. Lifetime + // matches the report reference index and the current association. + public HashSet AmbiguousReportReferenceKeys { get; } = new(StringComparer.OrdinalIgnoreCase); public PriorityQueue PollQueue { get; } = new(); public Dictionary ReportStreams { get; } = new(StringComparer.OrdinalIgnoreCase); // Active association only: exact ARIEC write-step outcomes for each @@ -838,6 +841,7 @@ public async Task> StartMonitoringAsync( session.ActiveReportPlanOrder.Clear(); session.PointPlanIds.Clear(); session.ReportReferenceIndex.Clear(); + session.AmbiguousReportReferenceKeys.Clear(); session.PollQueue.Clear(); session.ReportStreams.Clear(); session.ReportActivationByPlanId.Clear(); @@ -2958,6 +2962,7 @@ private async Task StopMonitoringSessionAsync(DeviceSession session, bool preser session.Points.Clear(); session.States.Clear(); session.ReportReferenceIndex.Clear(); + session.AmbiguousReportReferenceKeys.Clear(); } } @@ -3351,37 +3356,81 @@ internal static void EnsureDistinctRuntimePointIdentities( private static void IndexPointReference(DeviceSession session, Iec61850MonitorPoint point) { foreach (var key in GetReferenceKeys(point.IecReference)) - session.ReportReferenceIndex.TryAdd(key, point); + AddReportReferenceCandidate( + session.ReportReferenceIndex, session.AmbiguousReportReferenceKeys, key, point); } - private static Iec61850MonitorPoint? FindPointForReportReference(DeviceSession session, string reference) + // One association-scoped index only. When two distinct selected points + // claim a canonical reference, neither may own its Report value. + internal static void AddReportReferenceCandidate( + IDictionary index, + ISet ambiguousKeys, + string key, + Iec61850MonitorPoint candidate) + { + if (ambiguousKeys.Contains(key)) + return; + if (index.TryGetValue(key, out var prior)) + { + if (!ReferenceEquals(prior, candidate)) + { + index.Remove(key); + ambiguousKeys.Add(key); + } + return; + } + index.Add(key, candidate); + } + + private static Iec61850MonitorPoint? FindPointForReportReference( + DeviceSession session, string reference) { foreach (var key in GetReferenceKeys(reference)) { + if (session.AmbiguousReportReferenceKeys.Contains(key)) + continue; if (session.ReportReferenceIndex.TryGetValue(key, out var indexed)) return indexed; } - // Rare vendor projection fallback. Cache the resolved alias so subsequent report - // events do not scan every monitored point again. - var canonical = CanonicalDataReference(reference); - var point = session.Points.Values.FirstOrDefault(candidate => - { - var candidateCanonical = CanonicalDataReference(candidate.IecReference); - return candidateCanonical.Equals(canonical, StringComparison.OrdinalIgnoreCase) || - candidateCanonical.StartsWith(canonical + ".", StringComparison.OrdinalIgnoreCase) || - canonical.StartsWith(candidateCanonical + ".", StringComparison.OrdinalIgnoreCase); - }); - + // Nonexact aliases occur on some older report projections. Never + // select the first child of a structured parent (e.g. phase A), or + // overwrite an existing alias. Only a unique selected point may own + // the alias; otherwise its Report observation remains unrouted. + var point = FindUniqueReportReferenceFallback(session.Points.Values, reference); if (point != null) { foreach (var key in GetReferenceKeys(reference)) - session.ReportReferenceIndex[key] = point; + AddReportReferenceCandidate( + session.ReportReferenceIndex, session.AmbiguousReportReferenceKeys, + key, point); } - return point; } + internal static Iec61850MonitorPoint? FindUniqueReportReferenceFallback( + IEnumerable points, string reference) + { + var canonical = CanonicalDataReference(reference); + if (string.IsNullOrWhiteSpace(canonical)) + return null; + + Iec61850MonitorPoint? unique = null; + foreach (var candidate in points) + { + var candidateCanonical = CanonicalDataReference(candidate.IecReference); + if (!candidateCanonical.Equals(canonical, StringComparison.OrdinalIgnoreCase) && + !candidateCanonical.StartsWith(canonical + ".", StringComparison.OrdinalIgnoreCase) && + !canonical.StartsWith(candidateCanonical + ".", StringComparison.OrdinalIgnoreCase)) + continue; + + if (unique is not null && !ReferenceEquals(unique, candidate)) + return null; // Ambiguous phase/child/FC — fail closed. + unique = candidate; + } + return unique; + } + private static IReadOnlyList ResolveCoveredPoints( ReportControlPlan plan, IReadOnlyList coveredReferences) From 1617cb14b771e2aad57fe5fb0720bd5937fb8912 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 17:40:55 +0700 Subject: [PATCH 2/4] test(report): sibling-phase and canonical alias collision regressions --- .../P0HReportReferenceAmbiguityTests.cs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs diff --git a/tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs b/tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs new file mode 100644 index 000000000..750f28cee --- /dev/null +++ b/tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs @@ -0,0 +1,84 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Prevents a Report parent alias from borrowing the first sibling phase, +/// or a canonical FC collision from inheriting insertion order. +/// +public sealed class P0HReportReferenceAmbiguityTests +{ + private static Iec61850MonitorPoint Point(string reference, string fc = "MX") => new() + { + DeviceId = "IED-1", + IecReference = reference, + FunctionalConstraint = fc + }; + + [Fact] + public void StructuredParentCannotChooseFirstOfMultiplePhases() + { + var first = Point("IEDLD0/MMXU1.A.phsA.cVal.mag.f"); + var second = Point("IEDLD0/MMXU1.A.phsB.cVal.mag.f"); + Assert.Null(Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( + new[] { first, second }, "IEDLD0/MMXU1.A")); + Assert.Null(Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( + new[] { second, first }, "IEDLD0/MMXU1.A")); + } + + [Fact] + public void UniqueLegacyAliasAndExactLeafRemainResolvable() + { + var phaseA = Point("IEDLD0/MMXU1.A.phsA.cVal.mag.f"); + var unrelated = Point("IEDLD0/MMXU1.B.phsB.cVal.mag.f"); + Assert.Same(phaseA, Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( + new[] { phaseA, unrelated }, "IEDLD0/MMXU1.A")); + Assert.Same(phaseA, Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( + new[] { phaseA, unrelated }, "IEDLD0/MMXU1$MX$A$phsA$cVal$mag$f")); + } + + [Fact] + public void SameNamePrefixWithoutSegmentBoundaryDoesNotMatch() + { + var phaseA1 = Point("IEDLD0/MMXU1.A1.phsA.cVal.mag.f"); + Assert.Null(Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( + new[] { phaseA1 }, "IEDLD0/MMXU1.A")); + } + + [Fact] + public void CanonicalCollisionRejectsAllCandidatesAndCannotBeRecached() + { + var index = new Dictionary( + StringComparer.OrdinalIgnoreCase); + var ambiguous = new HashSet(StringComparer.OrdinalIgnoreCase); + var st = Point("IEDLD0/XCBR1.Pos.stVal", "ST"); + var sv = Point("IEDLD0/XCBR1.Pos.subVal", "SV"); + const string key = "iedld0/xcbr1.pos"; + Iec61850MonitorRuntime.AddReportReferenceCandidate( + index, ambiguous, key, st); + Assert.Same(st, index[key]); + Iec61850MonitorRuntime.AddReportReferenceCandidate( + index, ambiguous, key, sv); + Assert.False(index.ContainsKey(key)); + Assert.Contains(key, ambiguous); + Iec61850MonitorRuntime.AddReportReferenceCandidate( + index, ambiguous, key, st); + Assert.False(index.ContainsKey(key)); + } + + [Fact] + public void RepeatOfSamePointKeepsStableAliasAndExactDifferentPointIndependent() + { + var index = new Dictionary( + StringComparer.OrdinalIgnoreCase); + var ambiguous = new HashSet(StringComparer.OrdinalIgnoreCase); + var point = Point("IEDLD0/XCBR1.Pos.stVal", "ST"); + Iec61850MonitorRuntime.AddReportReferenceCandidate( + index, ambiguous, "iedld0/xcbr1.pos.stval", point); + Iec61850MonitorRuntime.AddReportReferenceCandidate( + index, ambiguous, "iedld0/xcbr1.pos.stval", point); + Assert.Same(point, index["iedld0/xcbr1.pos.stval"]); + Assert.Empty(ambiguous); + } +} From 33c2b3655f70c359b8244801cba6dfb8d9b7d8e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 17:47:28 +0700 Subject: [PATCH 3/4] fix(report): require exact canonical leaf identity, never infer a selected phase from parent --- Services/Iec61850MonitorRuntime.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index a9c412fb8..75af112e2 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -3419,9 +3419,11 @@ internal static void AddReportReferenceCandidate( foreach (var candidate in points) { var candidateCanonical = CanonicalDataReference(candidate.IecReference); - if (!candidateCanonical.Equals(canonical, StringComparison.OrdinalIgnoreCase) && - !candidateCanonical.StartsWith(canonical + ".", StringComparison.OrdinalIgnoreCase) && - !canonical.StartsWith(candidateCanonical + ".", StringComparison.OrdinalIgnoreCase)) + // Prefix ancestry does NOT prove a phase's scalar identity: + // one selected phsA under a three-phase parent is still ambiguous + // in the IED schema. ARIEC's typed structured projection must emit + // the exact semantic leaf; a parent cannot borrow its value. + if (!candidateCanonical.Equals(canonical, StringComparison.OrdinalIgnoreCase)) continue; if (unique is not null && !ReferenceEquals(unique, candidate)) From 2b4e4ea0b917b6c10fbe4b64b6a3a7b127870598 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 17:47:31 +0700 Subject: [PATCH 4/4] test(report): reject single selected child as authority for aggregate parent --- tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs b/tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs index 750f28cee..6893bf3ec 100644 --- a/tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs +++ b/tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs @@ -28,11 +28,13 @@ public void StructuredParentCannotChooseFirstOfMultiplePhases() } [Fact] - public void UniqueLegacyAliasAndExactLeafRemainResolvable() + public void OneSelectedPhaseDoesNotProveParentAggregateButExactFcAliasDoes() { var phaseA = Point("IEDLD0/MMXU1.A.phsA.cVal.mag.f"); var unrelated = Point("IEDLD0/MMXU1.B.phsB.cVal.mag.f"); - Assert.Same(phaseA, Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( + // A three-phase parent cannot be reduced to a selected child, even + // when that child is the only one currently monitored. + Assert.Null(Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( new[] { phaseA, unrelated }, "IEDLD0/MMXU1.A")); Assert.Same(phaseA, Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( new[] { phaseA, unrelated }, "IEDLD0/MMXU1$MX$A$phsA$cVal$mag$f"));