Skip to content
Merged
16 changes: 15 additions & 1 deletion Services/DiagnosticReportBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ public static async Task<string> BuildAsync(
builder.AppendLine($"Selected : live={device.SelectedLiveSignalCount:N0}, control={device.SelectedControlSignalCount:N0}");
builder.AppendLine($"Logical Devices : {EmptyAsUnavailable(device.LogicalDeviceSummary)}");
builder.AppendLine($"Identity source : {EmptyAsUnavailable(device.IdentitySource)}");

var dataSetModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel;
if (dataSetModel is not null)
{
var completeness = Iec61850DataSetCompletenessDiagnostic.Evaluate(dataSetModel, device.Signals);
builder.AppendLine("DataSet inventory:");
foreach (var line in Iec61850DataSetCompletenessDiagnostic.FormatReportLines(completeness))
builder.AppendLine($" {line}");
}
else
{
builder.AppendLine("DataSet inventory: unavailable (no SCL/live discovery model)");
}

var route = AnalyzeRoute(device.IpAddress);
builder.AppendLine($"TCP probe : {probe?.Result ?? "not run"} {probe?.Detail ?? string.Empty}".TrimEnd());
builder.AppendLine($"Route source : {route.Source}");
Expand Down Expand Up @@ -369,4 +383,4 @@ private static string Truncate(string? value, int maximumLength)
? normalized
: normalized[..maximumLength] + $"… [truncated {normalized.Length - maximumLength:N0} chars]";
}
}
}
104 changes: 104 additions & 0 deletions Services/Iec61850DataSetCompletenessDiagnostic.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;

namespace ArIED61850Tester.Services;

public sealed record Iec61850DataSetCompletenessSnapshot(
int DataSetCount,
int StaticMemberCount,
int MandatoryInventoryCount,
int RepresentedCount,
int PrimaryLeafUnresolvedCount,
IReadOnlyList<string> MissingReferences)
{
public int MissingCount => MissingReferences.Count;
public bool IsComplete => StaticMemberCount == RepresentedCount && MissingCount == 0;

public string Summary =>
$"DataSets={DataSetCount:N0}; static members={StaticMemberCount:N0}; mandatory inventory={MandatoryInventoryCount:N0}; " +
$"represented={RepresentedCount:N0}/{StaticMemberCount:N0}; primary leaf unresolved={PrimaryLeafUnresolvedCount:N0}; missing={MissingCount:N0}";
}

/// <summary>
/// Actionable diagnostic for static IEC 61850 DataSet inventory completeness.
/// ARIEC remains the authority for reference identity and primary-leaf resolution.
/// ARSAS measures every static FCDA member directly so projection aggregation can never
/// make a partially represented DataSet appear complete.
/// </summary>
public static class Iec61850DataSetCompletenessDiagnostic
{
public static Iec61850DataSetCompletenessSnapshot Evaluate(
LiveIedModelDiscoveryDocument? model,
IEnumerable<SignalDefinition> signals)
{
ArgumentNullException.ThrowIfNull(signals);

if (model is null)
return new Iec61850DataSetCompletenessSnapshot(0, 0, 0, 0, 0, Array.Empty<string>());

var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model);
var signalReferences = signals
.SelectMany(signal => new[] { signal.DisplayReference, signal.ObjectReference })
.Where(reference => !string.IsNullOrWhiteSpace(reference))
.Select(Literal)
.ToHashSet(StringComparer.OrdinalIgnoreCase);

var missing = new List<string>();
var represented = 0;
var staticMembers = model.DataSets
.OrderBy(dataSet => dataSet.Reference, StringComparer.OrdinalIgnoreCase)
.SelectMany(dataSet => dataSet.Members
.OrderBy(member => member.Index)
.Select(member => new
{
DataSetReference = dataSet.Reference,
member.Index,
Reference = Literal(member.Reference)
}))
.ToArray();

foreach (var member in staticMembers)
{
if (member.Reference.Length > 0 && signalReferences.Contains(member.Reference))
{
represented++;
continue;
}

var reference = member.Reference.Length == 0 ? "<no static member reference>" : member.Reference;
missing.Add($"{member.DataSetReference}[{member.Index}] -> {reference}");
}

return new Iec61850DataSetCompletenessSnapshot(
model.DataSets.Count,
staticMembers.Length,
mandatory.Count,
represented,
mandatory.Count(descriptor => descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved),
missing);
}

