diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index f10c0ab4..967b0a7d 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -20,7 +20,9 @@ on: - "*.slnx" - "Directory.Build.props" pull_request: - branches: [ main, feat/495-dataset-type-closure ] + # The live ARSAS product pins PR #169 until targeted engine changes pass CI. + # Stacked engine PRs must run the exact same Windows build/test gate. + branches: [ main, feat/495-dataset-type-closure, fix/p10-9l-readonly-ctlmodel-probe ] workflow_dispatch: permissions: diff --git a/src/AR.Iec61850/Mms/MmsReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsReportValueProjector.cs index ead27b50..3f501a87 100644 --- a/src/AR.Iec61850/Mms/MmsReportValueProjector.cs +++ b/src/AR.Iec61850/Mms/MmsReportValueProjector.cs @@ -310,6 +310,14 @@ private static bool TryProjectKnownDataObjectStruct( } } + // C264/IEC status FCD members such as Mod and PhyHealth are whole + // structured ST DataObjects, not the selected scalar stVal leaf. + // Trust only a *typed* MMS tuple (integer enumeration, decoded q, + // decoded UTC time, optional Boolean stSeld). Never peel the first + // element from an arbitrary human-readable Structure(...) summary. + if (TryProjectEnumeratedStatusStruct(reference, fc, value, reason, receivedAt, out projected)) + return true; + if (TryProjectBooleanStatusStruct(reference, fc, value, reason, receivedAt, out projected)) return true; @@ -334,6 +342,51 @@ private static bool TryProjectKnownDataObjectStruct( return false; } + private static bool TryProjectEnumeratedStatusStruct( + string reference, + string fc, + MmsDataValue value, + string reason, + DateTimeOffset receivedAt, + out IReadOnlyList projected) + { + projected = Array.Empty(); + // Constrain the standard status DO names and exact ST FC. A valid q/t + // tuple is not enough to authorize a guessed semantic leaf elsewhere. + if (!string.Equals(fc, "ST", StringComparison.OrdinalIgnoreCase) || + LastSegment(reference).ToUpperInvariant() is not ("MOD" or "BEH" or "HEALTH" or "PHYHEALTH") || + value.Kind != MmsDataKind.Structure || + value.Children.Count is not (3 or 4) || + value.Children[0].Kind is not (MmsDataKind.Integer or MmsDataKind.Unsigned) || + (value.Children.Count == 4 && value.Children[3].Kind != MmsDataKind.Boolean)) + return false; + + var q = Iec61850QualityDecoder.Decode(value.Children[1]); + var t = Iec61850TimestampDecoder.Decode(value.Children[2]); + if (!q.IsDecoded || !t.IsDecoded) + return false; + + // Extra field (if any) was type-checked but is not given an invented + // path. The exact IED schema can project stSeld independently. + var stValReference = Combine(reference, "stVal"); + projected = + [ + new MmsReportProjectedSignalCandidate + { + Reference = stValReference, + FunctionalConstraint = "ST", + Value = DisplayScalar(stValReference, value.Children[0]), + Quality = q.Validity, + Timestamp = t.DisplayTime, + Reason = reason, + UpdatedAt = receivedAt, + IsProjectedChild = true, + ProjectionStatus = "projected-typed-enumerated-status" + } + ]; + return true; + } + private static bool TryProjectBooleanStatusStruct( string reference, string fc, diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs index 3a109e77..0dff372b 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs @@ -193,6 +193,94 @@ public void Project_Does_Not_Misclassify_NonBoolean_Three_Field_Struct_As_Status Assert.Equal("direct", update.ProjectionStatus); } + [Theory] + [InlineData("C264CONTROL/CILO1.Mod", true)] + [InlineData("C264CONTROL/CSWI1.Mod", true)] + [InlineData("C264SYSTEM/LPHD1.PhyHealth", false)] + [InlineData("C264CONTROL/CILO1.Beh", false)] + public void C264_Typed_ST_Status_FCD_Projects_StVal_Quality_Time( + string memberReference, bool hasStSeld) + { + // Exact IEC 61850 ST declaration order in the supplied C264 IID: + // enumerated stVal, quality q, UTC t, optionally Boolean stSeld. + // The old generic projector left Mod and PhyHealth as Structure(...). + var at = new DateTimeOffset(2026, 10, 17, 2, 24, 5, TimeSpan.Zero); + var children = new List + { + MmsDataValue.Integer(1), + MmsDataValue.BitString(3, [0x00, 0x00]), + MmsDataValue.UtcTime(new Iec61850UtcTime(at, 0)) + }; + if (hasStSeld) + children.Add(MmsDataValue.Boolean(false)); + var frame = new MmsReportFrame + { + ReceivedAt = at, + Values = + [ + new MmsReportValue + { + Index = 0, + Member = new MmsDataSetDirectoryMember + { + UserReference = memberReference, + FunctionalConstraint = "ST" + }, + Value = MmsDataValue.Structure(children), + ReasonForInclusion = ["general-interrogation"] + } + ] + }; + + var projected = MmsReportValueProjector.Project(frame); + var update = Assert.Single(projected.Updates); + Assert.Equal(memberReference + ".stVal", update.Reference); + Assert.Equal("1", update.Value); // no invented on/off enum mapping + Assert.Equal("good", update.Quality); + Assert.Contains("2026-10-17", update.Timestamp, StringComparison.Ordinal); + Assert.True(update.HasValue); + Assert.True(update.HasQuality); + Assert.True(update.HasTimestamp); + Assert.Equal("projected-typed-enumerated-status", update.ProjectionStatus); + Assert.DoesNotContain(projected.Warnings, x => x.StartsWith("REPORT_RAW_STRUCT", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("MX", false, false, false)] + [InlineData("ST", true, false, false)] + [InlineData("ST", false, true, false)] + [InlineData("ST", false, false, true)] + public void Typed_Enum_Status_Rejects_Unverified_Tuple_Shape( + string fc, bool wrongQuality, bool wrongTimestamp, bool wrongFourth) + { + var at = new DateTimeOffset(2026, 10, 17, 2, 24, 5, TimeSpan.Zero); + var payload = MmsDataValue.Structure([ + MmsDataValue.Integer(1), + wrongQuality ? MmsDataValue.Boolean(false) : MmsDataValue.BitString(3, [0x00, 0x00]), + wrongTimestamp ? MmsDataValue.Boolean(false) : MmsDataValue.UtcTime(new Iec61850UtcTime(at, 0)), + wrongFourth ? MmsDataValue.Integer(99) : MmsDataValue.Boolean(false) + ]); + var frame = new MmsReportFrame + { + ReceivedAt = at, + Values = [new MmsReportValue + { + Index = 0, + Member = new MmsDataSetDirectoryMember + { + UserReference = "C264CONTROL/CILO1.Mod", + FunctionalConstraint = fc + }, + Value = payload + }] + }; + var projected = MmsReportValueProjector.Project(frame); + Assert.DoesNotContain(projected.Updates, + x => x.Reference.Equals("C264CONTROL/CILO1.Mod.stVal", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projected.Warnings, + x => x.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase)); + } + [Theory] [InlineData(0x40, "off")] [InlineData(0x80, "on")] diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 8fb40dc2..00e95eba 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -191,6 +191,107 @@ public void Schema_Mismatch_Fails_Closed_And_Preserves_Raw_Projection() update.ProjectionStatus.Equals("semantic-structured-leaf", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void C264_Incomplete_Live_Schema_Still_Projects_Strictly_Typed_Mod_FCD() + { + // C264 smart discovery can expose stVal without type-probing every + // sibling. That partial model must not suppress a fully typed ST + // Report containing stVal, q, t and stSeld. + const string root = "C264CONTROL/CILO1.Mod"; + const string dataSet = "C264CONTROL/LLN0.LLN0BRptStDs"; + var schema = new LiveIedModelDiscoveryDocument + { + IedName = "C264", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "C264CONTROL", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "CILO1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = root, + Name = "Mod", + Attributes = + [ + new LiveIedDataAttributeModel + { + ObjectReference = root + ".stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST" + } + ] + } + ] + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = dataSet, + Domain = "C264CONTROL", + LogicalNode = "LLN0", + Name = "LLN0BRptStDs", + MemberCount = 1, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = root, + FunctionalConstraint = "ST", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + } + ] + } + ] + }; + var at = new DateTimeOffset(2026, 10, 17, 2, 24, 5, TimeSpan.Zero); + var frame = new MmsReportFrame + { + ReceivedAt = at, + Header = new MmsReportHeader { DataSetReference = dataSet }, + Values = + [ + new MmsReportValue + { + Index = 0, + Member = new MmsDataSetDirectoryMember + { + UserReference = root, + FunctionalConstraint = "ST" + }, + Value = MmsDataValue.Structure([ + MmsDataValue.Integer(1), + MmsDataValue.BitString(3, [0x00, 0x00]), + MmsDataValue.UtcTime(new Iec61850UtcTime(at, 0)), + MmsDataValue.Boolean(false) + ]) + } + ] + }; + + var projected = MmsSemanticReportValueProjector.Project( + frame, MmsReportSemanticProjectionContext.Create(schema)); + var scalar = Assert.Single(projected.Updates); + Assert.Equal(root + ".stVal", scalar.Reference); + Assert.Equal("good", scalar.Quality); + Assert.True(scalar.HasTimestamp); + Assert.DoesNotContain(projected.Warnings, + x => x.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projected.Updates, + x => x.Value.StartsWith("Structure(", StringComparison.OrdinalIgnoreCase)); + } + private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( string objectReference, string dataSetReference)