Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7b5659f
Resolve structured DataSet members to primary runtime leaves
masarray Sep 1, 2026
c8e91d0
Project mandatory DataSet runtime leaf from semantic binding
masarray Sep 1, 2026
7d40a3e
Cover structured measurement DataSet member binding
masarray Sep 1, 2026
bd959bc
Preserve unresolved DataSet evidence wording
masarray Sep 1, 2026
a5b0723
P5.4: add fail-closed semantic structured report projection
masarray Sep 1, 2026
892540b
P5.4: cover multi-phase structured report fan-out
masarray Sep 1, 2026
fe68652
P5.4: record physical THD bench provenance
masarray Sep 1, 2026
7a4e52a
P5.4: keep public regression provenance vendor-neutral
masarray Sep 1, 2026
448b26c
P5.4: satisfy xUnit analyzer in semantic report regression
masarray Sep 1, 2026
888545b
P5.4: align semantic projection regression with renderer precision
masarray Sep 1, 2026
1fc06d2
Fix structured report semantic matching by exact member reference
masarray Sep 2, 2026
b9ee5fc
Add regression for sparse structured report index drift
masarray Sep 2, 2026
2eaefb8
Prefer exact semantic schema over generic structured report heuristics
masarray Sep 4, 2026
52c1908
Add TotPF regression for exact semantic report schema authority
masarray Sep 4, 2026
69bfe70
Model TotPF report shape with q and t companions
masarray Sep 4, 2026
ba6211b
Harden semantic report replacement for index-resolved members
masarray Sep 5, 2026
9158783
Add regression for index-resolved semantic report members
masarray Sep 5, 2026
0d7525b
Clarify index-fallback regression intent
masarray Sep 5, 2026
11ab230
Harden persistent BRCB activation for mature client behavior
masarray Sep 5, 2026
1b12744
Support multiple RCB selections in legacy SAS export
masarray Sep 6, 2026
cdcaaa8
Preserve exact single-RCB export behavior
masarray Sep 6, 2026
f1fa176
Cover separate analog and digital RCB export
masarray Sep 6, 2026
9fa571a
Normalize exact runtime identities for multi-RCB export
masarray Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 254 additions & 38 deletions src/AR.Iec61850/Discovery/Iec61850DataSetSemanticBindingResolver.cs

Large diffs are not rendered by default.

101 changes: 58 additions & 43 deletions src/AR.Iec61850/Discovery/Iec61850DataSetSignalInventoryProjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,77 +17,87 @@ public static IReadOnlyList<Iec61850SignalDescriptor> GetMandatorySignals(
{
ArgumentNullException.ThrowIfNull(design);

var semanticBindings = Iec61850DataSetSemanticBindingResolver.Resolve(design);
var catalog = Iec61850SignalCatalogBuilder.Build(design, reconciliation);
var primaryByMember = catalog.GetMandatoryPrimarySignals()
.SelectMany(signal => signal.DataSetMemberships
.Where(membership => membership.IsPrimaryValueForMember)
.Select(membership => new
{
Key = MembershipKey(membership),
Signal = signal
}))
.GroupBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.Select(item => item.Signal).Distinct().ToArray(),
StringComparer.OrdinalIgnoreCase);

var result = new List<Iec61850SignalDescriptor>();

foreach (var dataSet in design.DataSets.OrderBy(x => x.Reference, StringComparer.OrdinalIgnoreCase))
{
foreach (var member in dataSet.Members.OrderBy(x => x.Index))
{
var key = MembershipKey(dataSet.Reference, member.Index);
if (primaryByMember.TryGetValue(key, out var candidates) && candidates.Length == 1)
var binding = semanticBindings.Find(dataSet.Reference, member.Index);
var primary = binding?.PrimaryValue;
if (primary is not null)
{
result.Add(ProjectResolvedMemberDescriptor(candidates[0], dataSet, member));
continue;
var source = FindCatalogSignal(catalog, primary);
if (source is not null)
{
result.Add(ProjectResolvedMemberDescriptor(source, dataSet, member, binding!));
continue;
}
}

var reason = candidates is { Length: > 1 }
? $"Static DataSet member {dataSet.Reference}[{member.Index}] has {candidates.Length} primary-value candidates; the member identity is preserved without guessing a runtime leaf."
: $"Static DataSet member {dataSet.Reference}[{member.Index}] has no unique primary DataAttribute; the member identity is preserved in the signal inventory.";
var reason = binding is null
? $"Static DataSet member {dataSet.Reference}[{member.Index}] has no semantic binding; the member identity is preserved without guessing a runtime leaf."
: binding.ResolutionStatus == LiveIedDataSetMemberResolutionStatus.Ambiguous
? $"Static DataSet member {dataSet.Reference}[{member.Index}] has ambiguous primary-value semantics; the member identity is preserved without guessing a runtime leaf."
: $"Static DataSet member {dataSet.Reference}[{member.Index}] has no unique primary DataAttribute; the member identity is preserved without guessing a runtime leaf.";
result.Add(BuildUnresolvedMemberDescriptor(design, dataSet, member, reason));
}
}

return result.ToArray();
}

