From b34c687264e3fc7b22ddeb6699bf9b7da2eafb14 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 29 Oct 2026 18:59:54 +0700 Subject: [PATCH 1/2] P8: reject exact-type-conflicting structured MMS Report leaves without fallback projection --- .../Mms/MmsSemanticReportValueProjector.cs | 67 ++++++++++++++++- .../MmsSemanticReportValueProjectorTests.cs | 75 +++++++++++++++++++ 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs index b81cea8e..bbbc24d9 100644 --- a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs +++ b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs @@ -63,6 +63,9 @@ public static MmsReportSemanticProjectionContext Create(LiveIedModelDiscoveryDoc accumulated = $"{accumulated}.{part}"; current = current.GetOrAdd(part, accumulated); } + // Only a type backed by exact model evidence can veto a + // wire leaf. Partial discovery must remain compatible. + current.ObserveExactType(ExactMmsKind(attribute)); } if (root.Children.Count == 0) @@ -194,6 +197,16 @@ private static bool TryFlatten( return false; } + if (schema.HasTypeConflict) + { + reason = $"typed schema conflict for {schema.Reference}: exact model sources disagree"; + return false; + } + if (schema.ExactKind is MmsDataKind expected && expected != value.Kind) + { + reason = $"typed MMS leaf mismatch at {schema.Reference}: expected {expected}, received {value.Kind}"; + return false; + } leaves.Add(new ExpandedLeaf(schema.Reference, value)); reason = string.Empty; return true; @@ -253,6 +266,30 @@ private static string NormalizeDataSetReference(string value) return normalized[..(slash + 1)] + normalized[(slash + 1)..].Replace('$', '.'); } + // Strictly known, model-qualified MMS kinds only. SCL bType alone is not + // live MMS type authority; unknown/partial types retain existing fallback. + private static MmsDataKind? ExactMmsKind(LiveIedDataAttributeModel attribute) + { + if (attribute.TypeConfidence != LiveIedDiscoveryConfidenceLevel.Exact || + string.IsNullOrWhiteSpace(attribute.MmsType)) + return null; + + return attribute.MmsType.Trim().ToLowerInvariant().Replace('_', '-') switch + { + "floating-point" or "floatingpoint" => MmsDataKind.FloatingPoint, + "bit-string" or "bitstring" => MmsDataKind.BitString, + "utc-time" or "utctime" => MmsDataKind.UtcTime, + "boolean" => MmsDataKind.Boolean, + "integer" or "signed-integer" => MmsDataKind.Integer, + "unsigned" or "unsigned-integer" => MmsDataKind.Unsigned, + "octet-string" or "octetstring" => MmsDataKind.OctetString, + "visible-string" or "visiblestring" => MmsDataKind.VisibleString, + "mms-string" or "mmsstring" => MmsDataKind.MmsString, + "binary-time" or "binarytime" => MmsDataKind.BinaryTime, + _ => null + }; + } + private sealed class SchemaNode { private readonly Dictionary _byName = new(StringComparer.OrdinalIgnoreCase); @@ -263,6 +300,18 @@ public SchemaNode(string reference) public string Reference { get; } public IReadOnlyList Children => _children; + public MmsDataKind? ExactKind { get; private set; } + public bool HasTypeConflict { get; private set; } + + public void ObserveExactType(MmsDataKind? kind) + { + if (!kind.HasValue) + return; + if (ExactKind.HasValue && ExactKind != kind) + HasTypeConflict = true; + else + ExactKind = kind; + } public SchemaNode GetOrAdd(string name, string reference) { @@ -302,6 +351,7 @@ public static MmsReportValueProjection Project( var baseline = MmsReportValueProjector.Project(frame); var semanticReplacementPositions = new HashSet(); + var rejectedTypedPositions = new HashSet(); var semanticUpdates = new List(); var semanticWarnings = new List(); @@ -323,7 +373,17 @@ public static MmsReportValueProjection Project( out var resolvedMemberReference, out var expansionReason)) { - if (baselineWasRaw) + // A positively contradictory exact MMS type is not permission + // for the generic positional fallback to invent process values. + // Preserve the original frame and explicit rejection evidence, + // but exclude this member's candidate value/quality/timestamp. + if (expansionReason.StartsWith("typed MMS leaf mismatch", StringComparison.Ordinal) || + expansionReason.StartsWith("typed schema conflict", StringComparison.Ordinal)) + { + rejectedTypedPositions.Add(valuePosition); + semanticWarnings.Add($"REPORT_SEMANTIC_REJECTED: {reportedMemberReference}; {expansionReason}; no scalar projection from this member."); + } + else if (baselineWasRaw) { semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {reportedMemberReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible."); } @@ -369,7 +429,7 @@ public static MmsReportValueProjection Project( semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {resolvedMemberReference} {expansionReason}; exact static DataSet schema overrode generic structured-value heuristics."); } - if (semanticReplacementPositions.Count == 0) + if (semanticReplacementPositions.Count == 0 && rejectedTypedPositions.Count == 0) { return new MmsReportValueProjection { @@ -383,7 +443,8 @@ public static MmsReportValueProjection Project( ReceivedAt = frame.ReceivedAt, Header = frame.Header, Values = frame.Values - .Where((_, index) => !semanticReplacementPositions.Contains(index)) + .Where((_, index) => !semanticReplacementPositions.Contains(index) && + !rejectedTypedPositions.Contains(index)) .ToArray(), DecoderMode = frame.DecoderMode, Message = frame.Message diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 00e95eba..0dc0ab16 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -292,6 +292,81 @@ public void C264_Incomplete_Live_Schema_Still_Projects_Strictly_Typed_Mod_FCD() x => x.Value.StartsWith("Structure(", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void ExactTypedFcd_SwappedQualityAndTimeIsRejectedWithoutFalseValuePublication() + { + const string root = "C264MEAS/MMXU1.A"; + const string dataSet = "C264MASTER/LLN0.Analog"; + var model = BuildMeasurementPairModel(root, dataSet); + var when = new DateTimeOffset(2026, 10, 20, 10, 0, 0, TimeSpan.Zero); + var invalid = BuildFrame(root, dataSet, + MmsDataValue.FloatingPoint(12f), + MmsDataValue.FloatingPoint(13f), + MmsDataValue.UtcTime(new Iec61850UtcTime(when, 0)), + MmsDataValue.BitString(3, new byte[] { 0 })); + var result = MmsSemanticReportValueProjector.Project( + invalid, MmsReportSemanticProjectionContext.Create(model)); + + Assert.Empty(result.Updates); + Assert.Contains(result.Warnings, + warning => warning.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal) && + warning.Contains("expected BitString, received UtcTime", StringComparison.Ordinal)); + // The original received frame remains available to diagnosis. + Assert.Equal(4, invalid.Values[0].Value!.Children.Count); + } + + [Fact] + public void ExactTypedFcd_ValidOrderedQualityAndTimestampRetainProcessProjection() + { + const string root = "C264MEAS/MMXU1.A"; + const string dataSet = "C264MASTER/LLN0.Analog"; + var when = new DateTimeOffset(2026, 10, 20, 10, 0, 0, TimeSpan.Zero); + var valid = BuildFrame(root, dataSet, + MmsDataValue.FloatingPoint(12f), + MmsDataValue.FloatingPoint(13f), + MmsDataValue.BitString(3, new byte[] { 0 }), + MmsDataValue.UtcTime(new Iec61850UtcTime(when, 0))); + var result = MmsSemanticReportValueProjector.Project( + valid, MmsReportSemanticProjectionContext.Create(BuildMeasurementPairModel(root, dataSet))); + + Assert.Contains(result.Updates, x => x.Reference == root + ".instMag.f" && x.HasValue); + Assert.Contains(result.Updates, x => x.Reference == root + ".mag.f" && x.HasValue); + Assert.DoesNotContain(result.Warnings, x => x.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal)); + } + + [Fact] + public void ConflictingExactSchemaAndPartialUnknownTypesStayDeterministic() + { + const string root = "C264MEAS/MMXU1.A"; + const string dataSet = "C264MASTER/LLN0.Analog"; + var contradiction = BuildModel(root, dataSet, "MV", new[] + { + Attribute(root + ".instMag.f", "instMag.f", "FLOAT32", "floating-point"), + Attribute(root + ".instMag.f", "instMag.f", "BOOLEAN", "boolean") + }); + var frame = BuildFrame(root, dataSet, MmsDataValue.FloatingPoint(12f)); + var rejected = MmsSemanticReportValueProjector.Project( + frame, MmsReportSemanticProjectionContext.Create(contradiction)); + Assert.Empty(rejected.Updates); + Assert.Contains(rejected.Warnings, x => x.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal)); + + var unknown = BuildModel(root, dataSet, "MV", new[] + { + new LiveIedDataAttributeModel + { + ObjectReference = root + ".instMag.f", + AttributePath = "instMag.f", + FunctionalConstraint = "MX", + MmsType = "", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Unknown + } + }); + var fallback = MmsSemanticReportValueProjector.Project( + frame, MmsReportSemanticProjectionContext.Create(unknown)); + Assert.Contains(fallback.Updates, x => x.Reference == root + ".instMag.f"); + Assert.DoesNotContain(fallback.Warnings, x => x.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal)); + } + private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( string objectReference, string dataSetReference) From 72e66c221abee42fcc14339e3b1bcf70c13bee9e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 29 Oct 2026 19:04:38 +0700 Subject: [PATCH 2/2] test: model-correct nested FCD leaf fixtures for exact MMS type validation --- .../Mms/MmsSemanticReportValueProjectorTests.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 0dc0ab16..4b0b9d0c 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -300,8 +300,8 @@ public void ExactTypedFcd_SwappedQualityAndTimeIsRejectedWithoutFalseValuePublic var model = BuildMeasurementPairModel(root, dataSet); var when = new DateTimeOffset(2026, 10, 20, 10, 0, 0, TimeSpan.Zero); var invalid = BuildFrame(root, dataSet, - MmsDataValue.FloatingPoint(12f), - MmsDataValue.FloatingPoint(13f), + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(12f) }), + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(13f) }), MmsDataValue.UtcTime(new Iec61850UtcTime(when, 0)), MmsDataValue.BitString(3, new byte[] { 0 })); var result = MmsSemanticReportValueProjector.Project( @@ -322,8 +322,8 @@ public void ExactTypedFcd_ValidOrderedQualityAndTimestampRetainProcessProjection const string dataSet = "C264MASTER/LLN0.Analog"; var when = new DateTimeOffset(2026, 10, 20, 10, 0, 0, TimeSpan.Zero); var valid = BuildFrame(root, dataSet, - MmsDataValue.FloatingPoint(12f), - MmsDataValue.FloatingPoint(13f), + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(12f) }), + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(13f) }), MmsDataValue.BitString(3, new byte[] { 0 }), MmsDataValue.UtcTime(new Iec61850UtcTime(when, 0))); var result = MmsSemanticReportValueProjector.Project( @@ -344,7 +344,8 @@ public void ConflictingExactSchemaAndPartialUnknownTypesStayDeterministic() Attribute(root + ".instMag.f", "instMag.f", "FLOAT32", "floating-point"), Attribute(root + ".instMag.f", "instMag.f", "BOOLEAN", "boolean") }); - var frame = BuildFrame(root, dataSet, MmsDataValue.FloatingPoint(12f)); + var frame = BuildFrame(root, dataSet, + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(12f) })); var rejected = MmsSemanticReportValueProjector.Project( frame, MmsReportSemanticProjectionContext.Create(contradiction)); Assert.Empty(rejected.Updates);