diff --git a/Services/Iec61850DataSetSignalInventoryService.cs b/Services/Iec61850DataSetSignalInventoryService.cs index 652280b77..7e297e13d 100644 --- a/Services/Iec61850DataSetSignalInventoryService.cs +++ b/Services/Iec61850DataSetSignalInventoryService.cs @@ -14,9 +14,9 @@ public sealed record Iec61850DataSetSignalInventoryMergeResult( /// /// Application-side projection of ARIEC-owned DataSet signal authority. /// -/// ARIEC decides which IEC 61850 signals are mandatory primary DataSet members. -/// ARSAS only guarantees that those already-classified engine signals are present in -/// the user-visible signal inventory. No IEC reference guessing, fuzzy matching, or +/// ARIEC decides which IEC 61850 signals are mandatory DataSet inventory members. +/// ARSAS only guarantees that those engine-owned descriptors are present in the +/// user-visible signal inventory. No IEC reference guessing, fuzzy matching, or /// DataSet semantic inference is performed here. /// public static class Iec61850DataSetSignalInventoryService @@ -29,8 +29,7 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( if (device.LiveDiscoveryModel is null) return new Iec61850DataSetSignalInventoryMergeResult(Array.Empty(), 0, 0); - var catalog = Iec61850SignalCatalogBuilder.Build(device.LiveDiscoveryModel); - var mandatory = catalog.GetMandatoryPrimarySignals(); + var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(device.LiveDiscoveryModel); if (mandatory.Count == 0) return new Iec61850DataSetSignalInventoryMergeResult(Array.Empty(), 0, 0); @@ -83,6 +82,7 @@ private static SignalDefinition CreateSignal( var primaryMembership = FirstMembership(descriptor); var report = descriptor.ReportMemberships.FirstOrDefault(); var dataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown"); + var unresolved = descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved; return new SignalDefinition { Name = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, reference), @@ -90,19 +90,25 @@ private static SignalDefinition CreateSignal( FunctionalConstraint = descriptor.FunctionalConstraint, DataType = dataType, Category = "DataSet", - Confidence = "High", + Confidence = unresolved ? "Medium" : "High", DataSetReference = primaryMembership?.DataSetReference ?? string.Empty, ReportControlReference = report?.ReportControlReference ?? string.Empty, QualityReference = descriptor.QualityReference, TimestampReference = descriptor.TimestampReference, - Source = "ARIEC61850 signal catalog • mandatory static DataSet member", + Source = unresolved + ? "ARIEC61850 signal inventory • mandatory static DataSet member • primary leaf unresolved" + : "ARIEC61850 signal inventory • mandatory static DataSet member", IsSelected = false, IsReportCapable = true, - ReportCoverage = report is null - ? "Static DataSet member • MMS polling fallback" - : "Static report/DataSet • polling fallback", + ReportCoverage = unresolved + ? report is null + ? "Static DataSet member • primary leaf unresolved" + : "Static report/DataSet • primary leaf unresolved" + : report is null + ? "Static DataSet member • MMS polling fallback" + : "Static report/DataSet • polling fallback", ReportCoverageReason = BuildCoverageReason(descriptor), - ProbeStatus = "Not probed", + ProbeStatus = unresolved ? "DataSet member — primary leaf unresolved" : "Not probed", Value = "-", Quality = "Unknown", DeviceTimestamp = "-" @@ -140,9 +146,14 @@ private static bool ApplyEngineDataSetAuthority( signal.ReportCoverage.Equals("Polling fallback", StringComparison.OrdinalIgnoreCase)) && (membership is not null || report is not null)) { - signal.ReportCoverage = report is null - ? "Static DataSet member • MMS polling fallback" - : "Static report/DataSet • polling fallback"; + var unresolved = descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved; + signal.ReportCoverage = unresolved + ? report is null + ? "Static DataSet member • primary leaf unresolved" + : "Static report/DataSet • primary leaf unresolved" + : report is null + ? "Static DataSet member • MMS polling fallback" + : "Static report/DataSet • polling fallback"; changed = true; } @@ -204,9 +215,17 @@ private static string BuildCoverageReason(Iec61850SignalDescriptor descriptor) var membershipText = memberships.Length == 0 ? "static DataSet membership" : string.Join(", ", memberships); - - return $"ARIEC61850 mandatory primary DataSet signal: {membershipText}. " + - "Inventory presence is engine-authoritative; user selection remains independent."; + var unresolved = descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved; + var authorityText = unresolved + ? "mandatory static DataSet member" + : "mandatory primary DataSet signal"; + var resolutionText = unresolved + ? " The original DataSet member is preserved while its unique primary DataAttribute remains unresolved." + : string.Empty; + + return $"ARIEC61850 {authorityText}: {membershipText}." + + resolutionText + + " Inventory presence is engine-authoritative; user selection remains independent."; } private static string LiteralReference(string? reference) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 0a7abe020..6796bc986 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "32351b42b4719e5dccfa068fbbd797c2414a6b4b", - "sourcePullRequest": 75, - "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Baseline includes connected reconciliation, COMTRADE/MMS FileOpen interoperability, UTC-Time wire provenance, typed signal catalog query/coverage APIs, and authoritative hybrid reporting. PR #75 adds bounded live DataSet-directory discovery and permits a verified-free dynamic RCB to own a temporary DataSet whose members reside in other logical devices. ARSAS consumes these typed engine decisions and does not recreate IEC report capability heuristics." + "commit": "52f8e383711f63d4a37af742fc92243be80aa0d6", + "sourcePullRequest": 76, + "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Baseline includes connected reconciliation, COMTRADE/MMS FileOpen interoperability, UTC-Time wire provenance, typed signal catalog query/coverage APIs, authoritative hybrid reporting, bounded live DataSet-directory discovery, and cross-logical-device dynamic RCB planning. PR #76 adds the engine-owned DataSet selection inventory projection so every static DataSet member remains application-visible even when an FCD/DataObject-level member has no uniquely resolved primary DataAttribute yet. Resolved primary signals remain preferred; unresolved members are preserved without inventing leaf semantics." } diff --git a/tests/ARSAS.Tests/DataSetFcdSignalInventoryTests.cs b/tests/ARSAS.Tests/DataSetFcdSignalInventoryTests.cs new file mode 100644 index 000000000..699a4ffc0 --- /dev/null +++ b/tests/ARSAS.Tests/DataSetFcdSignalInventoryTests.cs @@ -0,0 +1,150 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class DataSetFcdSignalInventoryTests +{ + [Fact] + public void FcdOnly_SiemensLike_DataSets_Appear_58Of58_In_Signal_Selector_Inventory() + { + var digitalMembers = Enumerable.Range(1, 36) + .Select(index => Member(index - 1, $"IEDLD0/GGIO1.Dig{index:00}", "ST")) + .ToArray(); + var analogMembers = Enumerable.Range(1, 22) + .Select(index => Member(index - 1, $"IEDLD0/MMXU1.Ana{index:00}", "MX")) + .ToArray(); + var device = new Iec61850MonitorDevice + { + Name = "IED", + LiveDiscoveryModel = BuildModel(digitalMembers, analogMembers) + }; + + var result = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + + Assert.Equal(58, result.MandatoryCatalogCount); + Assert.Equal(58, result.AddedCount); + Assert.Equal(58, device.Signals.Count); + Assert.All(device.Signals, signal => + { + Assert.False(signal.IsSelected); + Assert.Equal("DataSet", signal.Category); + Assert.True(signal.IsReportCapable); + Assert.Contains("primary leaf unresolved", signal.ReportCoverage, StringComparison.OrdinalIgnoreCase); + }); + Assert.Contains(device.Signals, signal => signal.ObjectReference == "IEDLD0/GGIO1.Dig01"); + Assert.Contains(device.Signals, signal => signal.ObjectReference == "IEDLD0/MMXU1.Ana22"); + } + + [Fact] + public void Reopening_Selector_Does_Not_Duplicate_FcdOnly_DataSet_Members() + { + var member = Member(0, "IEDLD0/GGIO1.CBOpnd", "ST"); + var device = new Iec61850MonitorDevice + { + Name = "IED", + LiveDiscoveryModel = BuildModel(new[] { member }, Array.Empty()) + }; + + var first = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + var second = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + + Assert.Equal(1, first.AddedCount); + Assert.Equal(0, second.AddedCount); + var signal = Assert.Single(device.Signals); + Assert.Equal("IEDLD0/GGIO1.CBOpnd", signal.ObjectReference); + Assert.False(signal.IsSelected); + } + + private static LiveIedModelDiscoveryDocument BuildModel( + IReadOnlyList digitalMembers, + IReadOnlyList analogMembers) + { + var dataSets = new List(); + if (digitalMembers.Count > 0) + { + dataSets.Add(new LiveIedDataSetModel + { + Reference = "IEDLD0/LLN0.Digital", + Domain = "IEDLD0", + LogicalNode = "LLN0", + Name = "Digital", + MemberCount = digitalMembers.Count, + Members = digitalMembers + }); + } + if (analogMembers.Count > 0) + { + dataSets.Add(new LiveIedDataSetModel + { + Reference = "IEDLD0/LLN0.Analog", + Domain = "IEDLD0", + LogicalNode = "LLN0", + Name = "Analog", + MemberCount = analogMembers.Count, + Members = analogMembers + }); + } + + return new LiveIedModelDiscoveryDocument + { + Source = "LiveMmsDiscovery", + IedName = "IED", + LogicalDevices = new[] + { + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + Inst = "LD0", + LogicalNodes = new[] + { + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + DataObjects = digitalMembers.Select(member => DataObject(member.Reference, "SPS")).ToArray() + }, + new LiveIedLogicalNodeModel + { + Name = "MMXU1", + LnClass = "MMXU", + LnInst = "1", + DataObjects = analogMembers.Select(member => DataObject(member.Reference, "MV")).ToArray() + } + } + } + }, + DataSets = dataSets + }; + } + + private static LiveIedDataSetMemberModel Member(int index, string reference, string functionalConstraint) + { + var slash = reference.IndexOf('/'); + var domain = reference[..slash]; + var path = reference[(slash + 1)..]; + var firstDot = path.IndexOf('.'); + var logicalNode = path[..firstDot]; + var objectPath = path[(firstDot + 1)..].Replace('.', '$'); + return new LiveIedDataSetMemberModel + { + Index = index, + Reference = reference, + FunctionalConstraint = functionalConstraint, + MmsReference = $"{domain}/{logicalNode}${functionalConstraint}${objectPath}", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + } + + private static LiveIedDataObjectModel DataObject(string reference, string cdc) + => new() + { + Reference = reference, + Name = reference[(reference.LastIndexOf('.') + 1)..], + InferredCdc = cdc, + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Low, + Attributes = Array.Empty() + }; +}