private static Iec61850SignalDescriptor? FindCatalogSignal(
Iec61850SignalCatalogDocument catalog,
LiveIedResolvedDataSetAttributeModel primary)
{
if (!string.IsNullOrWhiteSpace(primary.MmsReference))
{
var byMms = catalog.FindByCanonicalMmsReference(primary.MmsReference);
if (byMms is not null)
return byMms;
}

return catalog.Signals.FirstOrDefault(signal =>
ReferenceEquals(signal.DesignReference, primary.Reference));
}

private static Iec61850SignalDescriptor ProjectResolvedMemberDescriptor(
Iec61850SignalDescriptor source,
LiveIedDataSetModel dataSet,
LiveIedDataSetMemberModel member)
LiveIedDataSetMemberModel member,
LiveIedDataSetMemberSemanticBinding binding)
{
var memberReference = NormalizeReference(member.Reference);
var sourceMembership = source.DataSetMemberships.FirstOrDefault(membership =>
string.Equals(
MembershipKey(membership),
MembershipKey(dataSet.Reference, member.Index),
StringComparison.OrdinalIgnoreCase));
var functionalConstraint = (member.FunctionalConstraint ?? string.Empty).Trim().ToUpperInvariant();
var membership = new Iec61850SignalDataSetMembership
{
DataSetReference = dataSet.Reference,
MemberIndex = member.Index,
OriginalMemberReference = member.Reference,
CanonicalMemberReference = memberReference,
FunctionalConstraint = FirstNonEmpty(sourceMembership?.FunctionalConstraint, functionalConstraint),
Cdc = FirstNonEmpty(sourceMembership?.Cdc, source.Cdc),
ResolutionStatus = sourceMembership?.ResolutionStatus ?? LiveIedDataSetMemberResolutionStatus.Unresolved,
FunctionalConstraint = FirstNonEmpty(binding.FunctionalConstraint, source.FunctionalConstraint, functionalConstraint),
Cdc = FirstNonEmpty(binding.Cdc, source.Cdc),
ResolutionStatus = binding.ResolutionStatus,
IsPrimaryValueForMember = true
};
var reports = source.ReportMemberships
.Where(report => ReferenceEquals(report.DataSetReference, dataSet.Reference))
.ToArray();
if (reports.Length == 0)
{
reports = designReports(dataSet, source).ToArray();
}
var evidence = source.Evidence
.Concat(new[]
{
new Iec61850SignalEvidence
{
Kind = Iec61850SignalEvidenceKind.DataSetSemanticBinding,
SourceReference = memberReference,
Message = $"Static DataSet member {dataSet.Reference}[{member.Index}] keeps its original FCD/FCDA identity; runtime primary binding is '{FirstNonEmpty(source.PrimaryValueReference, source.DesignReference)}'."
Message = $"Static DataSet member {dataSet.Reference}[{member.Index}] keeps its original FCD/FCDA identity; runtime primary binding is '{binding.PrimaryValueReference}'. {string.Join(" ", binding.Evidence)}"
}
})
.ToArray();
Expand All @@ -100,7 +110,7 @@ private static Iec61850SignalDescriptor ProjectResolvedMemberDescriptor(
EffectiveMmsReference = source.EffectiveMmsReference,
ObservedMmsReference = source.ObservedMmsReference,
FunctionalConstraint = FirstNonEmpty(source.FunctionalConstraint, functionalConstraint),
Cdc = source.Cdc,
Cdc = FirstNonEmpty(binding.Cdc, source.Cdc),
SclBType = source.SclBType,
MmsType = source.MmsType,
MmsDomain = source.MmsDomain,
Expand All @@ -110,23 +120,34 @@ private static Iec61850SignalDescriptor ProjectResolvedMemberDescriptor(
DataObject = source.DataObject,
DataObjectReference = source.DataObjectReference,
DataAttributePath = source.DataAttributePath,
SemanticRole = source.SemanticRole,
PrimaryValueReference = FirstNonEmpty(source.PrimaryValueReference, source.DesignReference),
PrimaryValueMmsReference = FirstNonEmpty(source.PrimaryValueMmsReference, source.CanonicalMmsReference),
SemanticRole = Iec61850DataAttributeSemanticRole.PrimaryValue,
PrimaryValueReference = binding.PrimaryValueReference,
PrimaryValueMmsReference = binding.PrimaryValueMmsReference,
QualityReference = source.QualityReference,
QualityMmsReference = source.QualityMmsReference,
TimestampReference = source.TimestampReference,
TimestampMmsReference = source.TimestampMmsReference,
DataSetMemberships = new[] { membership },
ReportMemberships = reports,
IsStaticDataSetMandatory = true,
IsOperationalCandidate = source.IsOperationalCandidate,
IsOperationalCandidate = true,
IsEngineeringOnly = false,
ResolutionStatus = source.ResolutionStatus,
LiveStatus = source.LiveStatus,
AlternateStrategy = source.AlternateStrategy,
Evidence = evidence
};

static IEnumerable<Iec61850SignalReportMembership> designReports(
LiveIedDataSetModel dataSet,
Iec61850SignalDescriptor source)
{
// Source catalog normally already carries report membership. Keep the helper
// deliberately empty when it does not; the unresolved path remains responsible
// for design-only report projection. This avoids fabricating report authority.
return source.ReportMemberships.Where(report =>
string.Equals(report.DataSetReference, dataSet.Reference, StringComparison.OrdinalIgnoreCase));
}
}

private static Iec61850SignalDescriptor BuildUnresolvedMemberDescriptor(
Expand Down Expand Up @@ -196,7 +217,7 @@ private static Iec61850SignalDescriptor BuildUnresolvedMemberDescriptor(
{
Kind = Iec61850SignalEvidenceKind.DataSetSemanticBinding,
SourceReference = memberReference,
Message = evidenceMessage ?? $"Static DataSet member {dataSet.Reference}[{member.Index}] is preserved in the signal inventory although no unique primary DataAttribute has been resolved yet."
Message = evidenceMessage ?? $"Static DataSet member {dataSet.Reference}[{member.Index}] is preserved in the signal inventory although no unique primary DataAttribute has been resolved yet, without guessing a runtime leaf."
}
}
};
Expand Down Expand Up @@ -287,12 +308,6 @@ private static string ExtractDataObjectReference(string reference)
return secondDot < 0 ? reference : reference[..secondDot];
}

