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..4b0b9d0c 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -292,6 +292,82 @@ 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.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( + 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.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( + 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.Structure(new[] { 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)