From 2eaefb8a8e5396f06635f7840c5a55fff3c38368 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:09:21 +0700 Subject: [PATCH 1/7] Prefer exact semantic schema over generic structured report heuristics --- .../Mms/MmsSemanticReportValueProjector.cs | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs index e8e40984..c90e207b 100644 --- a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs +++ b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs @@ -281,8 +281,9 @@ private sealed record ExpandedLeaf(string Reference, MmsDataValue Value); } /// -/// Backward-compatible overlay for structures that the established report projector -/// intentionally leaves raw. Existing known CDC projections remain untouched. +/// Model-backed overlay for structured report members. Exact static DataSet/SCL schema is +/// authoritative when it can expand the structure safely; the established generic projector +/// remains the fail-closed fallback when no unique semantic schema matches. /// public static class MmsSemanticReportValueProjector { @@ -305,12 +306,19 @@ public static MmsReportValueProjection Project( var parentReference = reportValue.MemberReference; var rawPrefix = $"REPORT_RAW_STRUCT: {parentReference} "; - if (!baseline.Warnings.Any(warning => warning.StartsWith(rawPrefix, StringComparison.OrdinalIgnoreCase))) - continue; + var baselineWasRaw = baseline.Warnings.Any(warning => + warning.StartsWith(rawPrefix, StringComparison.OrdinalIgnoreCase)); + // Static DataSet identity + exact SCL/live schema is stronger evidence than a + // generic shape heuristic. Try semantic expansion first for every structured + // member, including structures the baseline recognizes as instMag/mag pairs. + // If the exact schema cannot prove the mapping, preserve baseline behavior. if (!context.TryExpand(frame.Header.DataSetReference, reportValue, out var expanded, out var expansionReason)) { - semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible."); + if (baselineWasRaw) + { + semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible."); + } continue; } @@ -325,7 +333,10 @@ public static MmsReportValueProjection Project( var projected = MmsReportValueProjector.Project(synthetic); if (projected.Updates.Count == 0 || projected.Warnings.Any(warning => warning.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase))) { - semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} expansion was not publishable; baseline raw projection was preserved."); + if (baselineWasRaw) + { + semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} expansion was not publishable; baseline raw projection was preserved."); + } continue; } @@ -347,7 +358,7 @@ public static MmsReportValueProjection Project( IsProjectedChild = true, ProjectionStatus = "semantic-structured-leaf" })); - semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {parentReference} {expansionReason}; static DataSet membership identity was preserved."); + semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {parentReference} {expansionReason}; exact static DataSet schema overrode generic structured-value heuristics."); } if (replacementParents.Count == 0) @@ -360,15 +371,20 @@ public static MmsReportValueProjection Project( } var updates = baseline.Updates - .Where(update => !replacementParents.Contains(Normalize(update.Reference))) + .Where(update => !replacementParents.Any(parent => IsInside(Normalize(update.Reference), parent))) .Concat(semanticUpdates) .GroupBy(update => Normalize(update.Reference) + "|" + update.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) .Select(group => group.Last()) - .OrderBy(update => update.Reference, StringComparer.OrdinalIgnoreCase) + // q/t companions are intentionally delivered before scalar values. Consumers can + // therefore attach report-native quality/timestamp to semantic value leaves without + // inventing defaults or issuing a separate MMS read. + .OrderBy(update => CompanionPriority(update.Reference)) + .ThenBy(update => update.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); var warnings = baseline.Warnings - .Where(warning => !replacementParents.Any(parent => warning.StartsWith($"REPORT_RAW_STRUCT: {parent} ", StringComparison.OrdinalIgnoreCase))) + .Where(warning => !replacementParents.Any(parent => + warning.StartsWith($"REPORT_RAW_STRUCT: {parent} ", StringComparison.OrdinalIgnoreCase))) .Concat(semanticWarnings) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -380,6 +396,21 @@ public static MmsReportValueProjection Project( }; } + private static int CompanionPriority(string reference) + { + var normalized = Normalize(reference); + return normalized.EndsWith(".q", StringComparison.OrdinalIgnoreCase) || + normalized.EndsWith(".t", StringComparison.OrdinalIgnoreCase) + ? 0 + : 1; + } + + private static bool IsInside(string reference, string parent) + => string.Equals(reference, parent, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(reference) && + !string.IsNullOrWhiteSpace(parent) && + reference.StartsWith(parent + ".", StringComparison.OrdinalIgnoreCase)); + private static string Normalize(string value) => string.IsNullOrWhiteSpace(value) ? string.Empty From 52c19088e215f2dc482db6550367beb1dcb216bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:10:00 +0700 Subject: [PATCH 2/7] Add TotPF regression for exact semantic report schema authority --- .../MmsSemanticReportValueProjectorTests.cs | 68 +++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 13b93c02..df0ecee2 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -90,6 +90,43 @@ public void Sparse_Report_Value_Index_Drift_Still_Uses_Exact_Static_Member_Refer warning.StartsWith("REPORT_SEMANTIC_FALLBACK:", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void Exact_Static_Schema_Overrides_Generic_InstMagMag_Heuristic() + { + const string objectReference = "AA1E1F06R4VI3p1_OperationalValues/PPRE_MMXU1.TotPF"; + const string dataSetReference = "AA1E1F06R4Application/LLN0.Analog"; + var model = BuildMeasurementPairModel(objectReference, dataSetReference); + var frame = BuildFrame( + objectReference, + dataSetReference, + MmsDataValue.FloatingPoint(0.125), + MmsDataValue.FloatingPoint(0.25)); + + // The generic projector recognizes a two-float structure as an instMag/mag pair. + // Static DataSet semantic authority must still win so exact schema leaf identities + // (including .f) reach ARSAS instead of heuristic aliases. + var baseline = MmsReportValueProjector.Project(frame); + Assert.Contains(baseline.Updates, update => + update.ProjectionStatus.Equals("measurement-pair(instMag/mag)", StringComparison.OrdinalIgnoreCase)); + + var projection = MmsSemanticReportValueProjector.Project( + frame, + MmsReportSemanticProjectionContext.Create(model)); + + var instant = Assert.Single(projection.Updates, update => + update.Reference.Equals(objectReference + ".instMag.f", StringComparison.OrdinalIgnoreCase)); + var magnitude = Assert.Single(projection.Updates, update => + update.Reference.Equals(objectReference + ".mag.f", StringComparison.OrdinalIgnoreCase)); + Assert.Equal("semantic-structured-leaf", instant.ProjectionStatus); + Assert.Equal("semantic-structured-leaf", magnitude.ProjectionStatus); + Assert.DoesNotContain(projection.Updates, update => + update.Reference.Equals(objectReference + ".instMag", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projection.Updates, update => + update.Reference.Equals(objectReference + ".mag", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projection.Warnings, warning => + warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void Schema_Mismatch_Fails_Closed_And_Preserves_Raw_Projection() { @@ -128,6 +165,27 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( Attribute(objectReference + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), Attribute(objectReference + ".phsC.cVal.mag.f", "phsC.cVal.mag.f") }; + return BuildModel(objectReference, dataSetReference, "WYE", attributes); + } + + private static LiveIedModelDiscoveryDocument BuildMeasurementPairModel( + string objectReference, + string dataSetReference) + { + var attributes = new[] + { + Attribute(objectReference + ".instMag.f", "instMag.f"), + Attribute(objectReference + ".mag.f", "mag.f") + }; + return BuildModel(objectReference, dataSetReference, "MV", attributes); + } + + private static LiveIedModelDiscoveryDocument BuildModel( + string objectReference, + string dataSetReference, + string cdc, + IReadOnlyList attributes) + { var slash = objectReference.IndexOf('/'); var domain = objectReference[..slash]; var logicalPath = objectReference[(slash + 1)..]; @@ -138,7 +196,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( return new LiveIedModelDiscoveryDocument { Source = "SclWorkspace", - IedName = "AA1E1F02R2", + IedName = "AA1E1F06R4", LogicalDevices = new[] { new LiveIedLogicalDeviceModel @@ -149,7 +207,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( new LiveIedLogicalNodeModel { Name = logicalNode, - LnClass = "MHAI", + LnClass = logicalNode.Contains("MHAI", StringComparison.OrdinalIgnoreCase) ? "MHAI" : "MMXU", LnInst = "1", DataObjects = new[] { @@ -157,7 +215,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( { Reference = objectReference, Name = dataObjectName, - InferredCdc = "WYE", + InferredCdc = cdc, Attributes = attributes } } @@ -170,7 +228,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( new LiveIedDataSetModel { Reference = dataSetReference, - Domain = "AA1E1F02R2Application", + Domain = dataSetReference.Split('/')[0], LogicalNode = "LLN0", Name = "Analog", MemberCount = 1, @@ -181,7 +239,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( Index = 0, Reference = objectReference, FunctionalConstraint = "MX", - MmsReference = "AA1E1F02R2VI3p1_THDHarmonics/I_MHAI1$MX$ThdA", + MmsReference = objectReference.Replace('.', '$'), Confidence = LiveIedDiscoveryConfidenceLevel.Exact } } From 69bfe70e2c779c7e8268af087bd1a3a38986c0fc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:16:15 +0700 Subject: [PATCH 3/7] Model TotPF report shape with q and t companions --- .../MmsSemanticReportValueProjectorTests.cs | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index df0ecee2..8902ff9d 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -16,10 +16,6 @@ public void Whole_MultiPhase_Member_Fans_Out_All_Scalar_Leaves_Without_Selecting Assert.Equal(LiveIedDataSetMemberResolutionStatus.Ambiguous, binding.ResolutionStatus); Assert.Null(binding.PrimaryValue); - // Physical bench evidence included phsB.cVal.mag.f = 40.04636. - // Keep that exact floating-point input here so the report fan-out regression stays tied - // to the field failure that exposed REPORT_RAW_STRUCT. The public display renderer has - // an established three-decimal contract, which is asserted below independently of routing. var frame = BuildFrame( objectReference, dataSetReference, @@ -62,10 +58,6 @@ public void Sparse_Report_Value_Index_Drift_Still_Uses_Exact_Static_Member_Refer const string dataSetReference = "AA1E1F02R2Application/LLN0.Analog"; var model = BuildThreePhaseModel(objectReference, dataSetReference); - // The static DataSet member index in the model is 0. A sparse InformationReport may - // expose a decoder-side value position that differs from that static member index. - // Exact IEC member identity must remain sufficient and must not be rejected solely - // because the transient report value index is different. var frame = BuildFrame( objectReference, dataSetReference, @@ -95,19 +87,22 @@ public void Exact_Static_Schema_Overrides_Generic_InstMagMag_Heuristic() { const string objectReference = "AA1E1F06R4VI3p1_OperationalValues/PPRE_MMXU1.TotPF"; const string dataSetReference = "AA1E1F06R4Application/LLN0.Analog"; + var timestamp = new DateTimeOffset(2026, 9, 4, 13, 20, 51, TimeSpan.Zero); var model = BuildMeasurementPairModel(objectReference, dataSetReference); var frame = BuildFrame( objectReference, dataSetReference, - MmsDataValue.FloatingPoint(0.125), - MmsDataValue.FloatingPoint(0.25)); + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.125) }), + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.25) }), + MmsDataValue.BitString(3, new byte[] { 0x00, 0x00 }), + MmsDataValue.UtcTime(new Iec61850UtcTime(timestamp, 0))); - // The generic projector recognizes a two-float structure as an instMag/mag pair. - // Static DataSet semantic authority must still win so exact schema leaf identities - // (including .f) reach ARSAS instead of heuristic aliases. + // The generic projector can correctly recognize the wire shape as an MX pair. + // Static DataSet semantic authority must still win so exact schema leaf identity, + // including the final .f and the report-native q/t, reaches the consumer. var baseline = MmsReportValueProjector.Project(frame); Assert.Contains(baseline.Updates, update => - update.ProjectionStatus.Equals("measurement-pair(instMag/mag)", StringComparison.OrdinalIgnoreCase)); + update.ProjectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase)); var projection = MmsSemanticReportValueProjector.Project( frame, @@ -119,6 +114,10 @@ public void Exact_Static_Schema_Overrides_Generic_InstMagMag_Heuristic() update.Reference.Equals(objectReference + ".mag.f", StringComparison.OrdinalIgnoreCase)); Assert.Equal("semantic-structured-leaf", instant.ProjectionStatus); Assert.Equal("semantic-structured-leaf", magnitude.ProjectionStatus); + Assert.Equal("good", magnitude.Quality); + Assert.True(magnitude.HasQuality); + Assert.True(magnitude.HasTimestamp); + Assert.Contains("2026-09-04", magnitude.Timestamp, StringComparison.Ordinal); Assert.DoesNotContain(projection.Updates, update => update.Reference.Equals(objectReference + ".instMag", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(projection.Updates, update => @@ -175,7 +174,9 @@ private static LiveIedModelDiscoveryDocument BuildMeasurementPairModel( var attributes = new[] { Attribute(objectReference + ".instMag.f", "instMag.f"), - Attribute(objectReference + ".mag.f", "mag.f") + Attribute(objectReference + ".mag.f", "mag.f"), + Attribute(objectReference + ".q", "q", "Quality", "bit-string"), + Attribute(objectReference + ".t", "t", "Timestamp", "utc-time") }; return BuildModel(objectReference, dataSetReference, "MV", attributes); } @@ -248,15 +249,19 @@ private static LiveIedModelDiscoveryDocument BuildModel( }; } - private static LiveIedDataAttributeModel Attribute(string reference, string path) + private static LiveIedDataAttributeModel Attribute( + string reference, + string path, + string sclBType = "FLOAT32", + string mmsType = "floating-point") => new() { ObjectReference = reference, AttributePath = path, FunctionalConstraint = "MX", MmsReference = reference.Replace('.', '$'), - SclBType = "FLOAT32", - MmsType = "floating-point", + SclBType = sclBType, + MmsType = mmsType, Source = "SCL.DataTypeTemplates", TypeSource = "SCL.DataTypeTemplates", TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact From ba6211b7c7bcac7ab76ff326949e2e1cdb40cc24 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:24:35 +0700 Subject: [PATCH 4/7] Harden semantic report replacement for index-resolved members --- .../Mms/MmsSemanticReportValueProjector.cs | 76 ++++++++++++++----- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs index c90e207b..07f7aedf 100644 --- a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs +++ b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs @@ -85,8 +85,22 @@ internal bool TryExpand( MmsReportValue reportValue, out IReadOnlyList expanded, out string reason) + => TryExpand( + dataSetReference, + reportValue, + out expanded, + out _, + out reason); + + internal bool TryExpand( + string dataSetReference, + MmsReportValue reportValue, + out IReadOnlyList expanded, + out string resolvedMemberReference, + out string reason) { expanded = Array.Empty(); + resolvedMemberReference = string.Empty; reason = string.Empty; if (reportValue.Value is null || reportValue.Value.Kind is not (MmsDataKind.Structure or MmsDataKind.Array)) @@ -127,6 +141,7 @@ internal bool TryExpand( ReasonForInclusion = reportValue.ReasonForInclusion }) .ToArray(); + resolvedMemberReference = schema.MemberReference; reason = $"expanded {schema.MemberReference} into {expanded.Count} scalar semantic descendant(s)"; return true; } @@ -295,17 +310,18 @@ public static MmsReportValueProjection Project( ArgumentNullException.ThrowIfNull(context); var baseline = MmsReportValueProjector.Project(frame); - var replacementParents = new HashSet(StringComparer.OrdinalIgnoreCase); + var semanticReplacementPositions = new HashSet(); var semanticUpdates = new List(); var semanticWarnings = new List(); - foreach (var reportValue in frame.Values) + for (var valuePosition = 0; valuePosition < frame.Values.Count; valuePosition++) { + var reportValue = frame.Values[valuePosition]; if (reportValue.Value is null || reportValue.Value.Kind is not (MmsDataKind.Structure or MmsDataKind.Array)) continue; - var parentReference = reportValue.MemberReference; - var rawPrefix = $"REPORT_RAW_STRUCT: {parentReference} "; + var reportedMemberReference = reportValue.MemberReference; + var rawPrefix = $"REPORT_RAW_STRUCT: {reportedMemberReference} "; var baselineWasRaw = baseline.Warnings.Any(warning => warning.StartsWith(rawPrefix, StringComparison.OrdinalIgnoreCase)); @@ -313,11 +329,16 @@ public static MmsReportValueProjection Project( // generic shape heuristic. Try semantic expansion first for every structured // member, including structures the baseline recognizes as instMag/mag pairs. // If the exact schema cannot prove the mapping, preserve baseline behavior. - if (!context.TryExpand(frame.Header.DataSetReference, reportValue, out var expanded, out var expansionReason)) + if (!context.TryExpand( + frame.Header.DataSetReference, + reportValue, + out var expanded, + out var resolvedMemberReference, + out var expansionReason)) { if (baselineWasRaw) { - semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible."); + semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {reportedMemberReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible."); } continue; } @@ -335,12 +356,18 @@ public static MmsReportValueProjection Project( { if (baselineWasRaw) { - semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} expansion was not publishable; baseline raw projection was preserved."); + semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {reportedMemberReference} expansion was not publishable; baseline raw projection was preserved."); } continue; } - replacementParents.Add(Normalize(parentReference)); + // Replace the generic projection by report-value position, not by a guessed parent + // reference. Some valid InformationReports omit member identity and are resolved + // only by the exact static DataSet + member index. In that case the generic + // projector can emit unrooted heuristic leaves, so descendant-name filtering is + // neither sufficient nor safe. A successful semantic projection owns this report + // value completely; generic projection remains available only for other values. + semanticReplacementPositions.Add(valuePosition); semanticUpdates.AddRange(projected.Updates.Select(update => new MmsReportSignalUpdate { Reference = update.Reference, @@ -358,10 +385,10 @@ public static MmsReportValueProjection Project( IsProjectedChild = true, ProjectionStatus = "semantic-structured-leaf" })); - semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {parentReference} {expansionReason}; exact static DataSet schema overrode generic structured-value heuristics."); + semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {resolvedMemberReference} {expansionReason}; exact static DataSet schema overrode generic structured-value heuristics."); } - if (replacementParents.Count == 0) + if (semanticReplacementPositions.Count == 0) { return new MmsReportValueProjection { @@ -370,8 +397,23 @@ public static MmsReportValueProjection Project( }; } - var updates = baseline.Updates - .Where(update => !replacementParents.Any(parent => IsInside(Normalize(update.Reference), parent))) + // Re-project only report values that were not replaced semantically. This preserves + // normal generic scalar/companion behavior for unrelated members while guaranteeing + // that no heuristic output from a successfully resolved structured member survives, + // even when the wire report omitted MemberReference entirely. + var retainedFrame = new MmsReportFrame + { + ReceivedAt = frame.ReceivedAt, + Header = frame.Header, + Values = frame.Values + .Where((_, index) => !semanticReplacementPositions.Contains(index)) + .ToArray(), + DecoderMode = frame.DecoderMode, + Message = frame.Message + }; + var retainedBaseline = MmsReportValueProjector.Project(retainedFrame); + + var updates = retainedBaseline.Updates .Concat(semanticUpdates) .GroupBy(update => Normalize(update.Reference) + "|" + update.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) .Select(group => group.Last()) @@ -382,9 +424,7 @@ public static MmsReportValueProjection Project( .ThenBy(update => update.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); - var warnings = baseline.Warnings - .Where(warning => !replacementParents.Any(parent => - warning.StartsWith($"REPORT_RAW_STRUCT: {parent} ", StringComparison.OrdinalIgnoreCase))) + var warnings = retainedBaseline.Warnings .Concat(semanticWarnings) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -405,12 +445,6 @@ private static int CompanionPriority(string reference) : 1; } - private static bool IsInside(string reference, string parent) - => string.Equals(reference, parent, StringComparison.OrdinalIgnoreCase) || - (!string.IsNullOrWhiteSpace(reference) && - !string.IsNullOrWhiteSpace(parent) && - reference.StartsWith(parent + ".", StringComparison.OrdinalIgnoreCase)); - private static string Normalize(string value) => string.IsNullOrWhiteSpace(value) ? string.Empty From 915878326e40aa1dfb85d4aa0a65448a89e38bbf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:25:10 +0700 Subject: [PATCH 5/7] Add regression for index-resolved semantic report members --- .../MmsSemanticReportValueProjectorTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 8902ff9d..5a0e4564 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -126,6 +126,49 @@ public void Exact_Static_Schema_Overrides_Generic_InstMagMag_Heuristic() warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void Missing_MemberReference_Uses_Static_DataSet_Index_Without_Leaking_Generic_Heuristic() + { + const string objectReference = "AA1E1F06R4VI3p1_OperationalValues/PPRE_MMXU1.TotPF"; + const string dataSetReference = "AA1E1F06R4Application/LLN0.Analog"; + var timestamp = new DateTimeOffset(2026, 9, 4, 13, 20, 51, TimeSpan.Zero); + var model = BuildMeasurementPairModel(objectReference, dataSetReference); + var frame = BuildFrame( + string.Empty, + dataSetReference, + reportValueIndex: 0, + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.125) }), + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.25) }), + MmsDataValue.BitString(3, new byte[] { 0x00, 0x00 }), + MmsDataValue.UtcTime(new Iec61850UtcTime(timestamp, 0))); + + // With no MemberReference, the generic projector has wire-shape evidence but no + // authoritative engineering parent. It can therefore emit unrooted MX-pair leaves. + var baseline = MmsReportValueProjector.Project(frame); + Assert.Contains(baseline.Updates, update => + update.ProjectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase)); + + var projection = MmsSemanticReportValueProjector.Project( + frame, + MmsReportSemanticProjectionContext.Create(model)); + + var instant = Assert.Single(projection.Updates, update => + update.Reference.Equals(objectReference + ".instMag.f", StringComparison.OrdinalIgnoreCase)); + var magnitude = Assert.Single(projection.Updates, update => + update.Reference.Equals(objectReference + ".mag.f", StringComparison.OrdinalIgnoreCase)); + Assert.Equal("semantic-structured-leaf", instant.ProjectionStatus); + Assert.Equal("semantic-structured-leaf", magnitude.ProjectionStatus); + Assert.DoesNotContain(projection.Updates, update => + update.ProjectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projection.Updates, update => + string.IsNullOrWhiteSpace(update.Reference) + || update.Reference.Equals("instMag.f", StringComparison.OrdinalIgnoreCase) + || update.Reference.Equals("mag.f", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projection.Warnings, warning => + warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase) + && warning.Contains(objectReference, StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void Schema_Mismatch_Fails_Closed_And_Preserves_Raw_Projection() { From 0d7525bd330900917fb9f6d15a46059dc3d7a70a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:26:33 +0700 Subject: [PATCH 6/7] Clarify index-fallback regression intent --- .../Mms/MmsSemanticReportValueProjectorTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 5a0e4564..c4e6a34f 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -161,8 +161,7 @@ public void Missing_MemberReference_Uses_Static_DataSet_Index_Without_Leaking_Ge Assert.DoesNotContain(projection.Updates, update => update.ProjectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(projection.Updates, update => - string.IsNullOrWhiteSpace(update.Reference) - || update.Reference.Equals("instMag.f", StringComparison.OrdinalIgnoreCase) + update.Reference.Equals("instMag.f", StringComparison.OrdinalIgnoreCase) || update.Reference.Equals("mag.f", StringComparison.OrdinalIgnoreCase)); Assert.Contains(projection.Warnings, warning => warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase) From 11ab2304482600c19ba979f4fc9021ddb46b9af9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 16:10:08 +0700 Subject: [PATCH 7/7] Harden persistent BRCB activation for mature client behavior --- ...sistentReportMonitorClientCompatibility.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs new file mode 100644 index 00000000..402538f8 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs @@ -0,0 +1,153 @@ +namespace AR.Iec61850.Mms; + +/// +/// Client-compatibility activation wrapper for persistent reporting. +/// +/// Mature IEC 61850 clients normally reserve a BRCB when ResvTms is exposed, +/// enable reporting, install/retain the report receiver, and only then request GI. +/// Some servers also support implicit BRCB reservation through RptEna=true, so an +/// explicit ResvTms rejection is non-fatal and the baseline activation is still tried. +/// +/// This wrapper does not create dynamic DataSets and does not schedule cyclic process +/// reads. It only hardens the RCB control-plane sequence used by report acquisition. +/// +public sealed partial class MmsClientSession +{ + public async Task StartPersistentReportMonitorClientCompatibleAsync( + MmsReportSubscriptionPlan plan, + bool triggerGeneralInterrogation = true, + bool deleteDynamicDataSetOnStop = true, + MmsIedModelDirectory? directory = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plan); + + var rcb = plan.ReportControl; + MmsReportAttributeWriteStep? reservationStep = null; + var compatibilityWarnings = new List(); + + if (rcb is { Buffered: true } && + rcb.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase) && + !MmsReportSubscriptionPlanner.IsExplicitlyEnabled(rcb) && + !MmsReportSubscriptionPlanner.IsReservedByOtherClient(rcb)) + { + reservationStep = await WriteReportAttributeAsync( + rcb, + "ResvTms", + MmsDataValue.Unsigned(60), + cancellationToken).ConfigureAwait(false); + + if (!reservationStep.IsSuccess) + { + compatibilityWarnings.Add( + $"BRCB ResvTms=60 explicit reservation was not accepted ({reservationStep.Message}). Continuing with standards-compatible implicit reservation through RptEna=true."); + } + } + + // Deliberately suppress GI inside the baseline start. The baseline method registers + // the persistent monitor only after RptEna succeeds. Requesting GI below guarantees + // that the report receiver/session is already registered when the server emits the + // initial InformationReport, while the receive router still preserves any earlier + // unconfirmed traffic that arrived during confirmed writes. + var attempt = await StartPersistentReportMonitorWithAttemptEvidenceAsync( + plan, + triggerGeneralInterrogation: false, + deleteDynamicDataSetOnStop, + directory, + cancellationToken).ConfigureAwait(false); + + var start = attempt.StartResult; + var writes = new List(); + if (reservationStep is not null) + writes.Add(reservationStep); + writes.AddRange(start.WriteSteps); + + var warnings = start.Warnings + .Where(warning => !warning.Contains("ResvTms pre-reserve was skipped", StringComparison.OrdinalIgnoreCase)) + .Concat(compatibilityWarnings) + .ToList(); + + if (!attempt.IsSuccess || start.Session is null) + { + var cleanupSteps = attempt.CleanupSteps.ToList(); + var cleanupWarnings = attempt.CleanupWarnings.ToList(); + var cleanupAttempted = attempt.CleanupAttempted; + var cleanupSucceeded = attempt.CleanupSucceeded; + + if (reservationStep?.IsSuccess == true && rcb is not null) + { + var release = await TryWriteReportAttributeForCleanupAsync( + rcb, + "ResvTms", + MmsDataValue.Unsigned(0), + CancellationToken.None).ConfigureAwait(false); + cleanupSteps.Add(release); + cleanupAttempted = true; + cleanupSucceeded &= release.IsSuccess; + if (!release.IsSuccess) + cleanupWarnings.Add($"BRCB ResvTms cleanup after failed activation was not accepted: {release.Message}"); + } + + return new MmsPersistentReportMonitorAttemptResult + { + StartResult = CopyStartResult(start, writes, warnings), + DynamicAttemptState = attempt.DynamicAttemptState, + FailureReason = attempt.FailureReason, + CleanupAttempted = cleanupAttempted, + CleanupSucceeded = cleanupSucceeded, + CleanupSteps = cleanupSteps, + CleanupWarnings = cleanupWarnings + }; + } + + if (reservationStep?.IsSuccess == true) + start.Session.ReservationTouched = true; + + if (triggerGeneralInterrogation) + { + var gi = await WriteReportAttributeAsync( + start.Session.ReportControl, + "GI", + MmsDataValue.Boolean(true), + cancellationToken).ConfigureAwait(false); + writes.Add(gi); + if (!gi.IsSuccess) + warnings.Add("GI=true write failed or is not supported by this RCB. Waiting for spontaneous/integrity reports only."); + } + + var compatibilityMessage = reservationStep?.IsSuccess == true + ? "BRCB explicitly reserved with ResvTms=60 before RptEna; GI was requested only after the persistent receiver was registered." + : "GI was requested only after the persistent receiver was registered."; + + return new MmsPersistentReportMonitorAttemptResult + { + StartResult = CopyStartResult( + start, + writes, + warnings, + $"{start.Message} {compatibilityMessage}"), + DynamicAttemptState = attempt.DynamicAttemptState, + FailureReason = attempt.FailureReason, + CleanupAttempted = attempt.CleanupAttempted, + CleanupSucceeded = attempt.CleanupSucceeded, + CleanupSteps = attempt.CleanupSteps, + CleanupWarnings = attempt.CleanupWarnings + }; + } + + private static MmsPersistentReportMonitorStartResult CopyStartResult( + MmsPersistentReportMonitorStartResult source, + IReadOnlyList writes, + IReadOnlyList warnings, + string? message = null) + => new() + { + IsSuccess = source.IsSuccess, + Message = message ?? source.Message, + Session = source.Session, + WriteSteps = writes.ToArray(), + Warnings = warnings.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + RcbSnapshots = source.RcbSnapshots, + DataSetSnapshots = source.DataSetSnapshots + }; +}