private static string MembershipKey(Iec61850SignalDataSetMembership membership)
=> MembershipKey(membership.DataSetReference, membership.MemberIndex);

private static string MembershipKey(string dataSetReference, int memberIndex)
=> $"{NormalizeReference(dataSetReference)}\u001f{memberIndex}";

private static bool ReferenceEquals(string? left, string? right)
=> string.Equals(NormalizeReference(left), NormalizeReference(right), StringComparison.OrdinalIgnoreCase);

Expand Down
153 changes: 153 additions & 0 deletions src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
namespace AR.Iec61850.Mms;

/// <summary>
/// Client-compatibility activation wrapper for persistent reporting.
///
/// Mature IEC 61850 clients normally reserve a BRCB when ResvTms is exposed,
/// enable reporting, install/retain the report receiver, and only then request GI.
/// Some servers also support implicit BRCB reservation through RptEna=true, so an
/// explicit ResvTms rejection is non-fatal and the baseline activation is still tried.
///
/// This wrapper does not create dynamic DataSets and does not schedule cyclic process
/// reads. It only hardens the RCB control-plane sequence used by report acquisition.
/// </summary>
public sealed partial class MmsClientSession
{
public async Task<MmsPersistentReportMonitorAttemptResult> StartPersistentReportMonitorClientCompatibleAsync(
MmsReportSubscriptionPlan plan,
bool triggerGeneralInterrogation = true,
bool deleteDynamicDataSetOnStop = true,
MmsIedModelDirectory? directory = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plan);

var rcb = plan.ReportControl;
MmsReportAttributeWriteStep? reservationStep = null;
var compatibilityWarnings = new List<string>();

if (rcb is { Buffered: true } &&
rcb.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase) &&
!MmsReportSubscriptionPlanner.IsExplicitlyEnabled(rcb) &&
!MmsReportSubscriptionPlanner.IsReservedByOtherClient(rcb))
{
reservationStep = await WriteReportAttributeAsync(
rcb,
"ResvTms",
MmsDataValue.Unsigned(60),
cancellationToken).ConfigureAwait(false);

if (!reservationStep.IsSuccess)
{
compatibilityWarnings.Add(
$"BRCB ResvTms=60 explicit reservation was not accepted ({reservationStep.Message}). Continuing with standards-compatible implicit reservation through RptEna=true.");
}
}