public static IEnumerable<string> FormatReportLines(Iec61850DataSetCompletenessSnapshot snapshot, int maxMissing = 12)
{
ArgumentNullException.ThrowIfNull(snapshot);

yield return $"Static DataSets : {snapshot.DataSetCount:N0}";
yield return $"Static members : {snapshot.StaticMemberCount:N0}";
yield return $"Mandatory inventory: {snapshot.MandatoryInventoryCount:N0} descriptor(s)";
yield return $"Signal Selection : {snapshot.RepresentedCount:N0}/{snapshot.StaticMemberCount:N0} static member(s) represented";
yield return $"Primary unresolved: {snapshot.PrimaryLeafUnresolvedCount:N0}";
yield return $"Missing inventory : {snapshot.MissingCount:N0}";

if (snapshot.MissingCount == 0)
yield break;

foreach (var reference in snapshot.MissingReferences.Take(Math.Max(0, maxMissing)))
yield return $" MISSING : {reference}";

if (snapshot.MissingCount > maxMissing)
yield return $" ... : {snapshot.MissingCount - maxMissing:N0} more missing member(s)";
}

private static string Literal(string? reference)
=> (reference ?? string.Empty).Trim();
}
96 changes: 79 additions & 17 deletions Services/Iec61850DataSetSignalInventoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,41 +49,57 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals(
return EmptyResult();

// Keep application matching literal. The engine owns IEC 61850 reference
// canonicalization; ARSAS only compares the reference forms that the engine has
// already exposed on the descriptor.
// canonicalization; ARSAS only compares reference forms already exposed by ARIEC.
// DisplayReference is included because static FCD identity is intentionally kept
// separate from a resolved runtime DataAttribute reference such as .stVal.
var existing = signals
.Where(signal => !string.IsNullOrWhiteSpace(signal.ObjectReference))
.GroupBy(signal => LiteralReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
.SelectMany(signal => SignalReferenceCandidates(signal).Select(reference => (reference, signal)))
.GroupBy(item => item.reference, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().signal, StringComparer.OrdinalIgnoreCase);

var added = new List<SignalDefinition>();
var enriched = 0;

foreach (var descriptor in mandatory)
{
var inventoryReference = InventoryReference(descriptor);
var engineReferences = EngineReferenceCandidates(descriptor).ToArray();
var current = engineReferences
.Select(reference => existing.TryGetValue(reference, out var signal) ? signal : null)
.FirstOrDefault(signal => signal is not null);

if (current is not null)
{
if (ApplyEngineDataSetAuthority(current, descriptor))
if (ApplyEngineDataSetAuthority(current, descriptor, inventoryReference))
enriched++;

foreach (var key in EngineReferenceCandidates(descriptor)
.Concat(SignalReferenceCandidates(current))
.Append(inventoryReference)
.Where(value => !string.IsNullOrWhiteSpace(value)))
{
existing.TryAdd(LiteralReference(key), current);
}
continue;
}

var reference = FirstNonEmpty(
var runtimeReference = FirstNonEmpty(
descriptor.PrimaryValueReference,
descriptor.DesignReference,
descriptor.ObservedReference);
var reference = FirstNonEmpty(runtimeReference, inventoryReference);
if (string.IsNullOrWhiteSpace(reference))
continue;

var signal = CreateSignal(descriptor, reference);
var signal = CreateSignal(descriptor, reference, inventoryReference);
signals.Add(signal);
foreach (var key in EngineReferenceCandidates(descriptor).Append(LiteralReference(reference)))
existing.TryAdd(key, signal);
foreach (var key in EngineReferenceCandidates(descriptor)
.Concat(SignalReferenceCandidates(signal))
.Append(inventoryReference)
.Where(value => !string.IsNullOrWhiteSpace(value)))
{
existing.TryAdd(LiteralReference(key), signal);
}
added.Add(signal);
}

Expand All @@ -95,16 +111,23 @@ private static Iec61850DataSetSignalInventoryMergeResult EmptyResult()

private static SignalDefinition CreateSignal(
Iec61850SignalDescriptor descriptor,
string reference)
string runtimeReference,
string inventoryReference)
{
var primaryMembership = FirstMembership(descriptor);
var report = descriptor.ReportMemberships.FirstOrDefault();
var dataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown");
var unresolved = descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved;
var staticReference = FirstNonEmpty(inventoryReference, runtimeReference);
var objectReference = unresolved ? staticReference : FirstNonEmpty(runtimeReference, staticReference);

return new SignalDefinition
{
Name = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, reference),
ObjectReference = reference,
Name = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, staticReference),
ObjectReference = objectReference,
// Signal Selection binds IEC Telegram to DisplayReference. Preserve the exact
// static FCDA/FCD member here even when ARIEC resolves a readable primary leaf.
DisplayReference = staticReference,
FunctionalConstraint = descriptor.FunctionalConstraint,
DataType = dataType,
Category = "DataSet",
Expand Down Expand Up @@ -135,11 +158,23 @@ private static SignalDefinition CreateSignal(

private static bool ApplyEngineDataSetAuthority(
SignalDefinition signal,
Iec61850SignalDescriptor descriptor)
Iec61850SignalDescriptor descriptor,
string inventoryReference)
{
var changed = false;
var membership = FirstMembership(descriptor);
var report = descriptor.ReportMemberships.FirstOrDefault();
var staticReference = FirstNonEmpty(inventoryReference, signal.DisplayReference, signal.ObjectReference);

// Never replace the user-visible static DataSet member with a guessed/resolved leaf.
// ObjectReference can remain the engine-resolved runtime leaf for MMS reads; the
// selector's IEC Telegram column is bound to DisplayReference.
if (!string.IsNullOrWhiteSpace(staticReference) &&
!string.Equals(signal.DisplayReference, staticReference, StringComparison.OrdinalIgnoreCase))
{
signal.DisplayReference = staticReference;
changed = true;
}

if (membership is not null &&
!string.Equals(signal.DataSetReference, membership.DataSetReference, StringComparison.OrdinalIgnoreCase))
Expand Down Expand Up @@ -197,9 +232,24 @@ private static bool ApplyEngineDataSetAuthority(
return changed;
}

private static IEnumerable<string> SignalReferenceCandidates(SignalDefinition signal)
=> new[] { signal.DisplayReference, signal.ObjectReference }
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(LiteralReference)
.Distinct(StringComparer.OrdinalIgnoreCase);

private static IEnumerable<string> EngineReferenceCandidates(Iec61850SignalDescriptor descriptor)
{
var values = new[]
var membershipReferences = descriptor.DataSetMemberships
.OrderBy(membership => membership.DataSetReference, StringComparer.OrdinalIgnoreCase)
.ThenBy(membership => membership.MemberIndex)
.SelectMany(membership => new[]
{
membership.CanonicalMemberReference,
membership.OriginalMemberReference
});

var descriptorReferences = new[]
{
descriptor.PrimaryValueReference,
descriptor.DesignReference,
Expand All @@ -210,12 +260,24 @@ private static IEnumerable<string> EngineReferenceCandidates(Iec61850SignalDescr
descriptor.ObservedMmsReference
};

return values
return membershipReferences
.Concat(descriptorReferences)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(LiteralReference)
.Distinct(StringComparer.OrdinalIgnoreCase);
}

private static string InventoryReference(Iec61850SignalDescriptor descriptor)
{
var membership = FirstMembership(descriptor);
return FirstNonEmpty(
membership?.CanonicalMemberReference,
membership?.OriginalMemberReference,
descriptor.DesignReference,
descriptor.ObservedReference,
descriptor.PrimaryValueReference);
}

private static Iec61850SignalDataSetMembership? FirstMembership(Iec61850SignalDescriptor descriptor)
=> descriptor.DataSetMemberships
.OrderBy(membership => membership.DataSetReference, StringComparer.OrdinalIgnoreCase)
Expand All @@ -239,7 +301,7 @@ private static string BuildCoverageReason(Iec61850SignalDescriptor descriptor)
: "mandatory primary DataSet signal";
var resolutionText = unresolved
? " The original DataSet member is preserved while its unique primary DataAttribute remains unresolved."
: string.Empty;
: " The static FCDA identity stays visible even when a readable primary DataAttribute is resolved for runtime acquisition.";

return $"ARIEC61850 {authorityText}: {membershipText}." +
resolutionText +
Expand Down
6 changes: 3 additions & 3 deletions engines/ARIEC61850.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
"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."
"commit": "4fc3119be2bb97e0ea3a8d31fb990e6a1a089e60",
"sourcePullRequest": 77,
"purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves every mandatory static DataSet member, including unresolved object-level FCD members without inventing .stVal. PR #77 canonicalizes SCL DataSet member identity across logical devices so parser display references such as IED/LD/LN.DO [FC] resolve to the same IEDLD/LN.DO identity used by the live model."
}
Loading
Loading