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()
+ };
+}