// Deliberately suppress GI inside the baseline start. The baseline method registers
// the persistent monitor only after RptEna succeeds. Requesting GI below guarantees
// that the report receiver/session is already registered when the server emits the
// initial InformationReport, while the receive router still preserves any earlier
// unconfirmed traffic that arrived during confirmed writes.
var attempt = await StartPersistentReportMonitorWithAttemptEvidenceAsync(
plan,
triggerGeneralInterrogation: false,
deleteDynamicDataSetOnStop,
directory,
cancellationToken).ConfigureAwait(false);

var start = attempt.StartResult;
var writes = new List<MmsReportAttributeWriteStep>();
if (reservationStep is not null)
writes.Add(reservationStep);
writes.AddRange(start.WriteSteps);

var warnings = start.Warnings
.Where(warning => !warning.Contains("ResvTms pre-reserve was skipped", StringComparison.OrdinalIgnoreCase))
.Concat(compatibilityWarnings)
.ToList();

if (!attempt.IsSuccess || start.Session is null)
{
var cleanupSteps = attempt.CleanupSteps.ToList();
var cleanupWarnings = attempt.CleanupWarnings.ToList();
var cleanupAttempted = attempt.CleanupAttempted;
var cleanupSucceeded = attempt.CleanupSucceeded;

if (reservationStep?.IsSuccess == true && rcb is not null)
{
var release = await TryWriteReportAttributeForCleanupAsync(
rcb,
"ResvTms",
MmsDataValue.Unsigned(0),
CancellationToken.None).ConfigureAwait(false);
cleanupSteps.Add(release);
cleanupAttempted = true;
cleanupSucceeded &= release.IsSuccess;
if (!release.IsSuccess)
cleanupWarnings.Add($"BRCB ResvTms cleanup after failed activation was not accepted: {release.Message}");
}

return new MmsPersistentReportMonitorAttemptResult
{
StartResult = CopyStartResult(start, writes, warnings),
DynamicAttemptState = attempt.DynamicAttemptState,
FailureReason = attempt.FailureReason,
CleanupAttempted = cleanupAttempted,
CleanupSucceeded = cleanupSucceeded,
CleanupSteps = cleanupSteps,
CleanupWarnings = cleanupWarnings
};
}

if (reservationStep?.IsSuccess == true)
start.Session.ReservationTouched = true;

if (triggerGeneralInterrogation)
{
var gi = await WriteReportAttributeAsync(
start.Session.ReportControl,
"GI",
MmsDataValue.Boolean(true),
cancellationToken).ConfigureAwait(false);
writes.Add(gi);
if (!gi.IsSuccess)
warnings.Add("GI=true write failed or is not supported by this RCB. Waiting for spontaneous/integrity reports only.");
}

var compatibilityMessage = reservationStep?.IsSuccess == true
? "BRCB explicitly reserved with ResvTms=60 before RptEna; GI was requested only after the persistent receiver was registered."
: "GI was requested only after the persistent receiver was registered.";

return new MmsPersistentReportMonitorAttemptResult
{
StartResult = CopyStartResult(
start,
writes,
warnings,
$"{start.Message} {compatibilityMessage}"),
DynamicAttemptState = attempt.DynamicAttemptState,
FailureReason = attempt.FailureReason,
CleanupAttempted = attempt.CleanupAttempted,
CleanupSucceeded = attempt.CleanupSucceeded,
CleanupSteps = attempt.CleanupSteps,
CleanupWarnings = attempt.CleanupWarnings
};
}

private static MmsPersistentReportMonitorStartResult CopyStartResult(
MmsPersistentReportMonitorStartResult source,
IReadOnlyList<MmsReportAttributeWriteStep> writes,
IReadOnlyList<string> warnings,
string? message = null)
=> new()
{
IsSuccess = source.IsSuccess,
Message = message ?? source.Message,
Session = source.Session,
WriteSteps = writes.ToArray(),
Warnings = warnings.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
RcbSnapshots = source.RcbSnapshots,
DataSetSnapshots = source.DataSetSnapshots
};
}
Loading