diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs
index bd60b925..8038c3f1 100644
--- a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs
+++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs
@@ -1,4 +1,5 @@
using AR.Iec61850.Discovery;
+using AR.Iec61850.Scl.Workspace;
namespace AR.Iec61850.Engineering.Canonical;
@@ -34,6 +35,24 @@ public static CanonicalIedModel FromLiveDiscovery(LiveIedModelDiscoveryDocument
});
}
+ ///
+ /// Public source-correct canonical ingress for an already opened SCL IED workspace.
+ /// Applications should use this instead of re-labeling the workspace DesignModel as
+ /// live discovery merely to reuse canonical planning.
+ ///
+ public static CanonicalIedModel FromSclWorkspace(SclIedWorkspace workspace)
+ {
+ ArgumentNullException.ThrowIfNull(workspace);
+
+ return FromSclProjection(
+ workspace.DesignModel,
+ sourceName: "SCL workspace",
+ sourceEdition: string.Empty,
+ iedName: workspace.IedName,
+ accessPointName: workspace.AccessPointName,
+ domainAliases: EmptyAliases);
+ }
+
internal static CanonicalIedModel FromSclProjection(
LiveIedModelDiscoveryDocument projection,
string sourceName,
@@ -150,7 +169,7 @@ private static CanonicalIedModel Build(
dataSets[i] = new CanonicalDataSet
{
- Reference = RemapReference(dataSet.Reference, context.DomainAliases),
+ Reference = NormalizeConfiguredReference(dataSet.Reference, context.DomainAliases),
MmsDomain = RemapDomain(dataSet.Domain, context.DomainAliases),
LogicalNode = dataSet.LogicalNode,
Name = dataSet.Name,
@@ -167,12 +186,12 @@ private static CanonicalIedModel Build(
var report = source.ReportControls[i];
reportControls[i] = new CanonicalReportControl
{
- Reference = RemapReference(report.Reference, context.DomainAliases),
+ Reference = NormalizeConfiguredReference(report.Reference, context.DomainAliases),
MmsDomain = RemapDomain(report.Domain, context.DomainAliases),
LogicalNode = report.LogicalNode,
Name = report.Name,
Buffered = report.Buffered,
- DataSetReference = RemapReference(report.DataSetReference, context.DomainAliases),
+ DataSetReference = NormalizeConfiguredReference(report.DataSetReference, context.DomainAliases),
ReportId = report.ReportId,
ConfRev = report.ConfRev,
TriggerOptions = report.TriggerOptions,
@@ -232,6 +251,17 @@ private static CanonicalIedModel Build(
};
}
+ ///
+ /// Canonical DataSet/ReportControl identity uses one source-neutral dotted form.
+ /// SCL commonly carries '$' MMS component separators while live discovery may expose
+ /// the same configured resource with dots. Source projections retain their original
+ /// spelling; the shared CanonicalIedModel does not.
+ ///
+ private static string NormalizeConfiguredReference(
+ string value,
+ IReadOnlyDictionary aliases)
+ => RemapReference(value, aliases).Replace('$', '.');
+
private static string RemapDomain(string value, IReadOnlyDictionary aliases)
{
var normalized = value?.Trim() ?? string.Empty;
diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs
new file mode 100644
index 00000000..c1e4ecba
--- /dev/null
+++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs
@@ -0,0 +1,282 @@
+namespace AR.Iec61850.Engineering.Canonical;
+
+public enum CanonicalStaticReportSignalCoverageStatus
+{
+ Covered,
+ SignalNotFound,
+ AmbiguousSignalIdentity,
+ NoStaticDataSetMembership,
+ StaticDataSetWithoutReportControl
+}
+
+public sealed class CanonicalStaticReportSelection
+{
+ public string Reference { get; init; } = string.Empty;
+ public string FunctionalConstraint { get; init; } = string.Empty;
+}
+
+public sealed class CanonicalStaticReportDataSetCandidate
+{
+ public string DataSetReference { get; init; } = string.Empty;
+ public string SourceDataSetReference { get; init; } = string.Empty;
+ public int[] MemberIndexes { get; init; } = Array.Empty();
+ public string[] ReportControlReferences { get; init; } = Array.Empty();
+}
+
+public sealed class CanonicalStaticReportSignalCoverage
+{
+ public string RequestedReference { get; init; } = string.Empty;
+ public string RequestedFunctionalConstraint { get; init; } = string.Empty;
+ public string CanonicalReference { get; init; } = string.Empty;
+ public string CanonicalFunctionalConstraint { get; init; } = string.Empty;
+ public CanonicalStaticReportSignalCoverageStatus Status { get; init; }
+ public CanonicalStaticReportDataSetCandidate[] DataSetCandidates { get; init; } = Array.Empty();
+
+ public bool IsCovered => Status == CanonicalStaticReportSignalCoverageStatus.Covered;
+}
+
+public sealed class CanonicalStaticReportCoverageSegment
+{
+ public string DataSetReference { get; init; } = string.Empty;
+ public string SourceDataSetReference { get; init; } = string.Empty;
+ public CanonicalDataSetMember[] OrderedMembers { get; init; } = Array.Empty();
+ public CanonicalReportControl[] ReportControls { get; init; } = Array.Empty();
+ public string[] SelectedSignalReferences { get; init; } = Array.Empty();
+}
+
+public sealed class CanonicalStaticReportCoveragePlan
+{
+ public CanonicalIngressKind Ingress { get; init; }
+ public CanonicalStaticReportSignalCoverage[] Signals { get; init; } = Array.Empty();
+ public CanonicalStaticReportCoverageSegment[] Segments { get; init; } = Array.Empty();
+
+ public int RequestedSignalCount => Signals.Length;
+ public int CoveredSignalCount => Signals.Count(signal => signal.IsCovered);
+ public int UncoveredSignalCount => RequestedSignalCount - CoveredSignalCount;
+}
+
+///
+/// Source-neutral static-report coverage over the shared CanonicalIedModel.
+///
+/// This resolver never chooses an operational RCB instance and performs no IED writes.
+/// It answers one question only: which configured static DataSet/ReportControl resources
+/// already cover each exact canonical signal? Discovery and Open-SCL therefore enter the
+/// same coverage logic after canonicalization.
+///
+/// Structured DataSet members are handled by exact reference-boundary containment. A
+/// member such as A.phsA covers its typed descendants but never sibling phases. Full
+/// ordered DataSet membership is retained in every segment because InformationReport
+/// projection remains positional even when the application selected only a subset.
+///
+public static class CanonicalStaticReportCoverageResolver
+{
+ public static CanonicalStaticReportCoveragePlan Resolve(
+ CanonicalIedModel model,
+ IEnumerable selections)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+ ArgumentNullException.ThrowIfNull(selections);
+
+ var requested = selections.ToArray();
+ var signalIndex = BuildSignalIndex(model);
+ var reportByDataSet = model.ReportControls
+ .Where(report => !string.IsNullOrWhiteSpace(report.DataSetReference))
+ .GroupBy(report => NormalizeConfiguredReference(report.DataSetReference), StringComparer.Ordinal)
+ .ToDictionary(
+ group => group.Key,
+ group => group
+ .OrderBy(report => NormalizeConfiguredReference(report.Reference), StringComparer.Ordinal)
+ .ToArray(),
+ StringComparer.Ordinal);
+
+ var results = new CanonicalStaticReportSignalCoverage[requested.Length];
+ for (var index = 0; index < requested.Length; index++)
+ results[index] = ResolveSignal(model, requested[index], signalIndex, reportByDataSet);
+
+ var segmentDataSets = results
+ .Where(result => result.IsCovered)
+ .SelectMany(result => result.DataSetCandidates)
+ .Select(candidate => candidate.DataSetReference)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ var segments = segmentDataSets
+ .Select(dataSetReference => BuildSegment(
+ model,
+ dataSetReference,
+ results,
+ reportByDataSet))
+ .Where(segment => segment is not null)
+ .Cast()
+ .ToArray();
+
+ return new CanonicalStaticReportCoveragePlan
+ {
+ Ingress = model.Source.Ingress,
+ Signals = results,
+ Segments = segments
+ };
+ }
+
+ private static CanonicalStaticReportSignalCoverage ResolveSignal(
+ CanonicalIedModel model,
+ CanonicalStaticReportSelection selection,
+ IReadOnlyDictionary signalIndex,
+ IReadOnlyDictionary reportByDataSet)
+ {
+ var requestedReference = selection.Reference?.Trim() ?? string.Empty;
+ var requestedFc = NormalizeFc(selection.FunctionalConstraint);
+
+ if (!signalIndex.TryGetValue(requestedReference, out var referenceMatches))
+ {
+ return new CanonicalStaticReportSignalCoverage
+ {
+ RequestedReference = requestedReference,
+ RequestedFunctionalConstraint = requestedFc,
+ Status = CanonicalStaticReportSignalCoverageStatus.SignalNotFound
+ };
+ }
+
+ var matches = string.IsNullOrWhiteSpace(requestedFc)
+ ? referenceMatches
+ : referenceMatches
+ .Where(signal => string.Equals(
+ NormalizeFc(model.Strings.Resolve(signal.FunctionalConstraint)),
+ requestedFc,
+ StringComparison.Ordinal))
+ .ToArray();
+
+ if (matches.Length != 1)
+ {
+ return new CanonicalStaticReportSignalCoverage
+ {
+ RequestedReference = requestedReference,
+ RequestedFunctionalConstraint = requestedFc,
+ Status = matches.Length == 0
+ ? CanonicalStaticReportSignalCoverageStatus.SignalNotFound
+ : CanonicalStaticReportSignalCoverageStatus.AmbiguousSignalIdentity
+ };
+ }
+
+ var signal = matches[0];
+ var canonicalReference = model.Strings.Resolve(signal.ObjectReference);
+ var canonicalFc = NormalizeFc(model.Strings.Resolve(signal.FunctionalConstraint));
+
+ var candidates = new List();
+ var anyDataSet = false;
+ foreach (var dataSet in model.DataSets)
+ {
+ var memberIndexes = dataSet.Members
+ .Where(member => MemberCoversSignal(member, canonicalReference, canonicalFc))
+ .Select(member => member.Index)
+ .OrderBy(memberIndex => memberIndex)
+ .ToArray();
+ if (memberIndexes.Length == 0)
+ continue;
+
+ anyDataSet = true;
+ var canonicalDataSetReference = NormalizeConfiguredReference(dataSet.Reference);
+ reportByDataSet.TryGetValue(canonicalDataSetReference, out var reports);
+ candidates.Add(new CanonicalStaticReportDataSetCandidate
+ {
+ DataSetReference = canonicalDataSetReference,
+ SourceDataSetReference = dataSet.Reference,
+ MemberIndexes = memberIndexes,
+ ReportControlReferences = reports?
+ .Select(report => NormalizeConfiguredReference(report.Reference))
+ .ToArray() ?? Array.Empty()
+ });
+ }
+
+ var reportBacked = candidates
+ .Where(candidate => candidate.ReportControlReferences.Length > 0)
+ .OrderBy(candidate => candidate.DataSetReference, StringComparer.Ordinal)
+ .ToArray();
+
+ return new CanonicalStaticReportSignalCoverage
+ {
+ RequestedReference = requestedReference,
+ RequestedFunctionalConstraint = requestedFc,
+ CanonicalReference = canonicalReference,
+ CanonicalFunctionalConstraint = canonicalFc,
+ Status = reportBacked.Length > 0
+ ? CanonicalStaticReportSignalCoverageStatus.Covered
+ : anyDataSet
+ ? CanonicalStaticReportSignalCoverageStatus.StaticDataSetWithoutReportControl
+ : CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership,
+ DataSetCandidates = reportBacked.Length > 0
+ ? reportBacked
+ : candidates.OrderBy(candidate => candidate.DataSetReference, StringComparer.Ordinal).ToArray()
+ };
+ }
+
+ private static CanonicalStaticReportCoverageSegment? BuildSegment(
+ CanonicalIedModel model,
+ string dataSetReference,
+ IReadOnlyList results,
+ IReadOnlyDictionary reportByDataSet)
+ {
+ var dataSet = model.DataSets.SingleOrDefault(candidate =>
+ string.Equals(
+ NormalizeConfiguredReference(candidate.Reference),
+ dataSetReference,
+ StringComparison.Ordinal));
+ if (dataSet is null)
+ return null;
+
+ reportByDataSet.TryGetValue(dataSetReference, out var reports);
+ reports ??= Array.Empty();
+
+ var selected = results
+ .Where(result => result.IsCovered &&
+ result.DataSetCandidates.Any(candidate =>
+ string.Equals(candidate.DataSetReference, dataSetReference, StringComparison.Ordinal)))
+ .Select(result => result.CanonicalReference)
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(reference => reference, StringComparer.Ordinal)
+ .ToArray();
+
+ return new CanonicalStaticReportCoverageSegment
+ {
+ DataSetReference = dataSetReference,
+ SourceDataSetReference = dataSet.Reference,
+ OrderedMembers = dataSet.Members.OrderBy(member => member.Index).ToArray(),
+ ReportControls = reports,
+ SelectedSignalReferences = selected
+ };
+ }
+
+ private static IReadOnlyDictionary BuildSignalIndex(CanonicalIedModel model)
+ => model.Signals
+ .GroupBy(signal => model.Strings.Resolve(signal.ObjectReference), StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal);
+
+ private static bool MemberCoversSignal(
+ CanonicalDataSetMember member,
+ string signalReference,
+ string signalFc)
+ {
+ var memberReference = member.Reference?.Trim() ?? string.Empty;
+ if (memberReference.Length == 0 || signalReference.Length == 0)
+ return false;
+
+ var memberFc = NormalizeFc(member.FunctionalConstraint);
+ if (memberFc.Length > 0 &&
+ !string.Equals(memberFc, signalFc, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return string.Equals(signalReference, memberReference, StringComparison.Ordinal) ||
+ signalReference.StartsWith(memberReference + ".", StringComparison.Ordinal);
+ }
+
+ private static string NormalizeConfiguredReference(string? value)
+ {
+ var text = (value ?? string.Empty).Trim();
+ return text.Length == 0 ? string.Empty : text.Replace('$', '.');
+ }
+
+ private static string NormalizeFc(string? value)
+ => (value ?? string.Empty).Trim().ToUpperInvariant();
+}
diff --git a/src/AR.Iec61850/Mms/MmsCanonicalReportInventoryProjection.cs b/src/AR.Iec61850/Mms/MmsCanonicalReportInventoryProjection.cs
new file mode 100644
index 00000000..d3d31984
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsCanonicalReportInventoryProjection.cs
@@ -0,0 +1,89 @@
+using AR.Iec61850.Engineering.Canonical;
+
+namespace AR.Iec61850.Mms;
+
+///
+/// Projects immutable canonical engineering structure into the legacy report-inventory shape
+/// without promoting it to live runtime evidence.
+///
+/// This exists so applications do not need to duplicate IEC 61850 DataSet/RCB mapping logic.
+/// It intentionally does not fabricate indexed RCB instances, writable attribute exposure,
+/// reservation state, ownership, or live enable state.
+///
+public static class MmsCanonicalReportInventoryProjection
+{
+ public static MmsReportInventory Build(CanonicalIedModel model)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+
+ var inventory = new MmsReportInventory
+ {
+ Authority = model.Source.Ingress == CanonicalIngressKind.SclFile
+ ? MmsReportInventoryAuthority.SclDesignProjection
+ : MmsReportInventoryAuthority.CanonicalModelProjection
+ };
+
+ foreach (var dataSet in model.DataSets)
+ {
+ var (domain, tail) = SplitReference(dataSet.Reference);
+ inventory.DataSets.Add(new MmsDataSetCandidate
+ {
+ Domain = FirstNonEmpty(dataSet.MmsDomain, domain),
+ LogicalNode = FirstNonEmpty(dataSet.LogicalNode, LogicalNodeFromTail(tail)),
+ Name = FirstNonEmpty(dataSet.Name, LeafFromTail(tail)),
+ Reference = dataSet.Reference,
+ RawMmsName = string.Empty
+ });
+ }
+
+ foreach (var report in model.ReportControls)
+ {
+ var (domain, tail) = SplitReference(report.Reference);
+ inventory.ReportControls.Add(new MmsReportControlCandidate
+ {
+ Domain = FirstNonEmpty(report.MmsDomain, domain),
+ LogicalNode = FirstNonEmpty(report.LogicalNode, LogicalNodeFromTail(tail)),
+ FunctionalConstraint = report.Buffered ? "BR" : "RP",
+ Name = FirstNonEmpty(report.Name, LeafFromTail(tail)),
+ Reference = report.Reference,
+ Buffered = report.Buffered,
+ DataSetReference = report.DataSetReference,
+ ReportId = report.ReportId,
+ ConfRev = report.ConfRev,
+ IntegrityPeriodMs = report.IntegrityPeriodMs,
+ BufferTimeMs = report.BufferTimeMs,
+ TriggerOptions = report.TriggerOptions,
+ OptionalFields = report.OptionalFields,
+ Status = "CanonicalModelProjection"
+ });
+ }
+
+ return inventory;
+ }
+
+ private static (string Domain, string Tail) SplitReference(string? reference)
+ {
+ var normalized = (reference ?? string.Empty).Trim();
+ var slash = normalized.IndexOf('/');
+ return slash > 0 && slash + 1 < normalized.Length
+ ? (normalized[..slash], normalized[(slash + 1)..])
+ : (string.Empty, normalized);
+ }
+
+ private static string LogicalNodeFromTail(string tail)
+ {
+ var dot = tail.IndexOf('.');
+ return dot > 0 ? tail[..dot] : string.Empty;
+ }
+
+ private static string LeafFromTail(string tail)
+ {
+ var dot = tail.LastIndexOf('.');
+ return dot >= 0 && dot + 1 < tail.Length ? tail[(dot + 1)..] : tail;
+ }
+
+ private static string FirstNonEmpty(string? primary, string? fallback)
+ => !string.IsNullOrWhiteSpace(primary)
+ ? primary.Trim()
+ : (fallback ?? string.Empty).Trim();
+}
diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs
new file mode 100644
index 00000000..f1d66004
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs
@@ -0,0 +1,297 @@
+using AR.Iec61850.Engineering.Canonical;
+
+namespace AR.Iec61850.Mms;
+
+public enum MmsCanonicalStaticAcquisitionProbeStatus
+{
+ NoSelection,
+ NoConfiguredStaticCoverage,
+ LiveInventoryRequired,
+ ExactTargetsUnresolved,
+ ExactTargetsReady,
+ PartialExactTargetsReady,
+ TargetBudgetExceeded,
+ AvailabilityChecked,
+ PartialAvailabilityChecked
+}
+
+public sealed class MmsCanonicalStaticAcquisitionProbePlan
+{
+ public MmsReportInventoryAuthority InventoryAuthority { get; init; }
+ public CanonicalStaticReportCoveragePlan Coverage { get; init; } = new();
+ public MmsCanonicalStaticRcbTargetResolution TargetResolution { get; init; } = new();
+ public MmsCanonicalStaticAcquisitionProbeStatus Status { get; init; }
+ public IReadOnlySet ExactTargetReportControlReferences { get; init; }
+ = new HashSet(StringComparer.OrdinalIgnoreCase);
+ public IReadOnlyList Warnings { get; init; } = Array.Empty();
+
+ public int RequestedSignalCount => Coverage.RequestedSignalCount;
+ public int StaticCoveredSignalCount => Coverage.CoveredSignalCount;
+ public int CoveredSegmentCount => Coverage.Segments.Length;
+ public int ResolvedSegmentCount => TargetResolution.Segments.Count(segment =>
+ segment.ExactLiveReportControlReferences.Count > 0);
+ public int UnresolvedSegmentCount => Math.Max(0, CoveredSegmentCount - ResolvedSegmentCount);
+ public bool HasExactOperationalTargets =>
+ InventoryAuthority == MmsReportInventoryAuthority.LiveMmsObserved &&
+ ExactTargetReportControlReferences.Count > 0;
+
+ public string Summary =>
+ $"canonical static preflight: status={Status}, ingress={Coverage.Ingress}, requested={RequestedSignalCount}, " +
+ $"staticCovered={StaticCoveredSignalCount}, segments={CoveredSegmentCount}, resolvedSegments={ResolvedSegmentCount}, " +
+ $"unresolvedSegments={UnresolvedSegmentCount}, exactTargets={ExactTargetReportControlReferences.Count}, inventory={InventoryAuthority}.";
+}
+
+public sealed class MmsCanonicalStaticAcquisitionProbeOptions
+{
+ public int MaxExactTargets { get; init; } = 64;
+ public bool ReadDataSetDirectories { get; init; } = true;
+ public IReadOnlySet CallerOwnedRcbReferences { get; init; }
+ = new HashSet(StringComparer.OrdinalIgnoreCase);
+}
+
+public sealed class MmsCanonicalStaticAcquisitionProbeResult
+{
+ public MmsCanonicalStaticAcquisitionProbePlan Plan { get; init; } = new();
+ public MmsCanonicalStaticAcquisitionProbeStatus Status { get; init; }
+ public bool NetworkProbePerformed { get; init; }
+ public MmsRcbAvailabilityResult? Availability { get; init; }
+ public IReadOnlyList Warnings { get; init; } = Array.Empty();
+
+ public string Summary =>
+ $"{Plan.Summary} networkProbe={NetworkProbePerformed.ToString().ToLowerInvariant()}, " +
+ $"availability={(Availability is null ? "not-run" : Availability.Summary)}";
+}
+
+///
+/// Pure source-aware coordinator between canonical static coverage and live RCB targeting.
+///
+/// It deliberately refuses to turn an SCL-projected inventory into operational live evidence.
+/// A caller must provide a live MMS-observed inventory before exact target resolution can
+/// authorize network probing. Unresolved segments stay unresolved; this planner never broadens
+/// them to siblings or starts a broad RCB scan.
+///
+public static class MmsCanonicalStaticAcquisitionPreflightPlanner
+{
+ public static MmsCanonicalStaticAcquisitionProbePlan Build(
+ CanonicalIedModel model,
+ IEnumerable selections,
+ MmsReportInventory inventory)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+ ArgumentNullException.ThrowIfNull(selections);
+ ArgumentNullException.ThrowIfNull(inventory);
+
+ var requested = selections.ToArray();
+ var coverage = CanonicalStaticReportCoverageResolver.Resolve(model, requested);
+ return Build(coverage, inventory);
+ }
+
+ public static MmsCanonicalStaticAcquisitionProbePlan Build(
+ CanonicalStaticReportCoveragePlan coverage,
+ MmsReportInventory inventory)
+ {
+ ArgumentNullException.ThrowIfNull(coverage);
+ ArgumentNullException.ThrowIfNull(inventory);
+
+ var warnings = new List();
+
+ if (coverage.RequestedSignalCount == 0)
+ {
+ return Result(
+ coverage,
+ inventory.Authority,
+ new MmsCanonicalStaticRcbTargetResolution(),
+ MmsCanonicalStaticAcquisitionProbeStatus.NoSelection,
+ warnings);
+ }
+
+ if (coverage.CoveredSignalCount == 0 || coverage.Segments.Length == 0)
+ {
+ warnings.Add("No selected signal has configured static DataSet/RCB coverage; no operational RCB probe is required.");
+ return Result(
+ coverage,
+ inventory.Authority,
+ new MmsCanonicalStaticRcbTargetResolution(),
+ MmsCanonicalStaticAcquisitionProbeStatus.NoConfiguredStaticCoverage,
+ warnings);
+ }
+
+ if (inventory.Authority != MmsReportInventoryAuthority.LiveMmsObserved)
+ {
+ warnings.Add(
+ "Configured static coverage exists, but operational RCB targeting requires a live MMS-observed report inventory. " +
+ "SCL/design inventory is structural evidence only and was not promoted to live target evidence.");
+ return Result(
+ coverage,
+ inventory.Authority,
+ new MmsCanonicalStaticRcbTargetResolution(),
+ MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired,
+ warnings);
+ }
+
+ var targetResolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory);
+ warnings.AddRange(targetResolution.Warnings);
+
+ if (!targetResolution.HasTargets)
+ {
+ warnings.Add(
+ "Configured static coverage exists but no exact live RCB target was proven. No broad diagnostic scan or sibling-name guess was started.");
+ return Result(
+ coverage,
+ inventory.Authority,
+ targetResolution,
+ MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsUnresolved,
+ warnings);
+ }
+
+ var unresolved = coverage.Segments.Length - targetResolution.Segments.Count(segment =>
+ segment.ExactLiveReportControlReferences.Count > 0);
+ var status = unresolved > 0
+ ? MmsCanonicalStaticAcquisitionProbeStatus.PartialExactTargetsReady
+ : MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady;
+
+ return Result(coverage, inventory.Authority, targetResolution, status, warnings);
+ }
+
+ private static MmsCanonicalStaticAcquisitionProbePlan Result(
+ CanonicalStaticReportCoveragePlan coverage,
+ MmsReportInventoryAuthority inventoryAuthority,
+ MmsCanonicalStaticRcbTargetResolution targetResolution,
+ MmsCanonicalStaticAcquisitionProbeStatus status,
+ IReadOnlyList warnings)
+ => new()
+ {
+ InventoryAuthority = inventoryAuthority,
+ Coverage = coverage,
+ TargetResolution = targetResolution,
+ Status = status,
+ ExactTargetReportControlReferences = targetResolution.ExactLiveReportControlReferences,
+ Warnings = warnings
+ .Where(message => !string.IsNullOrWhiteSpace(message))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray()
+ };
+}
+
+public sealed partial class MmsClientSession
+{
+ ///
+ /// Preferred one-shot runtime entry point. Planning and execution consume the same
+ /// inventory instance so callers cannot accidentally plan from one evidence source
+ /// and execute against another.
+ ///
+ public Task PrepareCanonicalStaticAcquisitionAsync(
+ CanonicalIedModel model,
+ IEnumerable selections,
+ MmsReportInventory inventory,
+ MmsIedModelDirectory? directory = null,
+ MmsCanonicalStaticAcquisitionProbeOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+ ArgumentNullException.ThrowIfNull(selections);
+ ArgumentNullException.ThrowIfNull(inventory);
+
+ var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build(
+ model,
+ selections,
+ inventory);
+ return ProbeCanonicalStaticAcquisitionAsync(
+ plan,
+ inventory,
+ directory,
+ options,
+ cancellationToken);
+ }
+
+ ///
+ /// Executes only the exact-target availability work authorized by the pure preflight
+ /// planner. There is intentionally no implicit broad fallback.
+ ///
+ public async Task ProbeCanonicalStaticAcquisitionAsync(
+ MmsCanonicalStaticAcquisitionProbePlan plan,
+ MmsReportInventory liveInventory,
+ MmsIedModelDirectory? directory = null,
+ MmsCanonicalStaticAcquisitionProbeOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+ ArgumentNullException.ThrowIfNull(liveInventory);
+ options ??= new MmsCanonicalStaticAcquisitionProbeOptions();
+
+ if (liveInventory.Authority != MmsReportInventoryAuthority.LiveMmsObserved)
+ {
+ return new MmsCanonicalStaticAcquisitionProbeResult
+ {
+ Plan = plan,
+ Status = MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired,
+ NetworkProbePerformed = false,
+ Warnings = plan.Warnings
+ .Concat(new[]
+ {
+ "The execution inventory is not marked LiveMmsObserved; the exact-target plan was not allowed to touch the network."
+ })
+ .Distinct(StringComparer.Ordinal)
+ .ToArray()
+ };
+ }
+
+ if (!plan.HasExactOperationalTargets)
+ {
+ return new MmsCanonicalStaticAcquisitionProbeResult
+ {
+ Plan = plan,
+ Status = plan.Status,
+ NetworkProbePerformed = false,
+ Warnings = plan.Warnings
+ };
+ }
+
+ var maxTargets = Math.Clamp(options.MaxExactTargets, 1, 512);
+ if (plan.ExactTargetReportControlReferences.Count > maxTargets)
+ {
+ return new MmsCanonicalStaticAcquisitionProbeResult
+ {
+ Plan = plan,
+ Status = MmsCanonicalStaticAcquisitionProbeStatus.TargetBudgetExceeded,
+ NetworkProbePerformed = false,
+ Warnings = plan.Warnings
+ .Concat(new[]
+ {
+ $"Exact static RCB target count {plan.ExactTargetReportControlReferences.Count} exceeds the configured hot-path budget {maxTargets}; no partial probe was started."
+ })
+ .ToArray()
+ };
+ }
+
+ EnsureMmsReady();
+
+ var availability = await CheckReportControlAvailabilityAsync(
+ liveInventory,
+ directory,
+ new MmsRcbAvailabilityOptions
+ {
+ MaxReportControls = maxTargets,
+ ReadDataSetDirectories = options.ReadDataSetDirectories,
+ TargetReportControlReferences = plan.ExactTargetReportControlReferences,
+ CallerOwnedRcbReferences = options.CallerOwnedRcbReferences
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ var status = plan.UnresolvedSegmentCount > 0
+ ? MmsCanonicalStaticAcquisitionProbeStatus.PartialAvailabilityChecked
+ : MmsCanonicalStaticAcquisitionProbeStatus.AvailabilityChecked;
+
+ return new MmsCanonicalStaticAcquisitionProbeResult
+ {
+ Plan = plan,
+ Status = status,
+ NetworkProbePerformed = true,
+ Availability = availability,
+ Warnings = plan.Warnings
+ .Concat(availability.Warnings)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray()
+ };
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs
new file mode 100644
index 00000000..d2f3b82e
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs
@@ -0,0 +1,402 @@
+using AR.Iec61850.Engineering.Canonical;
+
+namespace AR.Iec61850.Mms;
+
+public enum MmsCanonicalStaticLiveReconciliationStatus
+{
+ NoStaticCoverage,
+ Ready,
+ DomainBudgetExceeded,
+ CandidateBudgetExceeded,
+ Partial,
+ AssociationLost
+}
+
+public sealed class MmsCanonicalStaticLiveDomainTarget
+{
+ public string Domain { get; init; } = string.Empty;
+ public bool NeedsBuffered { get; init; }
+ public bool NeedsUnbuffered { get; init; }
+}
+
+public sealed class MmsCanonicalStaticLiveReconciliationPlan
+{
+ public MmsCanonicalStaticLiveReconciliationStatus Status { get; init; }
+ public IReadOnlyList Domains { get; init; }
+ = Array.Empty();
+ public IReadOnlyList Warnings { get; init; } = Array.Empty();
+
+ public bool RequiresNetwork => Status == MmsCanonicalStaticLiveReconciliationStatus.Ready &&
+ Domains.Count > 0;
+}
+
+public sealed class MmsCanonicalStaticLiveReconciliationOptions
+{
+ ///
+ /// Hard guard. Targeted reconciliation never silently truncates the configured domain set.
+ ///
+ public int MaxDomains { get; init; } = 16;
+
+ ///
+ /// Per-domain GetNameList bound. This is a safety envelope, not a target count.
+ ///
+ public int MaxVariableNamesPerDomain { get; init; } = 20000;
+
+ public int MaxNameListPages { get; init; } = 64;
+
+ ///
+ /// Bounded independent domain chains, additionally capped by negotiated MMS outstandingCalling.
+ ///
+ public int MaxConcurrentDomains { get; init; } = 4;
+ public int UnknownPeerMaxConcurrentDomains { get; init; } = 2;
+
+ ///
+ /// Maximum exact live RCB candidates that may be read for DatSet binding evidence.
+ /// The operation fails closed rather than reading an arbitrary subset.
+ ///
+ public int MaxReportControlCandidates { get; init; } = 64;
+}
+
+public sealed class MmsCanonicalStaticLiveReconciliationResult
+{
+ public MmsCanonicalStaticLiveReconciliationPlan Plan { get; init; } = new();
+ public MmsCanonicalStaticLiveReconciliationStatus Status { get; init; }
+ public MmsReportInventory Inventory { get; init; } = new();
+ public int EnumeratedVariableCount { get; init; }
+ public int CandidateReportControlCount { get; init; }
+ public int DataSetBoundReportControlCount { get; init; }
+ public IReadOnlyList Warnings { get; init; } = Array.Empty();
+
+ public bool HasLiveInventory =>
+ Inventory.Authority == MmsReportInventoryAuthority.LiveMmsObserved;
+
+ public string Summary =>
+ $"targeted live RCB reconciliation: status={Status}, domains={Plan.Domains.Count}, " +
+ $"rawVariables={EnumeratedVariableCount}, candidates={CandidateReportControlCount}, " +
+ $"datasetBound={DataSetBoundReportControlCount}, authority={Inventory.Authority}.";
+}
+
+public sealed class MmsCanonicalStaticSmartPreparationResult
+{
+ public CanonicalStaticReportCoveragePlan Coverage { get; init; } = new();
+ public MmsCanonicalStaticLiveReconciliationResult Reconciliation { get; init; } = new();
+ public MmsCanonicalStaticAcquisitionProbeResult Acquisition { get; init; } = new();
+
+ public string Summary =>
+ $"{Reconciliation.Summary} {Acquisition.Summary}";
+}
+
+///
+/// Pure planner for the minimum online structure needed to reconcile configured static
+/// reporting. It extracts only MMS domains and BRCB/URCB families actually referenced by
+/// selected static coverage. No runtime RCB instance is guessed here.
+///
+public static class MmsCanonicalStaticLiveReconciliationPlanner
+{
+ public static MmsCanonicalStaticLiveReconciliationPlan Build(
+ CanonicalStaticReportCoveragePlan coverage,
+ MmsCanonicalStaticLiveReconciliationOptions? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(coverage);
+ options ??= new MmsCanonicalStaticLiveReconciliationOptions();
+
+ if (coverage.Segments.Length == 0 || coverage.CoveredSignalCount == 0)
+ {
+ return new MmsCanonicalStaticLiveReconciliationPlan
+ {
+ Status = MmsCanonicalStaticLiveReconciliationStatus.NoStaticCoverage
+ };
+ }
+
+ var map = new Dictionary(StringComparer.Ordinal);
+ var warnings = new List();
+
+ foreach (var report in coverage.Segments.SelectMany(segment => segment.ReportControls))
+ {
+ var domain = ResolveDomain(report.MmsDomain, report.Reference, report.DataSetReference);
+ if (domain.Length == 0)
+ {
+ warnings.Add($"Configured RCB '{report.Reference}' has no resolvable MMS domain and cannot be reconciled online.");
+ continue;
+ }
+
+ map.TryGetValue(domain, out var current);
+ map[domain] = report.Buffered
+ ? (true, current.Unbuffered)
+ : (current.Buffered, true);
+ }
+
+ var domains = map
+ .OrderBy(pair => pair.Key, StringComparer.Ordinal)
+ .Select(pair => new MmsCanonicalStaticLiveDomainTarget
+ {
+ Domain = pair.Key,
+ NeedsBuffered = pair.Value.Buffered,
+ NeedsUnbuffered = pair.Value.Unbuffered
+ })
+ .ToArray();
+
+ var maxDomains = Math.Clamp(options.MaxDomains, 1, 256);
+ if (domains.Length > maxDomains)
+ {
+ warnings.Add(
+ $"Configured static coverage requires {domains.Length} MMS domains, exceeding targeted reconciliation budget {maxDomains}; no partial domain subset is authorized.");
+ return new MmsCanonicalStaticLiveReconciliationPlan
+ {
+ Status = MmsCanonicalStaticLiveReconciliationStatus.DomainBudgetExceeded,
+ Domains = domains,
+ Warnings = warnings
+ };
+ }
+
+ return new MmsCanonicalStaticLiveReconciliationPlan
+ {
+ Status = domains.Length == 0
+ ? MmsCanonicalStaticLiveReconciliationStatus.Partial
+ : MmsCanonicalStaticLiveReconciliationStatus.Ready,
+ Domains = domains,
+ Warnings = warnings
+ };
+ }
+
+ private static string ResolveDomain(
+ string mmsDomain,
+ string reportReference,
+ string dataSetReference)
+ {
+ if (!string.IsNullOrWhiteSpace(mmsDomain))
+ return mmsDomain.Trim();
+
+ foreach (var reference in new[] { reportReference, dataSetReference })
+ {
+ var normalized = reference?.Trim() ?? string.Empty;
+ var slash = normalized.IndexOf('/');
+ if (slash > 0)
+ return normalized[..slash];
+ }
+
+ return string.Empty;
+ }
+}
+
+public sealed partial class MmsClientSession
+{
+ ///
+ /// Performs bounded online reconciliation for configured static reporting without
+ /// running full IED discovery.
+ ///
+ /// Work is proportional to the exact MMS domains involved in selected static coverage:
+ /// one NamedVariable GetNameList chain per domain, followed by whole-RCB/DatSet reads
+ /// only for live RCB candidates of the required BRCB/URCB class. DataSet directories,
+ /// FC points, unrelated domains and unrelated RCB classes are not discovered here.
+ ///
+ public async Task ReconcileCanonicalStaticReportInventoryAsync(
+ CanonicalStaticReportCoveragePlan coverage,
+ MmsCanonicalStaticLiveReconciliationOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(coverage);
+ options ??= new MmsCanonicalStaticLiveReconciliationOptions();
+
+ var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build(coverage, options);
+ var emptyInventory = new MmsReportInventory
+ {
+ Authority = MmsReportInventoryAuthority.LiveMmsObserved
+ };
+
+ if (!plan.RequiresNetwork)
+ {
+ return new MmsCanonicalStaticLiveReconciliationResult
+ {
+ Plan = plan,
+ Status = plan.Status,
+ Inventory = emptyInventory,
+ Warnings = plan.Warnings
+ };
+ }
+
+ EnsureMmsReady();
+
+ var maxNames = Math.Clamp(options.MaxVariableNamesPerDomain, 1, 100000);
+ var maxPages = Math.Clamp(options.MaxNameListPages, 1, 256);
+ var smartOptions = new MmsSmartDiscoveryOptions
+ {
+ MaxConcurrentChains = Math.Clamp(options.MaxConcurrentDomains, 1, 64),
+ UnknownPeerMaxConcurrentChains = Math.Clamp(options.UnknownPeerMaxConcurrentDomains, 1, 32)
+ };
+ var window = ResolveSmartDiscoveryWindow(smartOptions);
+
+ var work = plan.Domains
+ .Select(target => new SmartNameChainWorkItem(
+ target.Domain,
+ MmsGetNameListObjectClass.NamedVariable,
+ maxNames))
+ .ToArray();
+
+ var chains = await RunSmartNameChainsAsync(
+ work,
+ window,
+ maxPages,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!IsMmsInitiated)
+ {
+ return new MmsCanonicalStaticLiveReconciliationResult
+ {
+ Plan = plan,
+ Status = MmsCanonicalStaticLiveReconciliationStatus.AssociationLost,
+ Inventory = emptyInventory,
+ EnumeratedVariableCount = chains.Sum(chain => chain.Names.Count),
+ Warnings = plan.Warnings
+ .Concat(new[] { "MMS association became unavailable during targeted RCB reconciliation." })
+ .ToArray()
+ };
+ }
+
+ var variables = chains.ToDictionary(
+ chain => chain.Domain,
+ chain => (IReadOnlyList)chain.Names,
+ StringComparer.Ordinal);
+
+ var observed = MmsReportDiscoveryMapper.BuildInventory(new MmsDiscoverySnapshot
+ {
+ DomainVariables = variables,
+ DomainVariableLists = new Dictionary>(StringComparer.Ordinal)
+ });
+
+ var targetsByDomain = plan.Domains.ToDictionary(
+ target => target.Domain,
+ StringComparer.Ordinal);
+
+ var candidates = observed.ReportControls
+ .Where(candidate =>
+ targetsByDomain.TryGetValue(candidate.Domain, out var target) &&
+ ((candidate.Buffered && target.NeedsBuffered) ||
+ (!candidate.Buffered && target.NeedsUnbuffered)))
+ .OrderBy(candidate => candidate.Domain, StringComparer.Ordinal)
+ .ThenByDescending(candidate => candidate.Buffered)
+ .ThenBy(candidate => candidate.Reference, StringComparer.Ordinal)
+ .ToArray();
+
+ var maxCandidates = Math.Clamp(options.MaxReportControlCandidates, 1, 512);
+ if (candidates.Length > maxCandidates)
+ {
+ return new MmsCanonicalStaticLiveReconciliationResult
+ {
+ Plan = plan,
+ Status = MmsCanonicalStaticLiveReconciliationStatus.CandidateBudgetExceeded,
+ Inventory = emptyInventory,
+ EnumeratedVariableCount = variables.Values.Sum(list => list.Count),
+ CandidateReportControlCount = candidates.Length,
+ Warnings = plan.Warnings
+ .Concat(new[]
+ {
+ $"Target domains expose {candidates.Length} required-family RCB candidates, exceeding live reconciliation budget {maxCandidates}; no partial RCB read subset was started."
+ })
+ .ToArray()
+ };
+ }
+
+ var inventory = new MmsReportInventory
+ {
+ Authority = MmsReportInventoryAuthority.LiveMmsObserved
+ };
+ inventory.ReportControls.AddRange(candidates);
+
+ foreach (var candidate in candidates)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await TryReadReportControlStructureSmartAsync(
+ candidate,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(candidate.DataSetReference))
+ {
+ await TryReadReportAttributeSmartAsync(
+ candidate,
+ "DatSet",
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ if (!IsMmsInitiated)
+ break;
+ }
+
+ var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete);
+ var warnings = plan.Warnings
+ .Concat(chains
+ .Where(chain => !chain.IsComplete)
+ .Select(chain => $"Target domain '{chain.Domain}' NamedVariable enumeration is partial: {chain.Message}"))
+ .Concat(candidates
+ .Where(candidate => string.IsNullOrWhiteSpace(candidate.DataSetReference))
+ .Select(candidate => $"Live RCB '{candidate.Reference}' did not expose a usable DatSet binding."))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ return new MmsCanonicalStaticLiveReconciliationResult
+ {
+ Plan = plan,
+ Status = !IsMmsInitiated
+ ? MmsCanonicalStaticLiveReconciliationStatus.AssociationLost
+ : complete
+ ? MmsCanonicalStaticLiveReconciliationStatus.Ready
+ : MmsCanonicalStaticLiveReconciliationStatus.Partial,
+ Inventory = inventory,
+ EnumeratedVariableCount = variables.Values.Sum(list => list.Count),
+ CandidateReportControlCount = candidates.Length,
+ DataSetBoundReportControlCount = candidates.Count(candidate =>
+ !string.IsNullOrWhiteSpace(candidate.DataSetReference)),
+ Warnings = warnings
+ };
+ }
+
+ ///
+ /// Preferred Open-SCL/Discovery-neutral static acquisition entry point.
+ /// Canonical coverage is resolved locally first. Only when configured static coverage
+ /// exists does the engine perform targeted live RCB reconciliation, then exact-target
+ /// availability. Full discovery is never implied by this method.
+ ///
+ public async Task PrepareCanonicalStaticAcquisitionSmartAsync(
+ CanonicalIedModel model,
+ IEnumerable selections,
+ MmsIedModelDirectory? directory = null,
+ MmsCanonicalStaticLiveReconciliationOptions? reconciliationOptions = null,
+ MmsCanonicalStaticAcquisitionProbeOptions? acquisitionOptions = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+ ArgumentNullException.ThrowIfNull(selections);
+
+ var requested = selections.ToArray();
+ var coverage = CanonicalStaticReportCoverageResolver.Resolve(model, requested);
+
+ var reconciliation = await ReconcileCanonicalStaticReportInventoryAsync(
+ coverage,
+ reconciliationOptions,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build(
+ coverage,
+ reconciliation.Inventory);
+
+ var acquisition = await ProbeCanonicalStaticAcquisitionAsync(
+ plan,
+ reconciliation.Inventory,
+ directory,
+ acquisitionOptions,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ return new MmsCanonicalStaticSmartPreparationResult
+ {
+ Coverage = coverage,
+ Reconciliation = reconciliation,
+ Acquisition = acquisition
+ };
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs
new file mode 100644
index 00000000..da44d3b8
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs
@@ -0,0 +1,129 @@
+using AR.Iec61850.Engineering.Canonical;
+
+namespace AR.Iec61850.Mms;
+
+public sealed class MmsCanonicalStaticRcbTargetSegment
+{
+ public string DataSetReference { get; init; } = string.Empty;
+ public IReadOnlyList ConfiguredReportControlReferences { get; init; } = Array.Empty();
+ public IReadOnlyList ExactLiveReportControlReferences { get; init; } = Array.Empty();
+ public string Resolution { get; init; } = string.Empty;
+}
+
+public sealed class MmsCanonicalStaticRcbTargetResolution
+{
+ public IReadOnlySet ExactLiveReportControlReferences { get; init; }
+ = new HashSet(StringComparer.OrdinalIgnoreCase);
+ public IReadOnlyList Segments { get; init; }
+ = Array.Empty();
+ public IReadOnlyList Warnings { get; init; } = Array.Empty();
+
+ public bool HasTargets => ExactLiveReportControlReferences.Count > 0;
+ public int CoveredDataSetCount => Segments.Count;
+}
+
+///
+/// Resolves canonical configured-static coverage to concrete live RCB references without
+/// inventing indexed names.
+///
+/// Exact live identity is accepted from either:
+/// 1) an exact configured RCB reference match, or
+/// 2) a live RCB whose live DatSet binding exactly matches the canonical covered DataSet
+/// and whose BRCB/URCB family is declared for that segment.
+///
+/// If neither evidence path is available the segment remains unresolved. The caller may
+/// explicitly choose a broader diagnostic probe, but this resolver never guesses a sibling
+/// RCB merely because its name looks similar.
+///
+public static class MmsCanonicalStaticRcbTargetResolver
+{
+ public static MmsCanonicalStaticRcbTargetResolution Resolve(
+ CanonicalStaticReportCoveragePlan coverage,
+ MmsReportInventory liveInventory)
+ {
+ ArgumentNullException.ThrowIfNull(coverage);
+ ArgumentNullException.ThrowIfNull(liveInventory);
+
+ var allTargets = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var segments = new List();
+ var warnings = new List();
+
+ foreach (var segment in coverage.Segments)
+ {
+ if (segment.SelectedSignalReferences.Length == 0)
+ continue;
+
+ var canonicalDataSet = Normalize(segment.DataSetReference);
+ var configuredReferences = segment.ReportControls
+ .Select(report => Normalize(report.Reference))
+ .Where(reference => reference.Length > 0)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var configuredBufferedFamilies = segment.ReportControls
+ .Select(report => report.Buffered)
+ .Distinct()
+ .ToHashSet();
+
+ var exactReferenceMatches = liveInventory.ReportControls
+ .Where(candidate => configuredReferences.Contains(Normalize(candidate.Reference)))
+ .ToArray();
+
+ var exactDataSetMatches = canonicalDataSet.Length == 0
+ ? Array.Empty()
+ : liveInventory.ReportControls
+ .Where(candidate =>
+ string.Equals(
+ Normalize(candidate.DataSetReference),
+ canonicalDataSet,
+ StringComparison.OrdinalIgnoreCase))
+ .Where(candidate =>
+ configuredBufferedFamilies.Count == 0 ||
+ configuredBufferedFamilies.Contains(candidate.Buffered))
+ .ToArray();
+
+ var matched = exactReferenceMatches
+ .Concat(exactDataSetMatches)
+ .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Reference))
+ .DistinctBy(candidate => Normalize(candidate.Reference), StringComparer.OrdinalIgnoreCase)
+ .OrderBy(candidate => candidate.Reference, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ foreach (var candidate in matched)
+ allTargets.Add(candidate.Reference);
+
+ var resolution = matched.Length > 0
+ ? exactDataSetMatches.Length > 0
+ ? "exact-live-dataset-binding"
+ : "exact-configured-rcb-reference"
+ : "unresolved";
+
+ if (matched.Length == 0)
+ {
+ warnings.Add(
+ $"Canonical static DataSet {segment.DataSetReference} covers selected signal(s), but no concrete live RCB could be proven by exact reference or exact live DatSet binding. No sibling/name-family guess was made.");
+ }
+
+ segments.Add(new MmsCanonicalStaticRcbTargetSegment
+ {
+ DataSetReference = segment.DataSetReference,
+ ConfiguredReportControlReferences = configuredReferences
+ .OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase)
+ .ToArray(),
+ ExactLiveReportControlReferences = matched
+ .Select(candidate => candidate.Reference)
+ .ToArray(),
+ Resolution = resolution
+ });
+ }
+
+ return new MmsCanonicalStaticRcbTargetResolution
+ {
+ ExactLiveReportControlReferences = allTargets,
+ Segments = segments,
+ Warnings = warnings
+ };
+ }
+
+ private static string Normalize(string? value)
+ => MmsRcbAvailabilityEvaluator.NormalizeReference(value);
+}
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs
new file mode 100644
index 00000000..76729fcc
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs
@@ -0,0 +1,46 @@
+using System.Collections.Concurrent;
+
+namespace AR.Iec61850.Mms;
+
+///
+/// Association-scoped cache for immutable configured DataSet directories.
+///
+/// Only successfully read non-deletable DataSets are cached. Dynamic/deletable or
+/// unknown-mutability DataSets are never cached because their membership can change.
+/// The cache is cleared on every successful MMS association.
+///
+public sealed partial class MmsClientSession
+{
+ private readonly ConcurrentDictionary _configuredStaticDataSetDirectoryEvidence =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ private void ResetConfiguredStaticDataSetDirectoryEvidence()
+ => _configuredStaticDataSetDirectoryEvidence.Clear();
+
+ private async Task<(MmsDataSetDirectoryResult Result, bool CacheHit)> GetAvailabilityDataSetDirectoryAsync(
+ string dataSetReference,
+ MmsIedModelDirectory? directory,
+ CancellationToken cancellationToken)
+ {
+ var key = MmsRcbAvailabilityEvaluator.NormalizeReference(dataSetReference);
+ if (key.Length > 0 &&
+ _configuredStaticDataSetDirectoryEvidence.TryGetValue(key, out var cached))
+ {
+ return (cached, true);
+ }
+
+ var result = await GetDataSetDirectoryAsync(
+ dataSetReference,
+ directory,
+ cancellationToken).ConfigureAwait(false);
+
+ if (key.Length > 0 &&
+ result.IsSuccess &&
+ result.IsDeletable == false)
+ {
+ _configuredStaticDataSetDirectoryEvidence[key] = result;
+ }
+
+ return (result, false);
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticFastSnapshot.cs b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticFastSnapshot.cs
new file mode 100644
index 00000000..ef0b1768
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticFastSnapshot.cs
@@ -0,0 +1,175 @@
+namespace AR.Iec61850.Mms;
+
+///
+/// Hot-path RCB state capture for configured-static activation.
+///
+/// Normal discovery/diagnostic probing intentionally reads many attributes. Activation must
+/// not repeat that broad scan. This path attempts one complete RCB structure read first and
+/// falls back only to the exact leaf evidence still required for the imminent mutation.
+/// There are no sleeps and no unrelated RCB/DataSet scans.
+///
+public sealed partial class MmsClientSession
+{
+ private async Task CaptureConfiguredStaticActivationSnapshotAsync(
+ MmsReportControlCandidate source,
+ string stage,
+ bool includeReservationEvidence,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+
+ var clone = CloneReportControlCandidate(source);
+ clone.EnabledState = string.Empty;
+ clone.ReservationState = string.Empty;
+ clone.ReservationTimeSeconds = string.Empty;
+ clone.Owner = string.Empty;
+ clone.DataSetReference = string.Empty;
+ clone.DataSetProbeState = MmsRcbDataSetProbeState.NotAttempted;
+ clone.DataSetProbeMessage = string.Empty;
+ clone.ProbeDiagnostics.Clear();
+
+ var logicalReads = 0;
+ var structureRead = false;
+ var leafFallbacks = 0;
+
+ try
+ {
+ var baseReference = MmsObjectReference.Parse(
+ source.Reference,
+ source.FunctionalConstraint);
+ var baseRead = await ReadSingleVariableAsync(
+ baseReference,
+ cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+
+ clone.ProbeDiagnostics.Add(
+ $"Activation RCB base {baseReference.Item}: {(baseRead.IsSuccess ? "OK" : baseRead.Message)}");
+
+ if (baseRead.IsSuccess &&
+ baseRead.Value is { Kind: MmsDataKind.Structure } structure &&
+ structure.Children.Count > 0)
+ {
+ ApplyReportControlStructure(clone, structure);
+ structureRead = true;
+
+ // DatSet is element 2 for BRCB and element 3 for URCB in the standard RCB
+ // structure layouts used by ApplyReportControlStructure.
+ var dataSetIndex = source.Buffered ? 2 : 3;
+ if (structure.Children.Count > dataSetIndex)
+ {
+ clone.DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded;
+ clone.DataSetProbeMessage = "DatSet observed in complete RCB structure read.";
+ }
+ }
+
+ if (MmsRcbAvailabilityEvaluator.ParseBool(clone.EnabledState) is null)
+ {
+ leafFallbacks++;
+ logicalReads++;
+ await ProbeReportControlAttributeAsync(
+ clone,
+ "RptEna",
+ value => clone.EnabledState = NormalizeReportAttributeText(value),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ // A positive live DatSet contradiction is safety-relevant. If the complete
+ // structure did not provide the field, read only this one leaf.
+ if (clone.DataSetProbeState != MmsRcbDataSetProbeState.ReadSucceeded)
+ {
+ leafFallbacks++;
+ logicalReads++;
+ var beforeDiagnostics = clone.ProbeDiagnostics.Count;
+ await ProbeReportControlAttributeAsync(
+ clone,
+ "DatSet",
+ value =>
+ {
+ var text = NormalizeReportAttributeText(value);
+ clone.DataSetReference = string.IsNullOrWhiteSpace(text)
+ ? string.Empty
+ : NormalizeReportedDataSetReference(clone.Domain, text);
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ var datSetDiagnostic = clone.ProbeDiagnostics
+ .Skip(beforeDiagnostics)
+ .LastOrDefault(line => line.StartsWith("DatSet", StringComparison.OrdinalIgnoreCase));
+ if (datSetDiagnostic?.Contains(": OK ", StringComparison.OrdinalIgnoreCase) == true)
+ {
+ clone.DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded;
+ clone.DataSetProbeMessage = datSetDiagnostic;
+ }
+ else
+ {
+ clone.DataSetProbeState = MmsRcbDataSetProbeState.ReadFailed;
+ clone.DataSetProbeMessage = datSetDiagnostic ?? "DatSet activation leaf read did not produce exact evidence.";
+ }
+ }
+
+ if (includeReservationEvidence)
+ {
+ if (source.Buffered)
+ {
+ if (MmsRcbAvailabilityEvaluator.ParseUnsigned(clone.ReservationTimeSeconds) is null &&
+ source.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase))
+ {
+ leafFallbacks++;
+ logicalReads++;
+ await ProbeReportControlAttributeAsync(
+ clone,
+ "ResvTms",
+ value => clone.ReservationTimeSeconds = NormalizeReportAttributeText(value),
+ cancellationToken).ConfigureAwait(false);
+ }
+ }
+ else if (MmsRcbAvailabilityEvaluator.ParseBool(clone.ReservationState) is null &&
+ source.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase))
+ {
+ leafFallbacks++;
+ logicalReads++;
+ await ProbeReportControlAttributeAsync(
+ clone,
+ "Resv",
+ value => clone.ReservationState = NormalizeReportAttributeText(value),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ // Owner is a separate optional leaf on many IEDs. Avoid paying this RTT
+ // when explicit reservation evidence is already free. Probe it only when
+ // reservation evidence remains ambiguous or earlier evidence was positive.
+ var explicitFree = source.Buffered
+ ? MmsRcbAvailabilityEvaluator.ParseUnsigned(clone.ReservationTimeSeconds) == 0
+ : MmsRcbAvailabilityEvaluator.ParseBool(clone.ReservationState) == false;
+ var earlierPositiveOwner = MmsRcbAvailabilityEvaluator.HasOwner(source.Owner);
+ if (source.Attributes.Contains("Owner", StringComparer.OrdinalIgnoreCase) &&
+ (!explicitFree || earlierPositiveOwner))
+ {
+ leafFallbacks++;
+ logicalReads++;
+ await ProbeOwnerReadOnlyAsync(clone, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ var hasExactEnable = MmsRcbAvailabilityEvaluator.ParseBool(clone.EnabledState).HasValue;
+ var message =
+ $"Configured-static JIT snapshot: exactRptEna={hasExactEnable.ToString().ToLowerInvariant()}, " +
+ $"structureRead={structureRead.ToString().ToLowerInvariant()}, logicalReads={logicalReads}, " +
+ $"leafFallbacks={leafFallbacks}, dataSetProbe={clone.DataSetProbeState}.";
+
+ return MmsReportRcbSnapshot.FromCandidate(
+ stage,
+ clone,
+ success: hasExactEnable,
+ message);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ return MmsReportRcbSnapshot.FromCandidate(
+ stage,
+ clone,
+ success: false,
+ $"Configured-static JIT snapshot failed after logicalReads={logicalReads}: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs
index 22c40a57..5a859e87 100644
--- a/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs
+++ b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs
@@ -16,39 +16,49 @@ public async Task CheckReportControlAvailabilityAsync(
var warnings = new List();
var snapshots = new List();
var dataSetDirectories = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var rcbStateLogicalReads = 0;
+ var dataSetDirectoryNetworkReads = 0;
+ var dataSetDirectoryCacheHits = 0;
+
var callerOwned = options.CallerOwnedRcbReferences
.Select(MmsRcbAvailabilityEvaluator.NormalizeReference)
.Where(reference => reference.Length > 0)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
- var max = Math.Clamp(options.MaxReportControls, 1, 4096);
- var candidates = inventory.ReportControls
- .OrderByDescending(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference))
- .ThenByDescending(candidate => candidate.Buffered)
- .ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase)
- .ThenBy(candidate => candidate.LogicalNode, StringComparer.OrdinalIgnoreCase)
- .ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase)
- .Take(max)
- .ToArray();
-
- if (inventory.ReportControls.Count > candidates.Length)
- warnings.Add($"Availability check was bounded to {candidates.Length} of {inventory.ReportControls.Count} discovered RCBs.");
-
- foreach (var source in candidates)
+
+ var selection = MmsRcbAvailabilityTargetSelector.Select(inventory, options);
+ warnings.AddRange(selection.Warnings);
+
+ foreach (var source in selection.Candidates)
{
cancellationToken.ThrowIfCancellationRequested();
var candidate = CloneReportControl(source);
- // Preserve discovery/SCL-derived binding only as fallback evidence. Clear the
- // candidate before the forced live probe so a successful empty DatSet read can
- // be distinguished from a failed read that merely left an old value in memory.
+ // Preserve discovery/SCL-derived binding only as fallback evidence. Volatile
+ // runtime state is cleared before every live check so stale discovery values
+ // can never masquerade as just-in-time availability evidence.
var previouslyKnownDataSetReference = candidate.DataSetReference;
candidate.DataSetReference = string.Empty;
candidate.DataSetProbeState = MmsRcbDataSetProbeState.NotAttempted;
candidate.DataSetProbeMessage = string.Empty;
+ candidate.EnabledState = string.Empty;
+ candidate.ReservationState = string.Empty;
+ candidate.ReservationTimeSeconds = string.Empty;
+ candidate.Owner = string.Empty;
+
+ if (selection.TargetFilterApplied)
+ {
+ rcbStateLogicalReads += await ProbeTargetedReportControlAvailabilityAsync(
+ candidate,
+ cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false);
+ // Broad diagnostic mode intentionally retains full attribute/Owner evidence.
+ await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false);
+ }
- await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false);
CaptureDataSetProbeEvidence(candidate, previouslyKnownDataSetReference);
- await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false);
MmsDataSetDirectoryResult? dataSetDirectory = null;
var dataSetReference = MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference);
@@ -56,7 +66,15 @@ public async Task CheckReportControlAvailabilityAsync(
{
if (!dataSetDirectories.TryGetValue(dataSetReference, out dataSetDirectory))
{
- dataSetDirectory = await GetDataSetDirectoryAsync(candidate.DataSetReference, directory, cancellationToken).ConfigureAwait(false);
+ var directoryEvidence = await GetAvailabilityDataSetDirectoryAsync(
+ candidate.DataSetReference,
+ directory,
+ cancellationToken).ConfigureAwait(false);
+ dataSetDirectory = directoryEvidence.Result;
+ if (directoryEvidence.CacheHit)
+ dataSetDirectoryCacheHits++;
+ else
+ dataSetDirectoryNetworkReads++;
dataSetDirectories[dataSetReference] = dataSetDirectory;
}
}
@@ -71,6 +89,13 @@ public async Task CheckReportControlAvailabilityAsync(
return new MmsRcbAvailabilityResult
{
CheckedAtUtc = checkedAt,
+ InventoryReportControlCount = inventory.ReportControls.Count,
+ TargetFilterApplied = selection.TargetFilterApplied,
+ RequestedTargetReportControlCount = selection.RequestedTargetCount,
+ MatchedTargetReportControlCount = selection.MatchedTargetCount,
+ TargetedRcbStateLogicalReadCount = rcbStateLogicalReads,
+ DataSetDirectoryNetworkReadCount = dataSetDirectoryNetworkReads,
+ DataSetDirectoryCacheHitCount = dataSetDirectoryCacheHits,
ReportControls = snapshots
.OrderByDescending(item => item.DataSetMemberCount > 0)
.ThenBy(item => AvailabilityRank(item.Availability))
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs
index 5fb5da12..af26a9cb 100644
--- a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs
+++ b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs
@@ -45,15 +45,27 @@ public static IReadOnlyList BuildBrcbEnableFa
public sealed partial class MmsClientSession
{
///
- /// Starts a persistent monitor for a static DataSet already trusted from SCL.
+ /// Backward-compatible SCL entry point. Once a caller supplies an authoritative
+ /// ordered DataSet and configured RCB, activation is source-neutral: Discovery and
+ /// Open-SCL must execute the same wire lifecycle rather than maintain parallel
+ /// reporting implementations.
+ ///
+ public Task StartStaticSclReportMonitorAsync(
+ MmsReportSubscriptionPlan plan,
+ bool triggerGeneralInterrogation = false,
+ CancellationToken cancellationToken = default)
+ => StartConfiguredStaticReportMonitorAsync(plan, triggerGeneralInterrogation, cancellationToken);
+
+ ///
+ /// Starts a persistent monitor for an authoritative configured static DataSet.
/// The primary wire sequence is deliberately minimal: whole-RCB Read, optional
/// URCB Resv=true, RptEna=true, then two whole-RCB verification Reads. BRCB
/// ResvTms is a retry-only compatibility fallback when direct RptEna is rejected.
- /// No DataSet browsing/creation and no GI are performed unless explicitly requested.
- /// When explicit GI is requested, GI acceptance is part of startup success so a
- /// caller cannot report an active initial-image monitor when the GI write was rejected.
+ /// No DataSet browsing/creation is performed here. When explicit GI is requested,
+ /// GI acceptance is part of startup success so a caller cannot report an active
+ /// initial-image monitor when the GI write was rejected.
///
- public async Task StartStaticSclReportMonitorAsync(
+ public async Task StartConfiguredStaticReportMonitorAsync(
MmsReportSubscriptionPlan plan,
bool triggerGeneralInterrogation = false,
CancellationToken cancellationToken = default)
@@ -66,7 +78,7 @@ public async Task StartStaticSclReportMon
return new MmsPersistentReportMonitorStartResult
{
IsSuccess = false,
- Message = "Trusted-SCL static report activation requires a ready plan with selected RCB."
+ Message = "Configured static report activation requires a ready plan with selected RCB."
};
}
@@ -75,7 +87,7 @@ public async Task StartStaticSclReportMon
return new MmsPersistentReportMonitorStartResult
{
IsSuccess = false,
- Message = "Trusted-SCL static report activation never creates or mutates a dynamic DataSet."
+ Message = "Configured static report activation never creates or mutates a dynamic DataSet."
};
}
@@ -89,10 +101,44 @@ public async Task StartStaticSclReportMon
try
{
- snapshots.Add(await CaptureReportControlSnapshotAsync(
+ var beforeSnapshot = await CaptureConfiguredStaticActivationSnapshotAsync(
rcb,
- "scl-static-before-enable",
- cancellationToken).ConfigureAwait(false));
+ "configured-static-before-enable",
+ includeReservationEvidence: true,
+ cancellationToken).ConfigureAwait(false);
+ snapshots.Add(beforeSnapshot);
+
+ var beforeProof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(
+ beforeSnapshot,
+ plan.DataSetReference);
+ if (!beforeProof.IsProven)
+ {
+ return new MmsPersistentReportMonitorStartResult
+ {
+ IsSuccess = false,
+ WriteSteps = writes,
+ Warnings = warnings,
+ RcbSnapshots = snapshots,
+ Message = $"Configured static activation failed JIT pre-write proof: {beforeProof.Kind}: {beforeProof.Message}"
+ };
+ }
+
+ var normalPathWrites = new List
+ {
+ MmsReportControlSemanticWrite.ReportEnable(true)
+ };
+ if (!rcb.Buffered &&
+ rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase))
+ {
+ normalPathWrites.Add(MmsReportControlSemanticWrite.Reservation(true));
+ }
+ if (triggerGeneralInterrogation)
+ normalPathWrites.Add(MmsReportControlSemanticWrite.GeneralInterrogation(true));
+
+ await PrimeReportSemanticTypeEvidenceFromStructureAsync(
+ rcb,
+ normalPathWrites,
+ cancellationToken).ConfigureAwait(false);
monitor = new MmsPersistentReportMonitorSession(
plan,
@@ -107,10 +153,8 @@ public async Task StartStaticSclReportMon
if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase))
{
- var reserve = await WriteReportAttributeAsync(
- rcb,
- "Resv",
- MmsDataValue.Boolean(true),
+ var reserve = await WriteReportSemanticAttributeAsync(
+ rcb, MmsReportControlSemanticWrite.Reservation(true),
cancellationToken).ConfigureAwait(false);
writes.Add(reserve);
reservationTouched = reserve.IsSuccess;
@@ -119,10 +163,8 @@ public async Task StartStaticSclReportMon
warnings.Add("URCB Resv=true was not accepted; direct RptEna will still be attempted once.");
}
- var enable = await WriteReportAttributeAsync(
- rcb,
- "RptEna",
- MmsDataValue.Boolean(true),
+ var enable = await WriteReportSemanticAttributeAsync(
+ rcb, MmsReportControlSemanticWrite.ReportEnable(true),
cancellationToken).ConfigureAwait(false);
writes.Add(enable);
enabled = enable.IsSuccess;
@@ -131,10 +173,8 @@ public async Task StartStaticSclReportMon
rcb.Buffered &&
rcb.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase))
{
- var reserveBrcb = await WriteReportAttributeAsync(
- rcb,
- "ResvTms",
- MmsDataValue.Unsigned(60),
+ var reserveBrcb = await WriteReportSemanticAttributeAsync(
+ rcb, MmsReportControlSemanticWrite.ReservationTime(60),
cancellationToken).ConfigureAwait(false);
writes.Add(reserveBrcb);
reservationTouched = reserveBrcb.IsSuccess;
@@ -142,10 +182,8 @@ public async Task StartStaticSclReportMon
if (reserveBrcb.IsSuccess)
{
- var retryEnable = await WriteReportAttributeAsync(
- rcb,
- "RptEna",
- MmsDataValue.Boolean(true),
+ var retryEnable = await WriteReportSemanticAttributeAsync(
+ rcb, MmsReportControlSemanticWrite.ReportEnable(true),
cancellationToken).ConfigureAwait(false);
writes.Add(retryEnable);
enabled = retryEnable.IsSuccess;
@@ -160,15 +198,11 @@ public async Task StartStaticSclReportMon
if (reservationTouched)
{
var release = rcb.Buffered
- ? await TryWriteReportAttributeForCleanupAsync(
- rcb,
- "ResvTms",
- MmsDataValue.Unsigned(0),
+ ? await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb, MmsReportControlSemanticWrite.ReservationTime(0),
CancellationToken.None).ConfigureAwait(false)
- : await TryWriteReportAttributeForCleanupAsync(
- rcb,
- "Resv",
- MmsDataValue.Boolean(false),
+ : await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb, MmsReportControlSemanticWrite.Reservation(false),
CancellationToken.None).ConfigureAwait(false);
writes.Add(release);
}
@@ -179,27 +213,62 @@ public async Task StartStaticSclReportMon
WriteSteps = writes,
Warnings = warnings,
RcbSnapshots = snapshots,
- Message = "RptEna=true was not accepted; trusted-SCL static report monitor was not started."
+ Message = "RptEna=true was not accepted; configured static report monitor was not started."
};
}
monitor.EnabledByThisClient = true;
- snapshots.Add(await CaptureReportControlSnapshotAsync(
+ var afterEnable = await CaptureConfiguredStaticActivationSnapshotAsync(
rcb,
- "scl-static-after-enable-1",
- cancellationToken).ConfigureAwait(false));
- snapshots.Add(await CaptureReportControlSnapshotAsync(
- rcb,
- "scl-static-after-enable-2",
- cancellationToken).ConfigureAwait(false));
+ "configured-static-after-enable",
+ includeReservationEvidence: false,
+ cancellationToken).ConfigureAwait(false);
+ snapshots.Add(afterEnable);
- if (triggerGeneralInterrogation)
+ var enableProof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(
+ afterEnable,
+ plan.DataSetReference);
+ if (!enableProof.IsProven)
{
- var gi = await WriteReportAttributeAsync(
+ UnregisterPersistentReportMonitor(monitor);
+
+ writes.Add(await TryWriteReportSemanticAttributeForCleanupAsync(
rcb,
- "GI",
- MmsDataValue.Boolean(true),
+ MmsReportControlSemanticWrite.ReportEnable(false),
+ CancellationToken.None).ConfigureAwait(false));
+ enabled = false;
+ monitor.EnabledByThisClient = false;
+
+ if (reservationTouched)
+ {
+ writes.Add(rcb.Buffered
+ ? await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb,
+ MmsReportControlSemanticWrite.ReservationTime(0),
+ CancellationToken.None).ConfigureAwait(false)
+ : await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb,
+ MmsReportControlSemanticWrite.Reservation(false),
+ CancellationToken.None).ConfigureAwait(false));
+ reservationTouched = false;
+ monitor.ReservationTouched = false;
+ }
+
+ return new MmsPersistentReportMonitorStartResult
+ {
+ IsSuccess = false,
+ WriteSteps = writes,
+ Warnings = warnings,
+ RcbSnapshots = snapshots,
+ Message = $"Configured static activation failed post-enable proof: {enableProof.Kind}: {enableProof.Message}"
+ };
+ }
+
+ if (triggerGeneralInterrogation)
+ {
+ var gi = await WriteReportSemanticAttributeAsync(
+ rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true),
cancellationToken).ConfigureAwait(false);
writes.Add(gi);
@@ -208,10 +277,8 @@ public async Task StartStaticSclReportMon
warnings.Add("Explicit GI=true request was rejected; initial-image startup failed closed.");
UnregisterPersistentReportMonitor(monitor);
- writes.Add(await TryWriteReportAttributeForCleanupAsync(
- rcb,
- "RptEna",
- MmsDataValue.Boolean(false),
+ writes.Add(await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb, MmsReportControlSemanticWrite.ReportEnable(false),
CancellationToken.None).ConfigureAwait(false));
enabled = false;
monitor.EnabledByThisClient = false;
@@ -219,15 +286,11 @@ public async Task StartStaticSclReportMon
if (reservationTouched)
{
writes.Add(rcb.Buffered
- ? await TryWriteReportAttributeForCleanupAsync(
- rcb,
- "ResvTms",
- MmsDataValue.Unsigned(0),
+ ? await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb, MmsReportControlSemanticWrite.ReservationTime(0),
CancellationToken.None).ConfigureAwait(false)
- : await TryWriteReportAttributeForCleanupAsync(
- rcb,
- "Resv",
- MmsDataValue.Boolean(false),
+ : await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb, MmsReportControlSemanticWrite.Reservation(false),
CancellationToken.None).ConfigureAwait(false));
reservationTouched = false;
monitor.ReservationTouched = false;
@@ -252,8 +315,8 @@ public async Task StartStaticSclReportMon
Warnings = warnings,
RcbSnapshots = snapshots,
Message = triggerGeneralInterrogation
- ? $"Trusted-SCL static report monitor armed for {rcb.Reference}; explicit one-shot GI=true was accepted after activation."
- : $"Trusted-SCL static report monitor armed for {rcb.Reference}; no DataSet discovery/mutation or GI was performed."
+ ? $"Configured static report monitor armed for {rcb.Reference}; explicit one-shot GI=true was accepted after activation."
+ : $"Configured static report monitor armed for {rcb.Reference}; no DataSet discovery/mutation or GI was performed."
};
}
catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException)
@@ -263,25 +326,19 @@ public async Task StartStaticSclReportMon
if (enabled)
{
- writes.Add(await TryWriteReportAttributeForCleanupAsync(
- rcb,
- "RptEna",
- MmsDataValue.Boolean(false),
+ writes.Add(await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb, MmsReportControlSemanticWrite.ReportEnable(false),
CancellationToken.None).ConfigureAwait(false));
}
if (reservationTouched)
{
writes.Add(rcb.Buffered
- ? await TryWriteReportAttributeForCleanupAsync(
- rcb,
- "ResvTms",
- MmsDataValue.Unsigned(0),
+ ? await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb, MmsReportControlSemanticWrite.ReservationTime(0),
CancellationToken.None).ConfigureAwait(false)
- : await TryWriteReportAttributeForCleanupAsync(
- rcb,
- "Resv",
- MmsDataValue.Boolean(false),
+ : await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb, MmsReportControlSemanticWrite.Reservation(false),
CancellationToken.None).ConfigureAwait(false));
}
@@ -291,7 +348,7 @@ public async Task StartStaticSclReportMon
WriteSteps = writes,
Warnings = warnings,
RcbSnapshots = snapshots,
- Message = $"Trusted-SCL static report activation failed: {ex.GetType().Name}: {ex.Message}"
+ Message = $"Configured static report activation failed: {ex.GetType().Name}: {ex.Message}"
};
}
}
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs
new file mode 100644
index 00000000..940a75bc
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs
@@ -0,0 +1,86 @@
+namespace AR.Iec61850.Mms;
+
+///
+/// Minimal live-state probe used only after an exact RCB target has already been chosen.
+///
+/// The broad diagnostic probe reads many presentation/configuration attributes. The runtime
+/// hot path instead tries one complete RCB structure read, then fills only missing DatSet,
+/// RptEna, reservation and Owner evidence required for safe availability decisions.
+///
+public sealed partial class MmsClientSession
+{
+ private async Task ProbeTargetedReportControlAvailabilityAsync(
+ MmsReportControlCandidate reportControl,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(reportControl);
+
+ var logicalReads = 0;
+
+ // Prefer one complete RCB structure read. On interoperable peers this provides
+ // DatSet + RptEna + reservation state in one confirmed service round trip.
+ await TryReadReportControlStructureAsync(reportControl, cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+
+ if (MmsRcbAvailabilityEvaluator.ParseBool(reportControl.EnabledState) is null)
+ {
+ await ProbeReportControlAttributeAsync(
+ reportControl,
+ "RptEna",
+ value => reportControl.EnabledState = NormalizeReportAttributeText(value),
+ cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+ }
+
+ if (string.IsNullOrWhiteSpace(reportControl.DataSetReference))
+ {
+ await ProbeReportControlAttributeAsync(
+ reportControl,
+ "DatSet",
+ value =>
+ {
+ var text = NormalizeReportAttributeText(value);
+ reportControl.DataSetReference = string.IsNullOrWhiteSpace(text)
+ ? string.Empty
+ : NormalizeReportedDataSetReference(reportControl.Domain, text);
+ },
+ cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+ }
+
+ if (reportControl.Buffered)
+ {
+ if (MmsRcbAvailabilityEvaluator.ParseUnsigned(reportControl.ReservationTimeSeconds) is null &&
+ reportControl.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase))
+ {
+ await ProbeReportControlAttributeAsync(
+ reportControl,
+ "ResvTms",
+ value => reportControl.ReservationTimeSeconds = NormalizeReportAttributeText(value),
+ cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+ }
+ }
+ else if (MmsRcbAvailabilityEvaluator.ParseBool(reportControl.ReservationState) is null &&
+ reportControl.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase))
+ {
+ await ProbeReportControlAttributeAsync(
+ reportControl,
+ "Resv",
+ value => reportControl.ReservationState = NormalizeReportAttributeText(value),
+ cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+ }
+
+ // Owner is outside the base structure on many devices. If exposed, one exact
+ // read is cheaper and safer than inferring ownership from missing metadata.
+ if (reportControl.Attributes.Contains("Owner", StringComparer.OrdinalIgnoreCase))
+ {
+ await ProbeOwnerReadOnlyAsync(reportControl, cancellationToken).ConfigureAwait(false);
+ logicalReads++;
+ }
+
+ reportControl.Status = "Targeted-availability-probed";
+ return logicalReads;
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.cs b/src/AR.Iec61850/Mms/MmsClientSession.cs
index 237333a7..636182e7 100644
--- a/src/AR.Iec61850/Mms/MmsClientSession.cs
+++ b/src/AR.Iec61850/Mms/MmsClientSession.cs
@@ -765,6 +765,8 @@ private async Task AssociateAsync(bool resetAssociationDiagnostics, Cancellation
{
State = MmsAssociationState.MmsInitiated;
LastHandshakeMessage = result.Message;
+ ResetReportSemanticTypeEvidence();
+ ResetConfiguredStaticDataSetDirectoryEvidence();
_receivePump.Start(cancellationToken);
return;
}
diff --git a/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs b/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs
new file mode 100644
index 00000000..232a4f56
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs
@@ -0,0 +1,172 @@
+namespace AR.Iec61850.Mms;
+
+public enum MmsConfiguredStaticActivationProofKind
+{
+ Proven,
+ SnapshotReadFailed,
+ ReportEnableStateMismatch,
+ DataSetBindingMismatch,
+ BusyRuntimeEvidence,
+ InsufficientReadbackEvidence
+}
+
+public sealed class MmsConfiguredStaticActivationProof
+{
+ public MmsConfiguredStaticActivationProofKind Kind { get; init; }
+ public string Stage { get; init; } = string.Empty;
+ public string ExpectedDataSetReference { get; init; } = string.Empty;
+ public string ObservedDataSetReference { get; init; } = string.Empty;
+ public string ObservedEnabledState { get; init; } = string.Empty;
+ public string Message { get; init; } = string.Empty;
+
+ public bool IsProven => Kind == MmsConfiguredStaticActivationProofKind.Proven;
+}
+
+///
+/// Pure proof policy for configured-static RCB activation.
+///
+/// Planning evidence can go stale between plan creation and mutation. This verifier treats
+/// a minimal just-in-time RCB snapshot as the runtime authority. One exact post-write
+/// readback is sufficient when it proves RptEna=true and no positive DataSet contradiction;
+/// a second unconditional read would add latency without adding semantic evidence.
+///
+/// Missing reservation/Owner metadata is not converted into a blocker, but positive busy
+/// evidence always blocks. Missing DataSet text is not a mismatch unless the live DatSet
+/// read positively succeeded and proved an empty binding.
+///
+public static class MmsConfiguredStaticActivationVerifier
+{
+ public static MmsConfiguredStaticActivationProof VerifyBeforeWrite(
+ MmsReportRcbSnapshot snapshot,
+ string expectedDataSetReference)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ if (!snapshot.IsSuccess)
+ return Fail(
+ MmsConfiguredStaticActivationProofKind.SnapshotReadFailed,
+ snapshot,
+ expectedDataSetReference,
+ "Pre-activation JIT RCB read failed; no activation write is permitted.");
+
+ if (HasPositiveDataSetMismatch(snapshot, expectedDataSetReference))
+ return Fail(
+ MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch,
+ snapshot,
+ expectedDataSetReference,
+ "Live RCB DatSet contradicts the configured static plan; activation is blocked rather than rewriting DatSet.");
+
+ if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != false)
+ return Fail(
+ MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch,
+ snapshot,
+ expectedDataSetReference,
+ "Configured static activation requires exact RptEna=false immediately before mutation.");
+
+ if (HasPositiveBusyEvidence(snapshot))
+ return Fail(
+ MmsConfiguredStaticActivationProofKind.BusyRuntimeEvidence,
+ snapshot,
+ expectedDataSetReference,
+ "Fresh reservation/Owner evidence indicates the RCB is occupied by another runtime context.");
+
+ return Pass(
+ snapshot,
+ expectedDataSetReference,
+ "Pre-activation JIT evidence proves RptEna=false, no conflicting DataSet binding, and no positive busy evidence.");
+ }
+
+ public static MmsConfiguredStaticActivationProof VerifyAfterEnable(
+ MmsReportRcbSnapshot snapshot,
+ string expectedDataSetReference)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ if (!snapshot.IsSuccess)
+ return Fail(
+ MmsConfiguredStaticActivationProofKind.SnapshotReadFailed,
+ snapshot,
+ expectedDataSetReference,
+ "Post-enable JIT readback failed; write acceptance alone is not activation proof.");
+
+ if (HasPositiveDataSetMismatch(snapshot, expectedDataSetReference))
+ return Fail(
+ MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch,
+ snapshot,
+ expectedDataSetReference,
+ "Post-enable RCB DatSet contradicts the configured static plan.");
+
+ if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != true)
+ return Fail(
+ MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch,
+ snapshot,
+ expectedDataSetReference,
+ "RptEna=true was not proven by post-enable JIT readback.");
+
+ return Pass(
+ snapshot,
+ expectedDataSetReference,
+ "Post-enable JIT readback proves RptEna=true with no conflicting live DataSet binding.");
+ }
+
+ private static bool HasPositiveBusyEvidence(MmsReportRcbSnapshot snapshot)
+ {
+ if (MmsRcbAvailabilityEvaluator.HasOwner(snapshot.Owner))
+ return true;
+
+ return snapshot.Buffered
+ ? MmsRcbAvailabilityEvaluator.ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0
+ : MmsRcbAvailabilityEvaluator.ParseBool(snapshot.ReservationState) == true;
+ }
+
+ private static bool HasPositiveDataSetMismatch(
+ MmsReportRcbSnapshot snapshot,
+ string expected)
+ {
+ var observedNormalized = Normalize(snapshot.DataSetReference);
+ var expectedNormalized = Normalize(expected);
+
+ if (snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded &&
+ observedNormalized.Length == 0 &&
+ expectedNormalized.Length > 0)
+ {
+ return true;
+ }
+
+ return observedNormalized.Length > 0 &&
+ expectedNormalized.Length > 0 &&
+ !string.Equals(observedNormalized, expectedNormalized, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string Normalize(string? value)
+ => MmsRcbAvailabilityEvaluator.NormalizeReference(value);
+
+ private static MmsConfiguredStaticActivationProof Pass(
+ MmsReportRcbSnapshot snapshot,
+ string expectedDataSetReference,
+ string message)
+ => new()
+ {
+ Kind = MmsConfiguredStaticActivationProofKind.Proven,
+ Stage = snapshot.Stage,
+ ExpectedDataSetReference = Normalize(expectedDataSetReference),
+ ObservedDataSetReference = Normalize(snapshot.DataSetReference),
+ ObservedEnabledState = snapshot.EnabledState,
+ Message = message
+ };
+
+ private static MmsConfiguredStaticActivationProof Fail(
+ MmsConfiguredStaticActivationProofKind kind,
+ MmsReportRcbSnapshot snapshot,
+ string expectedDataSetReference,
+ string message)
+ => new()
+ {
+ Kind = kind,
+ Stage = snapshot.Stage,
+ ExpectedDataSetReference = Normalize(expectedDataSetReference),
+ ObservedDataSetReference = Normalize(snapshot.DataSetReference),
+ ObservedEnabledState = snapshot.EnabledState,
+ Message = $"{message} {snapshot.Message}".Trim()
+ };
+}
diff --git a/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs b/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs
new file mode 100644
index 00000000..d83140ba
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs
@@ -0,0 +1,131 @@
+namespace AR.Iec61850.Mms;
+
+public enum MmsConfiguredStaticRcbEligibilityKind
+{
+ Blocked,
+ CallerOwned,
+ ExplicitFree,
+ ReducedMissingReservationEvidence
+}
+
+public sealed class MmsConfiguredStaticRcbEligibility
+{
+ public MmsConfiguredStaticRcbEligibilityKind Kind { get; init; }
+ public string Reason { get; init; } = string.Empty;
+ public bool IsEligible => Kind != MmsConfiguredStaticRcbEligibilityKind.Blocked;
+ public bool RequiresWrite => Kind is
+ MmsConfiguredStaticRcbEligibilityKind.ExplicitFree or
+ MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence;
+ public bool UsesReducedEvidence =>
+ Kind == MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence;
+}
+
+///
+/// Evaluates only configured static RCB activation eligibility.
+///
+/// Exact-free evidence remains preferred. A separately gated reduced state may be emitted
+/// only when the configured DataSet binding is freshly read and populated, RptEna is
+/// explicitly false, no positive foreign ownership/reservation evidence exists, and the
+/// reservation field is genuinely absent/unexposed. Malformed or contradictory values are
+/// blockers, never "missing evidence".
+///
+/// This policy never relaxes dynamic DataSet mutation eligibility.
+///
+public static class MmsConfiguredStaticRcbEligibilityPolicy
+{
+ public static MmsConfiguredStaticRcbEligibility Evaluate(
+ MmsRcbAvailabilitySnapshot snapshot,
+ bool allowCallerOwned,
+ bool allowReducedMissingReservationEvidence)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ if (snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller)
+ {
+ var callerReady = allowCallerOwned &&
+ HasVerifiedStaticDataSet(snapshot);
+ return callerReady
+ ? Eligible(
+ MmsConfiguredStaticRcbEligibilityKind.CallerOwned,
+ "The configured static RCB is already active in this caller association and its populated DataSet is verified.")
+ : Blocked("Caller-owned RCB reuse is disabled or its configured DataSet is not fully verified.");
+ }
+
+ if (!HasVerifiedStaticDataSet(snapshot))
+ return Blocked("Configured static RCB requires a successful live DatSet read and a populated DataSet directory.");
+
+ if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != false)
+ return Blocked("Configured static RCB requires explicit RptEna=false before a new activation write.");
+
+ if (HasPositiveBusyEvidence(snapshot))
+ return Blocked("Positive RCB ownership/reservation evidence indicates the configured static RCB is not free.");
+
+ if (snapshot.Availability == MmsRcbOperationalAvailability.Available &&
+ HasExplicitFreeReservation(snapshot))
+ {
+ return Eligible(
+ MmsConfiguredStaticRcbEligibilityKind.ExplicitFree,
+ $"Fresh evidence confirms a populated configured DataSet, RptEna=false, and explicit free reservation state (confidence={snapshot.Confidence}).");
+ }
+
+ if (!allowReducedMissingReservationEvidence)
+ return Blocked("Exact free reservation evidence is unavailable and reduced configured-static activation is not enabled.");
+
+ if (snapshot.Availability != MmsRcbOperationalAvailability.Unknown)
+ return Blocked($"Reduced configured-static activation accepts only missing reservation evidence; availability={snapshot.Availability} is not an unknown-reservation state.");
+
+ if (!ReservationEvidenceIsGenuinelyMissing(snapshot))
+ return Blocked("Reservation evidence is present but is neither explicitly free nor safely classifiable as missing.");
+
+ return Eligible(
+ MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence,
+ "Configured static DataSet and RptEna=false are verified and no positive busy evidence exists, but the IED does not expose usable reservation metadata. Activation may proceed only under the explicit reduced-evidence policy and must still prove success by readback/report traffic.");
+ }
+
+ private static bool HasVerifiedStaticDataSet(MmsRcbAvailabilitySnapshot snapshot)
+ => snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded &&
+ snapshot.DataSetDirectorySuccess &&
+ snapshot.DataSetMembers.Count > 0 &&
+ !string.IsNullOrWhiteSpace(snapshot.DataSetReference);
+
+ private static bool HasPositiveBusyEvidence(MmsRcbAvailabilitySnapshot snapshot)
+ {
+ if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) == true)
+ return true;
+ if (MmsRcbAvailabilityEvaluator.HasOwner(snapshot.Owner))
+ return true;
+
+ return snapshot.Buffered
+ ? MmsRcbAvailabilityEvaluator.ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0
+ : MmsRcbAvailabilityEvaluator.ParseBool(snapshot.ReservationState) == true;
+ }
+
+ private static bool HasExplicitFreeReservation(MmsRcbAvailabilitySnapshot snapshot)
+ => snapshot.Buffered
+ ? MmsRcbAvailabilityEvaluator.ParseUnsigned(snapshot.ReservationTimeSeconds) == 0
+ : MmsRcbAvailabilityEvaluator.ParseBool(snapshot.ReservationState) == false;
+
+ private static bool ReservationEvidenceIsGenuinelyMissing(MmsRcbAvailabilitySnapshot snapshot)
+ => snapshot.Buffered
+ ? IsMissingToken(snapshot.ReservationTimeSeconds)
+ : IsMissingToken(snapshot.ReservationState);
+
+ private static bool IsMissingToken(string? value)
+ {
+ var text = (value ?? string.Empty).Trim();
+ return text.Length == 0 ||
+ text == "-" ||
+ text == "[]" ||
+ text.Equals("null", StringComparison.OrdinalIgnoreCase) ||
+ text.Equals("unsupported", StringComparison.OrdinalIgnoreCase) ||
+ text.Equals("not-exposed", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static MmsConfiguredStaticRcbEligibility Eligible(
+ MmsConfiguredStaticRcbEligibilityKind kind,
+ string reason)
+ => new() { Kind = kind, Reason = reason };
+
+ private static MmsConfiguredStaticRcbEligibility Blocked(string reason)
+ => new() { Kind = MmsConfiguredStaticRcbEligibilityKind.Blocked, Reason = reason };
+}
diff --git a/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs b/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs
index 12bb99f8..a39baf6c 100644
--- a/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs
+++ b/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs
@@ -47,6 +47,13 @@ public sealed class MmsHybridReportAcquisitionOptions
public bool AllowCallerOwnedReports { get; init; } = true;
public bool AllowStaticBrcb { get; init; } = true;
public bool AllowStaticUrcb { get; init; } = true;
+ ///
+ /// Explicit interoperability opt-in for configured static RCBs whose live DataSet
+ /// and RptEna=false are verified but whose reservation attribute is genuinely
+ /// unexposed. Default false preserves the exact-evidence production contract.
+ /// This option never applies to dynamic DataSet mutation.
+ ///
+ public bool AllowConfiguredStaticWithMissingReservationEvidence { get; init; }
public bool AllowDynamicBrcb { get; init; } = true;
public bool AllowDynamicUrcb { get; init; } = true;
public bool AllowPollingFallback { get; init; } = true;
@@ -347,11 +354,12 @@ private static void PlanStaticCoverage(
.Select(snapshot => new
{
Snapshot = snapshot,
+ Eligibility = EvaluateStaticEligibility(snapshot, options),
Covered = remaining.Where(signal => StaticDataSetCovers(snapshot, signal)).ToArray()
})
.Where(item => item.Covered.Length > 0)
.OrderByDescending(item => item.Covered.Length)
- .ThenBy(item => item.Snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller ? 0 : 1)
+ .ThenBy(item => StaticEligibilityRank(item.Eligibility.Kind))
.ThenBy(item => item.Snapshot.Buffered ? 0 : 1)
.ThenBy(item => item.Snapshot.Reference, StringComparer.OrdinalIgnoreCase)
.ToArray();
@@ -361,7 +369,8 @@ private static void PlanStaticCoverage(
var selected = scored[0];
var snapshot = selected.Snapshot;
- var plan = snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller
+ var eligibility = selected.Eligibility;
+ var plan = eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned
? BuildCallerOwnedStaticPlan(snapshot, inventory)
: BuildFreshStaticPlan(snapshot);
@@ -373,7 +382,14 @@ private static void PlanStaticCoverage(
}
var kind = snapshot.Buffered ? MmsHybridAcquisitionKind.StaticBrcb : MmsHybridAcquisitionKind.StaticUrcb;
- var callerOwned = snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller;
+ var callerOwned = eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned;
+ var reducedEvidence = eligibility.UsesReducedEvidence;
+ if (reducedEvidence)
+ {
+ warnings.Add(
+ $"Configured static candidate {snapshot.Reference} is using the explicit reduced reservation-evidence policy: {eligibility.Reason}");
+ }
+
var segment = new MmsHybridAcquisitionSegment
{
Kind = kind,
@@ -387,7 +403,9 @@ private static void PlanStaticCoverage(
IsAlreadyActiveByCaller = callerOwned,
Reason = callerOwned
? "Fresh availability evidence identifies this report as already active in the caller's current session; reuse it without reconfiguration."
- : "Fresh exact availability evidence confirms a populated static DataSet and a free RCB; enable the existing report without changing its DataSet membership."
+ : reducedEvidence
+ ? eligibility.Reason
+ : "Fresh availability evidence confirms a populated static DataSet, RptEna=false, and explicit free reservation state; enable the existing report without changing its DataSet membership."
};
segments.Add(segment);
@@ -681,23 +699,38 @@ private static bool IsStaticUsable(MmsRcbAvailabilitySnapshot snapshot, MmsHybri
return false;
if (!snapshot.Buffered && !options.AllowStaticUrcb)
return false;
- if (snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller)
- return options.AllowCallerOwnedReports &&
- snapshot.DataSetDirectorySuccess &&
- snapshot.DataSetMembers.Count > 0 &&
- !string.IsNullOrWhiteSpace(snapshot.DataSetReference);
- if (snapshot.Availability != MmsRcbOperationalAvailability.Available)
- return false;
- if (options.RequireExactAvailabilityEvidence && snapshot.Confidence != MmsRcbAvailabilityConfidence.Exact)
+
+ var eligibility = EvaluateStaticEligibility(snapshot, options);
+ if (!eligibility.IsEligible)
return false;
- return snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded &&
- snapshot.DataSetDirectorySuccess &&
- snapshot.DataSetMembers.Count > 0 &&
- !string.IsNullOrWhiteSpace(snapshot.DataSetReference) &&
- ParseBool(snapshot.EnabledState) == false &&
- HasExplicitFreeReservation(snapshot);
+
+ if (eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned)
+ return true;
+
+ if (eligibility.UsesReducedEvidence)
+ return options.AllowConfiguredStaticWithMissingReservationEvidence;
+
+ return !options.RequireExactAvailabilityEvidence ||
+ snapshot.Confidence == MmsRcbAvailabilityConfidence.Exact;
}
+ private static MmsConfiguredStaticRcbEligibility EvaluateStaticEligibility(
+ MmsRcbAvailabilitySnapshot snapshot,
+ MmsHybridReportAcquisitionOptions options)
+ => MmsConfiguredStaticRcbEligibilityPolicy.Evaluate(
+ snapshot,
+ options.AllowCallerOwnedReports,
+ options.AllowConfiguredStaticWithMissingReservationEvidence);
+
+ private static int StaticEligibilityRank(MmsConfiguredStaticRcbEligibilityKind kind)
+ => kind switch
+ {
+ MmsConfiguredStaticRcbEligibilityKind.CallerOwned => 0,
+ MmsConfiguredStaticRcbEligibilityKind.ExplicitFree => 1,
+ MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence => 2,
+ _ => 9
+ };
+
private static bool IsDynamicUsable(MmsRcbAvailabilitySnapshot snapshot, MmsHybridReportAcquisitionOptions options)
{
if (snapshot.Buffered && !options.AllowDynamicBrcb)
diff --git a/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs b/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs
new file mode 100644
index 00000000..df6b08ef
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs
@@ -0,0 +1,71 @@
+namespace AR.Iec61850.Mms;
+
+///
+/// Protocol-level failure categories used by planning/execution code. These are derived
+/// from structured MMS DataAccessError codes, never from human-readable message text.
+///
+public enum MmsInteropFailureKind
+{
+ None,
+ ObjectInvalidated,
+ HardwareFault,
+ TemporarilyUnavailable,
+ AccessDenied,
+ ObjectUndefined,
+ InvalidAddress,
+ TypeUnsupported,
+ TypeMismatch,
+ AttributeInconsistent,
+ AccessUnsupported,
+ ObjectNotFound,
+ InvalidValue,
+ Unknown
+}
+
+///
+/// Evidence-oriented recovery hint. This does not authorize an automatic retry; it tells
+/// higher layers what must be revalidated before any bounded follow-up operation.
+///
+public enum MmsInteropRecoveryHint
+{
+ None,
+ RevalidateType,
+ RevalidateRuntimeState,
+ RevalidateObjectIdentity,
+ RetryLaterAfterStateRefresh,
+ FailClosed
+}
+
+public readonly record struct MmsInteropFailureClassification(
+ MmsInteropFailureKind Kind,
+ MmsInteropRecoveryHint RecoveryHint)
+{
+ public static MmsInteropFailureClassification Success =>
+ new(MmsInteropFailureKind.None, MmsInteropRecoveryHint.None);
+}
+
+public static class MmsInteropFailureClassifier
+{
+ public static MmsInteropFailureClassification Classify(MmsWriteAccessResult? access)
+ {
+ if (access is null || access.IsSuccess)
+ return MmsInteropFailureClassification.Success;
+
+ return access.FailureCode switch
+ {
+ 0 => new(MmsInteropFailureKind.ObjectInvalidated, MmsInteropRecoveryHint.RevalidateRuntimeState),
+ 1 => new(MmsInteropFailureKind.HardwareFault, MmsInteropRecoveryHint.FailClosed),
+ 2 => new(MmsInteropFailureKind.TemporarilyUnavailable, MmsInteropRecoveryHint.RetryLaterAfterStateRefresh),
+ 3 => new(MmsInteropFailureKind.AccessDenied, MmsInteropRecoveryHint.FailClosed),
+ 4 => new(MmsInteropFailureKind.ObjectUndefined, MmsInteropRecoveryHint.RevalidateObjectIdentity),
+ 5 => new(MmsInteropFailureKind.InvalidAddress, MmsInteropRecoveryHint.RevalidateObjectIdentity),
+ 6 => new(MmsInteropFailureKind.TypeUnsupported, MmsInteropRecoveryHint.RevalidateType),
+ 7 => new(MmsInteropFailureKind.TypeMismatch, MmsInteropRecoveryHint.RevalidateType),
+ 8 => new(MmsInteropFailureKind.AttributeInconsistent, MmsInteropRecoveryHint.RevalidateRuntimeState),
+ 9 => new(MmsInteropFailureKind.AccessUnsupported, MmsInteropRecoveryHint.FailClosed),
+ 10 => new(MmsInteropFailureKind.ObjectNotFound, MmsInteropRecoveryHint.RevalidateObjectIdentity),
+ 11 => new(MmsInteropFailureKind.InvalidValue, MmsInteropRecoveryHint.FailClosed),
+ _ => new(MmsInteropFailureKind.Unknown, MmsInteropRecoveryHint.FailClosed)
+ };
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs
index b09f7b45..403ed96f 100644
--- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs
+++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs
@@ -2,8 +2,19 @@
namespace AR.Iec61850.Mms;
+public enum MmsPersistentReportTrafficState
+{
+ ArmedAwaitingTraffic,
+ TrafficProven,
+ Stopped
+}
+
public sealed class MmsPersistentReportMonitorSession
{
+ private long _firstRoutedReportUtcTicks;
+ private long _lastRoutedReportUtcTicks;
+ private int _routedReportCount;
+
internal MmsPersistentReportMonitorSession(
MmsReportSubscriptionPlan plan,
MmsReportControlCandidate reportControl,
@@ -38,10 +49,35 @@ internal MmsPersistentReportMonitorSession(
public int ReportCount { get; internal set; }
public int PollReadCount { get; internal set; }
public bool IsStopped { get; internal set; }
+ public int RoutedReportCount => Volatile.Read(ref _routedReportCount);
+ public DateTimeOffset? FirstRoutedReportAt => ToUtcTimestamp(Volatile.Read(ref _firstRoutedReportUtcTicks));
+ public DateTimeOffset? LastRoutedReportAt => ToUtcTimestamp(Volatile.Read(ref _lastRoutedReportUtcTicks));
+ public MmsPersistentReportTrafficState TrafficState => IsStopped
+ ? MmsPersistentReportTrafficState.Stopped
+ : RoutedReportCount > 0
+ ? MmsPersistentReportTrafficState.TrafficProven
+ : MmsPersistentReportTrafficState.ArmedAwaitingTraffic;
+ public TimeSpan? FirstReportLatency => FirstRoutedReportAt.HasValue
+ ? FirstRoutedReportAt.Value - StartedAt
+ : null;
+
internal ConcurrentQueue PendingReports { get; } = new();
+ internal void ObserveRoutedReport(MmsReportFrame frame)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ var ticks = frame.ReceivedAt.UtcDateTime.Ticks;
+ Interlocked.CompareExchange(ref _firstRoutedReportUtcTicks, ticks, 0);
+ Interlocked.Exchange(ref _lastRoutedReportUtcTicks, ticks);
+ Interlocked.Increment(ref _routedReportCount);
+ }
+
public string Summary =>
- $"persistent report monitor: rcb={ReportControl.Reference}, dataset={Plan.DataSetReference}, mode={Plan.Mode}, reports={ReportCount}, stopped={IsStopped}";
+ $"persistent report monitor: rcb={ReportControl.Reference}, dataset={Plan.DataSetReference}, mode={Plan.Mode}, " +
+ $"traffic={TrafficState}, routedReports={RoutedReportCount}, drainedReports={ReportCount}, stopped={IsStopped}";
+
+ private static DateTimeOffset? ToUtcTimestamp(long ticks)
+ => ticks <= 0 ? null : new DateTimeOffset(ticks, TimeSpan.Zero);
}
public sealed class MmsPersistentReportMonitorStartResult
@@ -233,14 +269,14 @@ public async Task StartPersistentReportMo
}
else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase))
{
- var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false);
+ var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false);
writes.Add(reserve);
reservationTouched = reserve.IsSuccess;
if (!reserve.IsSuccess)
warnings.Add("URCB Resv write failed. Continuing only if RptEna=true is accepted by the IED.");
}
- var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false);
+ var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false);
writes.Add(enable);
enabledByThisClient = enable.IsSuccess;
if (!enable.IsSuccess)
@@ -261,7 +297,7 @@ public async Task StartPersistentReportMo
if (triggerGeneralInterrogation)
{
- var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false);
+ var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(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.");
@@ -363,7 +399,7 @@ public async Task StopPersistentReportMoni
if (session.EnabledByThisClient)
{
- var disable = await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false);
+ var disable = await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false);
writes.Add(disable);
success &= disable.IsSuccess;
}
@@ -410,8 +446,8 @@ public async Task StopPersistentReportMoni
if (session.ReservationTouched)
{
var release = session.ReportControl.Buffered
- ? await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false)
- : await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false);
+ ? await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false)
+ : await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false);
writes.Add(release);
success &= release.IsSuccess;
}
@@ -509,10 +545,8 @@ private async Task ReceiveAndDispatchPersistentReportsAsync(
if (giPending)
{
- writes.Add(await WriteReportAttributeAsync(
- requestedSession.ReportControl,
- "GI",
- MmsDataValue.Boolean(true),
+ writes.Add(await WriteReportSemanticAttributeAsync(
+ requestedSession.ReportControl, MmsReportControlSemanticWrite.GeneralInterrogation(true),
cancellationToken).ConfigureAwait(false));
giPending = false;
continue;
@@ -541,9 +575,26 @@ private async Task ReceiveAndDispatchPersistentReportsAsync(
if (IsReceivePumpRunning)
{
- var delay = remaining < TimeSpan.FromMilliseconds(25) ? remaining : TimeSpan.FromMilliseconds(25);
- if (delay > TimeSpan.Zero)
- await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+ // The association-owned receive pump already blocks on the transport.
+ // Do not add a 25 ms application polling loop on top of it. Wait for the
+ // router's binary InformationReport signal, bounded by the next legitimate
+ // scheduler deadline (residual polling) or this receive slice deadline.
+ var wakeAt = deadline;
+ if (pollDirectory != null &&
+ references.Length > 0 &&
+ nextPollAt < wakeAt)
+ {
+ wakeAt = nextPollAt;
+ }
+
+ var waitFor = wakeAt - DateTimeOffset.UtcNow;
+ if (waitFor > TimeSpan.Zero)
+ {
+ await _receiveRouter.WaitForInformationReportAsync(
+ waitFor,
+ cancellationToken).ConfigureAwait(false);
+ }
+
continue;
}
@@ -598,6 +649,7 @@ private void RoutePersistentInformationReport(byte[] payload)
}
var frame = MmsReportFrameMapper.Map(decoded, target.Plan.Members, DateTimeOffset.UtcNow);
+ target.ObserveRoutedReport(frame);
target.PendingReports.Enqueue(frame);
LastReceiveRoutingSummary =
$"Routed InformationReport to {target.ReportControl.Reference} by {evidence}. RptID={RoutingTextOrDash(header.ReportId)}, DatSet={RoutingTextOrDash(header.DataSetReference)}.";
diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs
index af6e046c..a2942421 100644
--- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs
+++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs
@@ -19,7 +19,10 @@ public enum MmsReportActivationFailureReason
DynamicDataSetBindFailed,
TriggerOptionsUnavailable,
TriggerOptionsWriteFailed,
+ RcbFieldTypeConflict,
+ ReservationWriteFailed,
ReportEnableFailed,
+ GeneralInterrogationFailed,
ActivationException,
OtherActivationFailure
}
@@ -98,12 +101,22 @@ public async Task StartPersistentReport
}
}
- var start = await StartPersistentReportMonitorAsync(
- plan,
- triggerGeneralInterrogation,
- deleteDynamicDataSetOnStop,
- directory,
- cancellationToken).ConfigureAwait(false);
+ // A fresh configured static plan must use the same source-neutral activation
+ // lifecycle regardless of whether its model originated from live Discovery or SCL:
+ // receiver registration -> direct RptEna -> bounded BRCB ResvTms fallback ->
+ // readback -> optional GI. Dynamic plans retain the dynamic transaction path below.
+ var start = !isDynamic &&
+ plan.Status == MmsReportSubscriptionPlanStatus.ReadyRequiresWrite
+ ? await StartConfiguredStaticReportMonitorAsync(
+ plan,
+ triggerGeneralInterrogation,
+ cancellationToken).ConfigureAwait(false)
+ : await StartPersistentReportMonitorAsync(
+ plan,
+ triggerGeneralInterrogation,
+ deleteDynamicDataSetOnStop,
+ directory,
+ cancellationToken).ConfigureAwait(false);
if (probe is not null)
start = MergeProbeEvidence(start, probe);
@@ -162,10 +175,8 @@ public async Task StartPersistentReport
if (enabled)
{
- var disable = await TryWriteReportAttributeForCleanupAsync(
- rcb,
- "RptEna",
- MmsDataValue.Boolean(false),
+ var disable = await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb, MmsReportControlSemanticWrite.ReportEnable(false),
CancellationToken.None).ConfigureAwait(false);
cleanupSteps.Add(disable);
cleanupSucceeded &= disable.IsSuccess;
@@ -218,8 +229,8 @@ public async Task StartPersistentReport
if (reserved)
{
var release = rcb.Buffered
- ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false)
- : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false);
+ ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false)
+ : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false);
cleanupSteps.Add(release);
cleanupSucceeded &= release.IsSuccess;
}
@@ -266,9 +277,15 @@ private static bool SuccessfulStep(IEnumerable step
private static MmsReportActivationFailureReason ClassifyFailure(MmsPersistentReportMonitorStartResult result)
{
- var failed = result.WriteSteps.LastOrDefault(step => step.Attempted && !step.IsSuccess);
+ var failed = result.WriteSteps.LastOrDefault(step => !step.IsSuccess);
if (failed is not null)
{
+ if (failed.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.ExactMismatch ||
+ failed.FailureKind is MmsInteropFailureKind.TypeMismatch or MmsInteropFailureKind.TypeUnsupported)
+ {
+ return MmsReportActivationFailureReason.RcbFieldTypeConflict;
+ }
+
if (failed.Attribute.Equals("Probe.DefineNamedVariableList", StringComparison.OrdinalIgnoreCase))
return MmsReportActivationFailureReason.DynamicDataSetProbeDefineFailed;
if (failed.Attribute.Equals("Probe.GetNamedVariableListAttributes", StringComparison.OrdinalIgnoreCase))
@@ -281,8 +298,13 @@ private static MmsReportActivationFailureReason ClassifyFailure(MmsPersistentRep
return MmsReportActivationFailureReason.DynamicDataSetBindFailed;
if (failed.Attribute.Equals("TrgOps", StringComparison.OrdinalIgnoreCase))
return MmsReportActivationFailureReason.TriggerOptionsWriteFailed;
+ if (failed.Attribute.Equals("Resv", StringComparison.OrdinalIgnoreCase) ||
+ failed.Attribute.Equals("ResvTms", StringComparison.OrdinalIgnoreCase))
+ return MmsReportActivationFailureReason.ReservationWriteFailed;
if (failed.Attribute.Equals("RptEna", StringComparison.OrdinalIgnoreCase))
return MmsReportActivationFailureReason.ReportEnableFailed;
+ if (failed.Attribute.Equals("GI", StringComparison.OrdinalIgnoreCase))
+ return MmsReportActivationFailureReason.GeneralInterrogationFailed;
}
if (result.Message.Contains("requires a writable TrgOps", StringComparison.OrdinalIgnoreCase))
diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs
index b0fb63d2..2a4d50ce 100644
--- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs
+++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs
@@ -26,10 +26,8 @@ public async Task StartPersistentReport
!MmsReportSubscriptionPlanner.IsExplicitlyEnabled(rcb) &&
!MmsReportSubscriptionPlanner.IsReservedByOtherClient(rcb))
{
- reservationStep = await WriteReportAttributeAsync(
- rcb,
- "ResvTms",
- MmsDataValue.Unsigned(60),
+ reservationStep = await WriteReportSemanticAttributeAsync(
+ rcb, MmsReportControlSemanticWrite.ReservationTime(60),
cancellationToken).ConfigureAwait(false);
if (!reservationStep.IsSuccess)
@@ -66,10 +64,8 @@ public async Task StartPersistentReport
if (reservationStep?.IsSuccess == true && rcb is not null)
{
- var release = await TryWriteReportAttributeForCleanupAsync(
- rcb,
- "ResvTms",
- MmsDataValue.Unsigned(0),
+ var release = await TryWriteReportSemanticAttributeForCleanupAsync(
+ rcb, MmsReportControlSemanticWrite.ReservationTime(0),
CancellationToken.None).ConfigureAwait(false);
cleanupSteps.Add(release);
cleanupAttempted = true;
@@ -95,10 +91,8 @@ public async Task StartPersistentReport
if (triggerGeneralInterrogation)
{
- var gi = await WriteReportAttributeAsync(
- start.Session.ReportControl,
- "GI",
- MmsDataValue.Boolean(true),
+ var gi = await WriteReportSemanticAttributeAsync(
+ start.Session.ReportControl, MmsReportControlSemanticWrite.GeneralInterrogation(true),
cancellationToken).ConfigureAwait(false);
writes.Add(gi);
if (!gi.IsSuccess)
diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs
index d916a64f..aed0a3a3 100644
--- a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs
+++ b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs
@@ -22,6 +22,15 @@ public sealed class MmsRcbAvailabilityOptions
{
public int MaxReportControls { get; init; } = 512;
public bool ReadDataSetDirectories { get; init; } = true;
+
+ ///
+ /// Optional exact live RCB references to probe. Empty means broad diagnostic mode.
+ /// Runtime acquisition hot paths should supply only the concrete RCB instances that
+ /// canonical coverage/family resolution has already selected.
+ ///
+ public IReadOnlySet TargetReportControlReferences { get; init; }
+ = new HashSet(StringComparer.OrdinalIgnoreCase);
+
public IReadOnlySet CallerOwnedRcbReferences { get; init; }
= new HashSet(StringComparer.OrdinalIgnoreCase);
}
@@ -70,10 +79,22 @@ public sealed class MmsRcbAvailabilityResult
public IReadOnlyList ReportControls { get; init; } = Array.Empty();
public IReadOnlyList Warnings { get; init; } = Array.Empty();
+ public int InventoryReportControlCount { get; init; }
+ public bool TargetFilterApplied { get; init; }
+ public int RequestedTargetReportControlCount { get; init; }
+ public int MatchedTargetReportControlCount { get; init; }
+ public int TargetedRcbStateLogicalReadCount { get; init; }
+ public int DataSetDirectoryNetworkReadCount { get; init; }
+ public int DataSetDirectoryCacheHitCount { get; init; }
+
public int AvailableCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.Available);
public int InUseCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.InUse);
public int UnknownCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.Unknown);
- public string Summary => $"RCB availability checked: total={ReportControls.Count}, available={AvailableCount}, in-use={InUseCount}, unknown={UnknownCount}.";
+ public string Summary =>
+ $"RCB availability checked: inventory={InventoryReportControlCount}, probed={ReportControls.Count}, " +
+ $"targeted={TargetFilterApplied.ToString().ToLowerInvariant()}, available={AvailableCount}, in-use={InUseCount}, unknown={UnknownCount}, " +
+ $"targetedRcbStateReads={(TargetFilterApplied ? TargetedRcbStateLogicalReadCount.ToString() : "n/a")}, " +
+ $"datasetReads={DataSetDirectoryNetworkReadCount}, datasetCacheHits={DataSetDirectoryCacheHitCount}.";
}
public static class MmsRcbAvailabilityEvaluator
diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs b/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs
new file mode 100644
index 00000000..4963505c
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs
@@ -0,0 +1,75 @@
+namespace AR.Iec61850.Mms;
+
+public sealed class MmsRcbAvailabilitySelection
+{
+ public IReadOnlyList Candidates { get; init; } = Array.Empty();
+ public bool TargetFilterApplied { get; init; }
+ public int RequestedTargetCount { get; init; }
+ public int MatchedTargetCount { get; init; }
+ public int EligibleCountBeforeLimit { get; init; }
+ public IReadOnlyList Warnings { get; init; } = Array.Empty();
+}
+
+///
+/// Pure candidate selector for availability probing.
+///
+/// Exact target references are never broadened to family/neighbor RCBs. Family resolution
+/// belongs to the canonical/report-family planner before this stage. This keeps the network
+/// hot path proportional to the RCBs that can actually serve the selected signals.
+///
+public static class MmsRcbAvailabilityTargetSelector
+{
+ public static MmsRcbAvailabilitySelection Select(
+ MmsReportInventory inventory,
+ MmsRcbAvailabilityOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(inventory);
+ ArgumentNullException.ThrowIfNull(options);
+
+ var warnings = new List();
+ var targets = options.TargetReportControlReferences
+ .Select(MmsRcbAvailabilityEvaluator.NormalizeReference)
+ .Where(reference => reference.Length > 0)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var targeted = targets.Count > 0;
+
+ IEnumerable eligible = inventory.ReportControls;
+ if (targeted)
+ {
+ eligible = eligible.Where(candidate =>
+ targets.Contains(MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference)));
+ }
+
+ var eligibleArray = eligible.ToArray();
+ var matched = targeted ? eligibleArray.Length : 0;
+ if (targeted && matched < targets.Count)
+ {
+ warnings.Add(
+ $"Targeted RCB availability matched {matched} of {targets.Count} requested exact live reference(s); " +
+ "unmatched targets were not broadened to unrelated RCBs.");
+ }
+
+ var max = Math.Clamp(options.MaxReportControls, 1, 4096);
+ var candidates = eligibleArray
+ .OrderByDescending(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference))
+ .ThenByDescending(candidate => candidate.Buffered)
+ .ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(candidate => candidate.LogicalNode, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase)
+ .Take(max)
+ .ToArray();
+
+ if (eligibleArray.Length > candidates.Length)
+ warnings.Add($"Availability check was bounded to {candidates.Length} of {eligibleArray.Length} eligible RCBs.");
+
+ return new MmsRcbAvailabilitySelection
+ {
+ Candidates = candidates,
+ TargetFilterApplied = targeted,
+ RequestedTargetCount = targets.Count,
+ MatchedTargetCount = matched,
+ EligibleCountBeforeLimit = eligibleArray.Length,
+ Warnings = warnings
+ };
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs
index 052909bc..402ad4fc 100644
--- a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs
+++ b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs
@@ -25,6 +25,7 @@ public sealed class MmsReceiveRouter
private readonly Queue _unconfirmed = new();
private readonly Queue _unmatched = new();
private readonly Dictionary> _informationReportSubscriptions = new();
+ private readonly SemaphoreSlim _informationReportAvailable = new(0, 1);
public int QueuedConfirmedResultCount
{
@@ -88,6 +89,7 @@ public MmsReceiveRouteResult Route(ReadOnlyMemory presentationPayload)
if (envelope.IsInformationReport)
{
_informationReports.Enqueue(envelope);
+ SignalInformationReportAvailable();
foreach (var subscription in _informationReportSubscriptions.Values)
subscription.Writer.TryWrite(envelope);
@@ -152,6 +154,31 @@ public bool TryDequeueInformationReport(out MmsPduEnvelope envelope)
return false;
}
+ ///
+ /// Waits until the canonical InformationReport queue has work without polling.
+ /// The signal is binary: multiple reports coalesce into one wake because callers
+ /// drain the queue after waking. Queue contents remain the single routing authority.
+ ///
+ internal async Task WaitForInformationReportAsync(
+ TimeSpan timeout,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (QueuedInformationReportCount > 0)
+ {
+ _informationReportAvailable.Wait(0);
+ return true;
+ }
+
+ if (timeout <= TimeSpan.Zero)
+ return false;
+
+ return await _informationReportAvailable
+ .WaitAsync(timeout, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
internal MmsInformationReportSubscription SubscribeInformationReports(int capacity = 32)
{
var boundedCapacity = Math.Clamp(capacity, 1, 1024);
@@ -197,6 +224,18 @@ internal void FaultInformationReportSubscriptions(Exception exception)
subscription.Writer.TryComplete(exception);
}
+ private void SignalInformationReportAvailable()
+ {
+ try
+ {
+ _informationReportAvailable.Release();
+ }
+ catch (SemaphoreFullException)
+ {
+ // A wake is already pending. The queue remains authoritative and will be drained.
+ }
+ }
+
public void Clear()
{
Channel[] subscriptions;
@@ -210,6 +249,10 @@ public void Clear()
_informationReportSubscriptions.Clear();
}
+ while (_informationReportAvailable.Wait(0))
+ {
+ }
+
foreach (var subscription in subscriptions)
subscription.Writer.TryComplete(new IOException("MMS association receive routing was reset."));
}
diff --git a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs
new file mode 100644
index 00000000..653559b3
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs
@@ -0,0 +1,365 @@
+using System.Collections.Concurrent;
+
+namespace AR.Iec61850.Mms;
+
+///
+/// Binds a well-known IEC 61850 Report Control Block scalar field to the exact MMS
+/// value kind used on the wire. This is the semantic boundary between reporting
+/// policy and MMS encoding: callers request an IEC field/value, not a raw MMS scalar.
+///
+/// The contract intentionally covers only scalar mutation fields whose semantics are
+/// stable across BRCB/URCB implementations. Bit-string and object-reference fields keep
+/// their dedicated codecs.
+///
+public readonly record struct MmsReportControlSemanticWrite
+{
+ private MmsReportControlSemanticWrite(
+ string attribute,
+ MmsDataValue value,
+ MmsDataKind expectedKind,
+ string expectedMmsType)
+ {
+ Attribute = attribute;
+ Value = value;
+ ExpectedKind = expectedKind;
+ ExpectedMmsType = expectedMmsType;
+ }
+
+ public string Attribute { get; }
+ public MmsDataValue Value { get; }
+ public MmsDataKind ExpectedKind { get; }
+ public string ExpectedMmsType { get; }
+
+ public static MmsReportControlSemanticWrite ReportEnable(bool enabled)
+ => Boolean("RptEna", enabled);
+
+ public static MmsReportControlSemanticWrite GeneralInterrogation(bool requested)
+ => Boolean("GI", requested);
+
+ public static MmsReportControlSemanticWrite Reservation(bool reserved)
+ => Boolean("Resv", reserved);
+
+ ///
+ /// IEC 61850 BRCB ResvTms is mapped to MMS INTEGER with signed INT16 wire semantics.
+ /// Policy about particular reservation-time values belongs above this codec; this
+ /// boundary validates only the standardized scalar representation and width.
+ ///
+ public static MmsReportControlSemanticWrite ReservationTime(int seconds)
+ {
+ if (seconds is < short.MinValue or > short.MaxValue)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(seconds),
+ seconds,
+ $"BRCB ResvTms must fit the signed IEC INT16 range {short.MinValue}..{short.MaxValue}.");
+ }
+
+ return new MmsReportControlSemanticWrite(
+ "ResvTms",
+ MmsDataValue.Integer(seconds),
+ MmsDataKind.Integer,
+ "integer");
+ }
+
+ public bool IsCompatibleWith(MmsTypeSpecificationNode? liveType)
+ => liveType is not null &&
+ Value.Kind == ExpectedKind &&
+ string.Equals(liveType.MmsType, ExpectedMmsType, StringComparison.OrdinalIgnoreCase);
+
+ private static MmsReportControlSemanticWrite Boolean(string attribute, bool value)
+ => new(
+ attribute,
+ MmsDataValue.Boolean(value),
+ MmsDataKind.Boolean,
+ "boolean");
+}
+
+public enum MmsReportSemanticTypeEvidenceStatus
+{
+ NotChecked,
+ ExactMatch,
+ ExactMismatch,
+ Unavailable
+}
+
+///
+/// Evidence for one concrete RCB scalar field on the current MMS association.
+/// A successful live GetVariableAccessAttributes result is exact protocol evidence;
+/// a failed/unsupported probe remains missing evidence and is not converted into a
+/// false blocker. An exact contradiction is a positive blocker and prevents mutation.
+///
+public sealed class MmsReportSemanticTypeEvidence
+{
+ public MmsReportSemanticTypeEvidenceStatus Status { get; init; }
+ public string Reference { get; init; } = string.Empty;
+ public string Attribute { get; init; } = string.Empty;
+ public string ExpectedMmsType { get; init; } = string.Empty;
+ public string LiveMmsType { get; init; } = string.Empty;
+ public string Source { get; init; } = string.Empty;
+ public string Message { get; init; } = string.Empty;
+
+ public bool IsExact => Status is
+ MmsReportSemanticTypeEvidenceStatus.ExactMatch or
+ MmsReportSemanticTypeEvidenceStatus.ExactMismatch;
+
+ public bool AllowsMutation => Status != MmsReportSemanticTypeEvidenceStatus.ExactMismatch;
+}
+
+public static class MmsReportSemanticTypePolicy
+{
+ public static MmsReportSemanticTypeEvidence Evaluate(
+ MmsReportControlSemanticWrite write,
+ string reference,
+ MmsVariableAccessAttributesResult? live)
+ {
+ if (live?.IsSuccess != true || live.TypeSpecification is null)
+ {
+ return new MmsReportSemanticTypeEvidence
+ {
+ Status = MmsReportSemanticTypeEvidenceStatus.Unavailable,
+ Reference = reference,
+ Attribute = write.Attribute,
+ ExpectedMmsType = write.ExpectedMmsType,
+ LiveMmsType = live?.MmsType ?? string.Empty,
+ Source = live?.Source ?? "IEC semantic contract",
+ Message = live is null
+ ? "No live TypeSpecification probe was available; the IEC semantic contract remains the write authority."
+ : $"Live TypeSpecification was unavailable ({live.Message}); the IEC semantic contract remains the write authority."
+ };
+ }
+
+ var exactMatch = write.IsCompatibleWith(live.TypeSpecification);
+ return new MmsReportSemanticTypeEvidence
+ {
+ Status = exactMatch
+ ? MmsReportSemanticTypeEvidenceStatus.ExactMatch
+ : MmsReportSemanticTypeEvidenceStatus.ExactMismatch,
+ Reference = reference,
+ Attribute = write.Attribute,
+ ExpectedMmsType = write.ExpectedMmsType,
+ LiveMmsType = live.MmsType,
+ Source = live.Source,
+ Message = exactMatch
+ ? $"Live MMS TypeSpecification confirms {write.Attribute} as {live.MmsType}."
+ : $"Live MMS TypeSpecification reports {write.Attribute} as {live.MmsType}, conflicting with IEC semantic type {write.ExpectedMmsType}."
+ };
+ }
+}
+
+public sealed partial class MmsClientSession
+{
+ private readonly ConcurrentDictionary _reportSemanticTypeEvidence =
+ new(StringComparer.Ordinal);
+
+ ///
+ /// Association-stable type evidence must never cross an MMS re-association.
+ /// Volatile RCB state is intentionally not cached here.
+ ///
+ private void ResetReportSemanticTypeEvidence()
+ => _reportSemanticTypeEvidence.Clear();
+
+ ///
+ /// Primes multiple scalar-field type contracts from one complete RCB
+ /// GetVariableAccessAttributes response when the server exposes named structure
+ /// components. This is an optimization only: missing/unsupported structure evidence
+ /// leaves the normal exact-leaf preflight unchanged.
+ ///
+ private async Task PrimeReportSemanticTypeEvidenceFromStructureAsync(
+ MmsReportControlCandidate rcb,
+ IReadOnlyCollection writes,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(rcb);
+ ArgumentNullException.ThrowIfNull(writes);
+
+ var pending = writes
+ .DistinctBy(write => write.Attribute, StringComparer.OrdinalIgnoreCase)
+ .Where(write =>
+ {
+ var reference = BuildReportAttributeReference(rcb, write.Attribute);
+ return !_reportSemanticTypeEvidence.ContainsKey(ReportSemanticTypeCacheKey(reference));
+ })
+ .ToArray();
+
+ // A single field gains nothing from a structure probe because its normal leaf
+ // preflight costs the same one confirmed service request.
+ if (pending.Length < 2)
+ return;
+
+ MmsVariableAccessAttributesResult structure;
+ try
+ {
+ var baseReference = MmsObjectReference.Parse(rcb.Reference, rcb.FunctionalConstraint);
+ structure = await GetVariableAccessAttributesAsync(
+ baseReference,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ return;
+ }
+
+ if (!structure.IsSuccess ||
+ structure.TypeSpecification is not { MmsType: "structure" } type ||
+ type.Children.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var write in pending)
+ {
+ var child = type.Children.FirstOrDefault(component =>
+ string.Equals(component.Name, write.Attribute, StringComparison.OrdinalIgnoreCase));
+ if (child is null)
+ continue;
+
+ var reference = BuildReportAttributeReference(rcb, write.Attribute);
+ var live = new MmsVariableAccessAttributesResult
+ {
+ IsSuccess = true,
+ Reference = reference,
+ TypeSpecification = child,
+ Source = "GetVariableAccessAttributes(RCB structure)",
+ Message = $"RCB structure TypeSpecification supplied exact component {write.Attribute}:{child.MmsType}."
+ };
+ var evidence = MmsReportSemanticTypePolicy.Evaluate(
+ write,
+ reference.ToString(),
+ live);
+
+ if (evidence.IsExact)
+ _reportSemanticTypeEvidence[ReportSemanticTypeCacheKey(reference)] = evidence;
+ }
+ }
+
+ ///
+ /// Normal RCB scalar mutation path. The first mutation of each exact field on an
+ /// association performs a bounded exact GetVariableAccessAttributes check. Exact
+ /// matching evidence is cached for that association; missing evidence does not become
+ /// a false blocker, while an exact type contradiction fails closed before mutation.
+ ///
+ private async Task WriteReportSemanticAttributeAsync(
+ MmsReportControlCandidate rcb,
+ MmsReportControlSemanticWrite write,
+ CancellationToken cancellationToken)
+ {
+ var reference = BuildReportAttributeReference(rcb, write.Attribute);
+ var typeEvidence = await GetReportSemanticTypeEvidenceAsync(
+ reference,
+ write,
+ forceRefresh: false,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!typeEvidence.AllowsMutation)
+ {
+ return new MmsReportAttributeWriteStep
+ {
+ Attribute = write.Attribute,
+ Reference = reference.ToString(),
+ Attempted = false,
+ IsSuccess = false,
+ TypeEvidence = typeEvidence,
+ Message = $"RCB write blocked before mutation: {typeEvidence.Message}"
+ };
+ }
+
+ var step = await WriteReportAttributeAsync(
+ rcb,
+ write.Attribute,
+ write.Value,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!step.IsSuccess &&
+ step.FailureKind == MmsInteropFailureKind.TypeMismatch &&
+ IsMmsInitiated)
+ {
+ typeEvidence = await GetReportSemanticTypeEvidenceAsync(
+ reference,
+ write,
+ forceRefresh: true,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ return WithSemanticTypeEvidence(step, typeEvidence);
+ }
+
+ ///
+ /// Cleanup never adds a new network preflight. It reuses any exact type evidence
+ /// already learned on this association and otherwise uses the standardized semantic
+ /// encoder so cleanup is not delayed by optional diagnostics.
+ ///
+ private async Task TryWriteReportSemanticAttributeForCleanupAsync(
+ MmsReportControlCandidate rcb,
+ MmsReportControlSemanticWrite write,
+ CancellationToken cancellationToken)
+ {
+ var reference = BuildReportAttributeReference(rcb, write.Attribute);
+ _reportSemanticTypeEvidence.TryGetValue(ReportSemanticTypeCacheKey(reference), out var typeEvidence);
+
+ var step = await TryWriteReportAttributeForCleanupAsync(
+ rcb,
+ write.Attribute,
+ write.Value,
+ cancellationToken).ConfigureAwait(false);
+
+ return typeEvidence is null ? step : WithSemanticTypeEvidence(step, typeEvidence);
+ }
+
+ private async Task GetReportSemanticTypeEvidenceAsync(
+ MmsObjectReference reference,
+ MmsReportControlSemanticWrite write,
+ bool forceRefresh,
+ CancellationToken cancellationToken)
+ {
+ var key = ReportSemanticTypeCacheKey(reference);
+ if (!forceRefresh && _reportSemanticTypeEvidence.TryGetValue(key, out var cached))
+ return cached;
+
+ MmsVariableAccessAttributesResult? live = null;
+ try
+ {
+ live = await GetVariableAccessAttributesAsync(reference, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ live = new MmsVariableAccessAttributesResult
+ {
+ IsSuccess = false,
+ Reference = reference,
+ Source = "GetVariableAccessAttributes",
+ Message = $"Type preflight failed safely: {ex.GetType().Name}: {ex.Message}"
+ };
+ }
+
+ var evidence = MmsReportSemanticTypePolicy.Evaluate(write, reference.ToString(), live);
+ if (evidence.IsExact)
+ _reportSemanticTypeEvidence[key] = evidence;
+
+ return evidence;
+ }
+
+ private static MmsObjectReference BuildReportAttributeReference(
+ MmsReportControlCandidate rcb,
+ string attribute)
+ => MmsObjectReference.Parse($"{rcb.Reference}.{attribute}", rcb.FunctionalConstraint);
+
+ private static string ReportSemanticTypeCacheKey(MmsObjectReference reference)
+ => $"{reference.Domain}/{reference.Item}";
+
+ private static MmsReportAttributeWriteStep WithSemanticTypeEvidence(
+ MmsReportAttributeWriteStep step,
+ MmsReportSemanticTypeEvidence typeEvidence)
+ => new()
+ {
+ Attribute = step.Attribute,
+ Reference = step.Reference,
+ Attempted = step.Attempted,
+ IsSuccess = step.IsSuccess,
+ FailureCode = step.FailureCode,
+ FailureName = step.FailureName,
+ FailureKind = step.FailureKind,
+ RecoveryHint = step.RecoveryHint,
+ TypeEvidence = typeEvidence,
+ Message = $"{step.Message} Type evidence: {typeEvidence.Message}"
+ };
+}
diff --git a/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs b/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs
index 6c8a9b5b..8f4d9733 100644
--- a/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs
+++ b/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs
@@ -12,7 +12,10 @@ public static MmsReportInventory BuildInventory(MmsDiscoverySnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
- var inventory = new MmsReportInventory();
+ var inventory = new MmsReportInventory
+ {
+ Authority = MmsReportInventoryAuthority.LiveMmsObserved
+ };
inventory.DataSets.AddRange(BuildDataSets(snapshot.DomainVariableLists));
inventory.ReportControls.AddRange(BuildReportControls(snapshot.DomainVariables));
return inventory;
diff --git a/src/AR.Iec61850/Mms/MmsReportInventory.cs b/src/AR.Iec61850/Mms/MmsReportInventory.cs
index 9bc43697..4bab6819 100644
--- a/src/AR.Iec61850/Mms/MmsReportInventory.cs
+++ b/src/AR.Iec61850/Mms/MmsReportInventory.cs
@@ -1,5 +1,13 @@
namespace AR.Iec61850.Mms;
+public enum MmsReportInventoryAuthority
+{
+ Unknown,
+ LiveMmsObserved,
+ SclDesignProjection,
+ CanonicalModelProjection
+}
+
public enum MmsRcbDataSetProbeState
{
NotAttempted,
@@ -47,6 +55,11 @@ public sealed class MmsReportControlCandidate
public sealed class MmsReportInventory
{
+ ///
+ /// Provenance of the inventory itself. Operational planners must not infer live
+ /// authority merely from the presence of plausible RCB names or DataSet bindings.
+ ///
+ public MmsReportInventoryAuthority Authority { get; set; } = MmsReportInventoryAuthority.Unknown;
public List DataSets { get; } = new();
public List ReportControls { get; } = new();
diff --git a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs
index 64dbbc2b..3239a7c6 100644
--- a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs
+++ b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs
@@ -6,6 +6,11 @@ public sealed class MmsReportAttributeWriteStep
public string Reference { get; init; } = string.Empty;
public bool Attempted { get; init; }
public bool IsSuccess { get; init; }
+ public int? FailureCode { get; init; }
+ public string FailureName { get; init; } = string.Empty;
+ public MmsInteropFailureKind FailureKind { get; init; } = MmsInteropFailureKind.None;
+ public MmsInteropRecoveryHint RecoveryHint { get; init; } = MmsInteropRecoveryHint.None;
+ public MmsReportSemanticTypeEvidence? TypeEvidence { get; init; }
public string Message { get; init; } = string.Empty;
}
@@ -226,12 +231,16 @@ public sealed class MmsReportRcbSnapshot
public string Message { get; init; } = string.Empty;
public string Reference { get; init; } = string.Empty;
public string Mode { get; init; } = string.Empty;
+ public bool Buffered { get; init; }
public string DataSetReference { get; init; } = string.Empty;
+ public MmsRcbDataSetProbeState DataSetProbeState { get; init; } = MmsRcbDataSetProbeState.NotAttempted;
+ public string DataSetProbeMessage { get; init; } = string.Empty;
public string ReportId { get; init; } = string.Empty;
public string ConfRev { get; init; } = string.Empty;
public string EnabledState { get; init; } = string.Empty;
public string ReservationState { get; init; } = string.Empty;
public string ReservationTimeSeconds { get; init; } = string.Empty;
+ public string Owner { get; init; } = string.Empty;
public string BufferTimeMs { get; init; } = string.Empty;
public string IntegrityPeriodMs { get; init; } = string.Empty;
public string TriggerOptions { get; init; } = string.Empty;
@@ -251,12 +260,16 @@ internal static MmsReportRcbSnapshot FromCandidate(string stage, MmsReportContro
Message = message,
Reference = candidate.Reference,
Mode = candidate.Mode,
+ Buffered = candidate.Buffered,
DataSetReference = candidate.DataSetReference,
+ DataSetProbeState = candidate.DataSetProbeState,
+ DataSetProbeMessage = candidate.DataSetProbeMessage,
ReportId = candidate.ReportId,
ConfRev = candidate.ConfRev,
EnabledState = candidate.EnabledState,
ReservationState = candidate.ReservationState,
ReservationTimeSeconds = candidate.ReservationTimeSeconds,
+ Owner = candidate.Owner,
BufferTimeMs = candidate.BufferTimeMs,
IntegrityPeriodMs = candidate.IntegrityPeriodMs,
TriggerOptions = candidate.TriggerOptions,
@@ -1084,14 +1097,14 @@ public async Task RunGuardedStaticReportSessionAsy
}
else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase))
{
- var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false);
+ var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false);
writes.Add(reserve);
reservationTouched = true;
if (!reserve.IsSuccess)
warnings.Add("URCB Resv write failed. Proceeding guarded only if RptEna is accepted by the IED.");
}
- var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false);
+ var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false);
writes.Add(enable);
enabledByThisClient = enable.IsSuccess;
if (!enable.IsSuccess)
@@ -1113,7 +1126,7 @@ public async Task RunGuardedStaticReportSessionAsy
if (triggerGeneralInterrogation)
{
- var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false);
+ var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(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.");
@@ -1126,7 +1139,7 @@ public async Task RunGuardedStaticReportSessionAsy
{
periodicGiWriter = async token =>
{
- var step = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), token).ConfigureAwait(false);
+ var step = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), token).ConfigureAwait(false);
return new MmsReportAttributeWriteStep
{
Attribute = "GI(periodic)",
@@ -1158,7 +1171,7 @@ public async Task RunGuardedStaticReportSessionAsy
{
if (enabledByThisClient)
{
- var disable = await TryWriteReportAttributeForCleanupAsync(rcb, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false);
+ var disable = await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false);
writes.Add(disable);
if (!disable.IsSuccess)
verificationChecks.Add(FailCheck("after-cleanup", $"{rcb.Reference}.RptEna", "write false accepted", disable.Message, "RptEna=false cleanup write failed."));
@@ -1171,8 +1184,8 @@ public async Task RunGuardedStaticReportSessionAsy
if (reservationTouched)
{
var release = rcb.Buffered
- ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false)
- : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false);
+ ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false)
+ : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false);
writes.Add(release);
}
}
@@ -1273,7 +1286,7 @@ public async Task RunGuardedDynamicReportSessionAs
}
else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase))
{
- var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false);
+ var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false);
writes.Add(reserve);
reservationTouched = true;
if (!reserve.IsSuccess)
@@ -1300,7 +1313,7 @@ public async Task RunGuardedDynamicReportSessionAs
rcbSnapshots.Add(afterBindSnapshot);
AddRcbStateChecks(verificationChecks, afterBindSnapshot, expectedRptEna: false, expectedDataSet: plan.DataSetReference, stage: "after-bind");
- var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false);
+ var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false);
writes.Add(enable);
enabledByThisClient = enable.IsSuccess;
if (!enable.IsSuccess)
@@ -1322,7 +1335,7 @@ public async Task RunGuardedDynamicReportSessionAs
if (triggerGeneralInterrogation)
{
- var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false);
+ var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(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.");
@@ -1336,7 +1349,7 @@ public async Task RunGuardedDynamicReportSessionAs
{
if (enabledByThisClient)
{
- var disable = await TryWriteReportAttributeForCleanupAsync(rcb, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false);
+ var disable = await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false);
writes.Add(disable);
if (!disable.IsSuccess)
verificationChecks.Add(FailCheck("after-cleanup", $"{rcb.Reference}.RptEna", "write false accepted", disable.Message, "RptEna=false cleanup write failed."));
@@ -1360,8 +1373,8 @@ public async Task RunGuardedDynamicReportSessionAs
if (reservationTouched)
{
var release = rcb.Buffered
- ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false)
- : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false);
+ ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false)
+ : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false);
writes.Add(release);
}
@@ -1917,14 +1930,20 @@ private async Task WriteReportAttributeAsync(
MmsDataValue value,
CancellationToken cancellationToken)
{
- var reference = MmsObjectReference.Parse($"{rcb.Reference}.{attribute}", rcb.FunctionalConstraint);
+ var reference = BuildReportAttributeReference(rcb, attribute);
var result = await WriteSingleVariableAsync(reference, value, cancellationToken).ConfigureAwait(false);
+ var failure = result.AccessResults.FirstOrDefault(access => !access.IsSuccess);
+ var classification = MmsInteropFailureClassifier.Classify(failure);
return new MmsReportAttributeWriteStep
{
Attribute = attribute,
Reference = reference.ToString(),
Attempted = true,
IsSuccess = result.IsSuccess,
+ FailureCode = failure?.FailureCode,
+ FailureName = failure?.FailureName ?? string.Empty,
+ FailureKind = classification.Kind,
+ RecoveryHint = classification.RecoveryHint,
Message = result.Message
};
}
@@ -1950,6 +1969,11 @@ private async Task TryWriteReportAttributeForCleanu
Reference = first.Reference,
Attempted = true,
IsSuccess = false,
+ FailureCode = first.FailureCode,
+ FailureName = first.FailureName,
+ FailureKind = first.FailureKind,
+ RecoveryHint = first.RecoveryHint,
+ TypeEvidence = first.TypeEvidence,
Message = $"cleanup reconnect failed. First attempt: {first.Message}"
};
}
@@ -1961,6 +1985,11 @@ private async Task TryWriteReportAttributeForCleanu
Reference = retry.Reference,
Attempted = true,
IsSuccess = retry.IsSuccess,
+ FailureCode = retry.FailureCode,
+ FailureName = retry.FailureName,
+ FailureKind = retry.FailureKind,
+ RecoveryHint = retry.RecoveryHint,
+ TypeEvidence = retry.TypeEvidence,
Message = retry.IsSuccess
? $"cleanup retry after reconnect succeeded. First attempt: {first.Message}"
: $"cleanup retry after reconnect failed: {retry.Message}. First attempt: {first.Message}"
@@ -1979,6 +2008,11 @@ private async Task TryWriteReportAttributeForCleanu
Reference = retry.Reference,
Attempted = true,
IsSuccess = retry.IsSuccess,
+ FailureCode = retry.FailureCode,
+ FailureName = retry.FailureName,
+ FailureKind = retry.FailureKind,
+ RecoveryHint = retry.RecoveryHint,
+ TypeEvidence = retry.TypeEvidence,
Message = retry.IsSuccess
? $"cleanup retry after reconnect succeeded. First exception: {ex.GetType().Name}: {ex.Message}"
: $"cleanup retry after reconnect failed: {retry.Message}. First exception: {ex.GetType().Name}: {ex.Message}"
diff --git a/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs b/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs
index 8ad5286d..c100a44b 100644
--- a/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs
+++ b/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs
@@ -11,6 +11,11 @@ public sealed class MmsReportSessionDiagnostics
public int ValueCount { get; init; }
public int WriteStepCount { get; init; }
public int WriteFailureCount { get; init; }
+ public int WritePreflightBlockedCount { get; init; }
+ public int WriteTypeMismatchCount { get; init; }
+ public int WriteAccessDeniedCount { get; init; }
+ public int WriteTypeEvidenceConflictCount { get; init; }
+ public int WriteTypeEvidenceUnavailableCount { get; init; }
public int PollReadCount { get; init; }
public int PollReadSuccessCount { get; init; }
public int PollReadFailureCount { get; init; }
@@ -67,7 +72,8 @@ public IReadOnlyList WarningMessages
public string Summary =>
$"diagnostics={OverallStatus}, reports={ReportCount}, values={ValueCount}, mappedFailures={MappingFailureCount}, partialMappings={PartialMappingFailureCount}, " +
- $"pollReads={PollReadSuccessCount}/{PollReadCount}, writeFailures={WriteFailureCount}, " +
+ $"pollReads={PollReadSuccessCount}/{PollReadCount}, writeFailures={WriteFailureCount}, preflightBlocked={WritePreflightBlockedCount}, " +
+ $"typeMismatch={WriteTypeMismatchCount}, typeEvidenceConflict={WriteTypeEvidenceConflictCount}, " +
$"seqGaps={SequenceGapCount}, seqResets={SequenceResetCount}, seqRegressions={SequenceRegressionCount}, " +
$"entryIdGaps={EntryIdGapCount}, entryIdRegressions={EntryIdRegressionCount}, " +
$"duplicates={DuplicateReportKeyCount}, bufOvfl={BufferOverflowObserved.ToString().ToLowerInvariant()}";
@@ -177,6 +183,13 @@ public static MmsReportSessionDiagnostics Analyze(
ValueCount = reports.Sum(x => x.Values.Count),
WriteStepCount = writeSteps.Count,
WriteFailureCount = writeSteps.Count(x => !x.IsSuccess),
+ WritePreflightBlockedCount = writeSteps.Count(x => !x.Attempted && !x.IsSuccess),
+ WriteTypeMismatchCount = writeSteps.Count(x => x.FailureKind == MmsInteropFailureKind.TypeMismatch),
+ WriteAccessDeniedCount = writeSteps.Count(x => x.FailureKind == MmsInteropFailureKind.AccessDenied),
+ WriteTypeEvidenceConflictCount = writeSteps.Count(x =>
+ x.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.ExactMismatch),
+ WriteTypeEvidenceUnavailableCount = writeSteps.Count(x =>
+ x.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.Unavailable),
PollReadCount = pollReads.Count,
PollReadSuccessCount = pollReads.Count(x => x.IsSuccess),
PollReadFailureCount = pollReads.Count(x => !x.IsSuccess),
diff --git a/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs b/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs
index 1798bfe9..30fe9d6e 100644
--- a/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs
+++ b/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs
@@ -47,7 +47,7 @@ public SclEngineeringProfile Build(XDocument xdoc, string sourceName = "")
LogicalDeviceCount = logicalDevices.Count(ld => Same(ld.IedName, i.Name))
}).ToList();
- AddStaticFindings(scl, accessPoints, logicalDevices, logicalNodes, externalReferences, findings);
+ AddStaticFindings(root, scl, accessPoints, logicalDevices, logicalNodes, externalReferences, findings);
var capabilities = new SclEngineeringCapabilityMatrix
{
@@ -104,13 +104,18 @@ private static IEnumerable ParseAccessPoints(XElement
foreach (var ap in ied.Elements().Where(e => Is(e, "AccessPoint")))
{
var server = ap.Elements().FirstOrDefault(e => Is(e, "Server"));
+ var owner = server is null
+ ? SclServerAtModelResolver.ResolveOwner(ied, ap)
+ : ap;
+ var effectiveServer = owner?.Elements().FirstOrDefault(e => Is(e, "Server"));
yield return new SclEngineeringAccessPoint
{
IedName = iedName,
Name = Attr(ap, "name"),
+ // HasServer describes a physically declared Server, not ServerAt.
HasServer = server is not null,
Router = Attr(ap, "router"),
- LogicalDeviceCount = server?.Elements().Count(e => Is(e, "LDevice")) ?? 0
+ LogicalDeviceCount = effectiveServer?.Elements().Count(e => Is(e, "LDevice")) ?? 0
};
}
}
@@ -230,6 +235,7 @@ private static HashSet ParseServiceDeclarations(XElement root)
}
private static void AddStaticFindings(
+ XElement root,
SclDocument document,
IReadOnlyList accessPoints,
IReadOnlyList logicalDevices,
@@ -244,7 +250,24 @@ private static void AddStaticFindings(
findings.Add(Finding("High", "SCL_NO_SERVER_MODEL", "No Server/LDevice model was found. The file cannot seed MMS model discovery or simulator model generation."));
foreach (var ap in accessPoints.Where(a => !a.HasServer))
- findings.Add(Finding("Warning", "SCL_ACCESS_POINT_WITHOUT_SERVER", $"AccessPoint {ap.IedName}/{ap.Name} has no Server element.", $"{ap.IedName}/{ap.Name}"));
+ {
+ var iedElement = root.Elements().FirstOrDefault(e =>
+ Is(e, "IED") && Same(Attr(e, "name"), ap.IedName));
+ var apElement = iedElement?.Elements().FirstOrDefault(e =>
+ Is(e, "AccessPoint") && Same(Attr(e, "name"), ap.Name));
+ var hasServerAt = apElement?.Elements().Any(e => Is(e, "ServerAt")) == true;
+ if (hasServerAt && iedElement is not null && apElement is not null &&
+ SclServerAtModelResolver.ResolveOwner(iedElement, apElement) is not null)
+ continue; // Valid ServerAt is not a broken/missing Server.
+
+ findings.Add(hasServerAt
+ ? Finding("High", "SCL_SERVER_AT_UNRESOLVED",
+ $"AccessPoint {ap.IedName}/{ap.Name} has a dangling, ambiguous or cyclic ServerAt reference.",
+ $"{ap.IedName}/{ap.Name}")
+ : Finding("Warning", "SCL_ACCESS_POINT_WITHOUT_SERVER",
+ $"AccessPoint {ap.IedName}/{ap.Name} has no Server or ServerAt element.",
+ $"{ap.IedName}/{ap.Name}"));
+ }
foreach (var ln in logicalNodes.Where(l => string.IsNullOrWhiteSpace(l.LnType)))
findings.Add(Finding("Warning", "SCL_LN_TYPE_MISSING", $"Logical node {ln.Reference} has no lnType binding.", ln.Reference));
diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs b/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs
index 0643b4b9..e1d35132 100644
--- a/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs
+++ b/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs
@@ -91,10 +91,11 @@ public static SclMmsDomainInventory Read(XDocument document, string iedName, str
return Build(canonicalIedName, accessPointName, domains, errors, warnings);
}
- var server = accessPoint.Elements().FirstOrDefault(element => Is(element, "Server"));
+ var serverOwner = SclServerAtModelResolver.ResolveOwner(ied, accessPoint);
+ var server = serverOwner?.Elements().FirstOrDefault(element => Is(element, "Server"));
if (server is null)
{
- errors.Add($"SCL IED '{canonicalIedName}' AccessPoint '{accessPointName}' has no direct Server model.");
+ errors.Add($"SCL IED '{canonicalIedName}' AccessPoint '{accessPointName}' has no resolvable Server/ServerAt model.");
return Build(canonicalIedName, accessPointName, domains, errors, warnings);
}
diff --git a/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs b/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs
index ed5a8f7a..b78c7db4 100644
--- a/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs
+++ b/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs
@@ -63,7 +63,11 @@ public static SclInitialFcReadDesign Read(XDocument document, string iedName, st
var selectedAp = selectedIed.Elements()
.Where(element => Is(element, "AccessPoint"))
.First(element => string.Equals(Attr(element, "name"), accessPointName, StringComparison.Ordinal));
- var server = selectedAp.Elements().First(element => Is(element, "Server"));
+ // Initial FC-read must use the same canonical ServerAt model authority as
+ // offline browsing. Keep the selected AP identity for its own MMS address.
+ var serverOwner = SclServerAtModelResolver.ResolveOwner(selectedIed, selectedAp);
+ var server = serverOwner?.Elements().FirstOrDefault(element => Is(element, "Server"))
+ ?? throw new InvalidDataException("Selected AccessPoint has no resolvable Server/ServerAt model.");
var domainByInst = new Dictionary(StringComparer.Ordinal);
foreach (var lDevice in server.Elements().Where(element => Is(element, "LDevice")))
@@ -107,6 +111,17 @@ public static SclInitialFcReadDesign Read(XDocument document, string iedName, st
continue;
}
+ var scopedSelectedAp = otherIed.Elements().First(element =>
+ Is(element, "AccessPoint") &&
+ string.Equals(Attr(element, "name"), accessPointName, StringComparison.Ordinal));
+ if (!scopedSelectedAp.Elements().Any(element => Is(element, "Server")))
+ {
+ // Only the disposable projection receives a cloned server. No source
+ // SCD change, no second server address, no cross-IED inheritance.
+ scopedSelectedAp.Elements().Where(element => Is(element, "ServerAt")).Remove();
+ scopedSelectedAp.AddFirst(new XElement(server));
+ }
+
foreach (var otherAp in otherIed.Elements().Where(element => Is(element, "AccessPoint")).ToArray())
{
if (!string.Equals(Attr(otherAp, "name"), accessPointName, StringComparison.Ordinal))
diff --git a/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs b/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs
new file mode 100644
index 00000000..cd1d963c
--- /dev/null
+++ b/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs
@@ -0,0 +1,52 @@
+using System.Xml.Linq;
+
+namespace AR.Iec61850.Scl;
+
+///
+/// Resolves an IEC 61850 ServerAt reference within the same IED. This is a
+/// model reference, not an alternate MMS address. Fail closed for dangling,
+/// ambiguous, or circular references; never bind another IED's server.
+///
+internal static class SclServerAtModelResolver
+{
+ public static XElement? ResolveOwner(XElement ied, XElement selectedAccessPoint)
+ {
+ var accessPoints = ied.Elements()
+ .Where(element => Is(element, "AccessPoint"))
+ .ToArray();
+ var visited = new HashSet();
+
+ var current = selectedAccessPoint;
+ while (visited.Add(current))
+ {
+ if (current.Elements().Any(element => Is(element, "Server")))
+ return current;
+
+ var references = current.Elements()
+ .Where(element => Is(element, "ServerAt"))
+ .ToArray();
+ if (references.Length != 1)
+ return null;
+
+ var referencedName = ((string?)references[0].Attribute("apName"))?.Trim();
+ if (string.IsNullOrWhiteSpace(referencedName))
+ return null;
+
+ var matches = accessPoints
+ .Where(element => string.Equals(
+ ((string?)element.Attribute("name"))?.Trim(),
+ referencedName,
+ StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ if (matches.Length != 1)
+ return null;
+
+ current = matches[0];
+ }
+
+ return null; // ServerAt cycle
+ }
+
+ private static bool Is(XElement element, string localName)
+ => element.Name.LocalName.Equals(localName, StringComparison.Ordinal);
+}
diff --git a/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs
index 1992f3b6..1f602e5a 100644
--- a/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs
+++ b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs
@@ -208,6 +208,25 @@ private static LiveIedModelDiscoveryDocument BuildDesignModel(
if (!string.IsNullOrWhiteSpace(accessPointName))
{
+ var selectedAccessPoint = selectedIed.Elements()
+ .FirstOrDefault(x => Is(x, "AccessPoint") && Same(Attr(x, "name"), accessPointName));
+ if (selectedAccessPoint is not null &&
+ !selectedAccessPoint.Elements().Any(x => Is(x, "Server")))
+ {
+ // ServerAt points at another AP's server model, while this AP keeps
+ // its own Communication/ConnectedAP address. Materialize that model
+ // ONLY in the isolated projection (never modify the source SCD).
+ // This allows an AP like Siemens 7SX85/F -> J to retain LD/LN,
+ // DataSets and RCBs without misrepresenting its MMS endpoint.
+ var owner = SclServerAtModelResolver.ResolveOwner(selectedIed, selectedAccessPoint);
+ var server = owner?.Elements().FirstOrDefault(x => Is(x, "Server"));
+ if (server is not null)
+ {
+ selectedAccessPoint.Elements().Where(x => Is(x, "ServerAt")).Remove();
+ selectedAccessPoint.AddFirst(new XElement(server));
+ }
+ }
+
foreach (var otherAccessPoint in selectedIed.Elements()
.Where(x => Is(x, "AccessPoint") && !Same(Attr(x, "name"), accessPointName))
.ToArray())
diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs
index 34364f1e..15d3b73d 100644
--- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs
+++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs
@@ -1,5 +1,6 @@
using AR.Iec61850.Discovery;
using AR.Iec61850.Engineering.Canonical;
+using AR.Iec61850.Scl.Workspace;
namespace AR.Iec61850.Tests.Engineering;
@@ -71,6 +72,39 @@ public void FromLiveDiscovery_Preserves_Identity_Provenance_And_Compacts_Repeate
Assert.Equal("192.0.2.10", model.Communication.Host.Value);
}
+ [Fact]
+ public void FromSclWorkspace_Preserves_Scl_Ingress_Provenance()
+ {
+ var workspace = new SclIedWorkspace
+ {
+ IedName = "IED_SCL",
+ AccessPointName = "P1",
+ DesignModel = new LiveIedModelDiscoveryDocument
+ {
+ Source = "SclDesign",
+ IedName = "IED_SCL",
+ AccessPointName = "P1",
+ DataSets =
+ [
+ new LiveIedDataSetModel
+ {
+ Reference = "IED_SCLLD0/LLN0$Events",
+ Domain = "IED_SCLLD0",
+ LogicalNode = "LLN0",
+ Name = "Events"
+ }
+ ]
+ }
+ };
+
+ var model = CanonicalLiveModelAdapter.FromSclWorkspace(workspace);
+
+ Assert.Equal(CanonicalIngressKind.SclFile, model.Source.Ingress);
+ Assert.Equal(CanonicalEvidenceSource.SclDeclared, model.Identity.Provenance.Source);
+ Assert.Equal("IED_SCL", model.Identity.Name);
+ Assert.Equal("IED_SCLLD0/LLN0.Events", model.DataSets.Single().Reference);
+ }
+
private static LiveIedDataAttributeModel Attribute(string reference, string path)
=> new()
{
diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs
new file mode 100644
index 00000000..6d3a7170
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs
@@ -0,0 +1,475 @@
+using System.Xml.Linq;
+using AR.Iec61850.Discovery;
+using AR.Iec61850.Engineering.Canonical;
+using AR.Iec61850.Scl.Engineering;
+
+namespace AR.Iec61850.Tests.Engineering;
+
+public sealed class CanonicalStaticReportCoverageResolverTests
+{
+ [Fact]
+ public void Discovery_And_Scl_Ingress_Use_The_Same_Static_Coverage_Contract()
+ {
+ var discovery = BuildCanonical();
+ var scl = WithIngress(discovery, CanonicalIngressKind.SclFile);
+
+ var selection = new[]
+ {
+ new CanonicalStaticReportSelection
+ {
+ Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f",
+ FunctionalConstraint = "MX"
+ }
+ };
+
+ var fromDiscovery = CanonicalStaticReportCoverageResolver.Resolve(discovery, selection);
+ var fromScl = CanonicalStaticReportCoverageResolver.Resolve(scl, selection);
+
+ Assert.Equal(CanonicalIngressKind.LiveMmsDiscovery, fromDiscovery.Ingress);
+ Assert.Equal(CanonicalIngressKind.SclFile, fromScl.Ingress);
+ Assert.Equal(fromDiscovery.CoveredSignalCount, fromScl.CoveredSignalCount);
+ Assert.Equal(
+ fromDiscovery.Signals[0].DataSetCandidates.Select(candidate => candidate.DataSetReference),
+ fromScl.Signals[0].DataSetCandidates.Select(candidate => candidate.DataSetReference));
+ Assert.Equal(
+ fromDiscovery.Signals[0].DataSetCandidates[0].ReportControlReferences,
+ fromScl.Signals[0].DataSetCandidates[0].ReportControlReferences);
+ }
+
+ [Fact]
+ public void Real_Discovery_And_OpenScl_Builders_Converge_On_Static_Coverage()
+ {
+ var discoveryModel = BuildStatusDiscoveryCanonical();
+ var imported = SclCanonicalImporter.Import(
+ XDocument.Parse(BuildEquivalentStatusScl()),
+ new SclCanonicalImportOptions
+ {
+ IedName = "IEDA",
+ AccessPointName = "P1",
+ SourceName = "equivalent.cid"
+ });
+
+ Assert.True(imported.IsSuccess, string.Join(" | ", imported.Errors));
+ var sclModel = Assert.IsType(imported.Model);
+
+ Assert.Equal(
+ discoveryModel.DataSets.Single().Reference,
+ sclModel.DataSets.Single().Reference);
+ Assert.Equal(
+ "IEDALD0/LLN0.Indications",
+ sclModel.DataSets.Single().Reference);
+ Assert.Equal(
+ discoveryModel.ReportControls.Single().Reference,
+ sclModel.ReportControls.Single().Reference);
+ Assert.Equal(
+ "IEDALD0/LLN0.BR.Rpt01",
+ sclModel.ReportControls.Single().Reference);
+ Assert.Equal(
+ discoveryModel.ReportControls.Single().DataSetReference,
+ sclModel.ReportControls.Single().DataSetReference);
+
+ var selection = new[]
+ {
+ new CanonicalStaticReportSelection
+ {
+ Reference = "IEDALD0/GGIO1.Alm.stVal",
+ FunctionalConstraint = "ST"
+ }
+ };
+
+ var discoveryCoverage = CanonicalStaticReportCoverageResolver.Resolve(discoveryModel, selection);
+ var sclCoverage = CanonicalStaticReportCoverageResolver.Resolve(sclModel, selection);
+
+ Assert.Equal(1, discoveryCoverage.CoveredSignalCount);
+ Assert.Equal(1, sclCoverage.CoveredSignalCount);
+ Assert.Equal(
+ discoveryCoverage.Signals[0].DataSetCandidates[0].DataSetReference,
+ sclCoverage.Signals[0].DataSetCandidates[0].DataSetReference);
+ Assert.Equal(
+ "IEDALD0/LLN0.Indications",
+ sclCoverage.Signals[0].DataSetCandidates[0].DataSetReference);
+ Assert.Equal(
+ discoveryCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences,
+ sclCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences);
+ Assert.Equal(
+ ["IEDALD0/LLN0.BR.Rpt01"],
+ sclCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences);
+ }
+
+ [Fact]
+ public void Structured_Member_Covers_Only_Exact_Boundary_Descendants()
+ {
+ var model = BuildCanonical();
+ var plan = CanonicalStaticReportCoverageResolver.Resolve(
+ model,
+ [
+ new CanonicalStaticReportSelection
+ {
+ Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f",
+ FunctionalConstraint = "MX"
+ },
+ new CanonicalStaticReportSelection
+ {
+ Reference = "IEDALD0/MMXU1.A.phsB.cVal.mag.f",
+ FunctionalConstraint = "MX"
+ }
+ ]);
+
+ Assert.Equal(CanonicalStaticReportSignalCoverageStatus.Covered, plan.Signals[0].Status);
+ Assert.Equal([0], plan.Signals[0].DataSetCandidates[0].MemberIndexes);
+ Assert.Equal(
+ CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership,
+ plan.Signals[1].Status);
+ }
+
+ [Fact]
+ public void Segment_Preserves_Full_Ordered_DataSet_And_All_Configured_Rcbs()
+ {
+ var model = BuildCanonical();
+ var plan = CanonicalStaticReportCoverageResolver.Resolve(
+ model,
+ [
+ new CanonicalStaticReportSelection
+ {
+ Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f",
+ FunctionalConstraint = "MX"
+ }
+ ]);
+
+ var segment = Assert.Single(plan.Segments);
+ Assert.Equal("IEDALD0/LLN0.Analog", segment.DataSetReference);
+ Assert.Equal(2, segment.OrderedMembers.Length);
+ Assert.Equal(0, segment.OrderedMembers[0].Index);
+ Assert.Equal(1, segment.OrderedMembers[1].Index);
+ Assert.Equal(
+ new[]
+ {
+ "IEDALD0/LLN0.BR.Buffer01",
+ "IEDALD0/LLN0.RP.Unbuffer01"
+ },
+ segment.ReportControls.Select(report => report.Reference));
+ Assert.Equal(
+ ["IEDALD0/MMXU1.A.phsA.cVal.mag.f"],
+ segment.SelectedSignalReferences);
+ }
+
+ [Fact]
+ public void Canonical_Identity_Remains_Exact_Case()
+ {
+ var model = BuildCanonical();
+ var plan = CanonicalStaticReportCoverageResolver.Resolve(
+ model,
+ [
+ new CanonicalStaticReportSelection
+ {
+ Reference = "iedald0/MMXU1.A.phsA.cVal.mag.f",
+ FunctionalConstraint = "MX"
+ }
+ ]);
+
+ Assert.Equal(CanonicalStaticReportSignalCoverageStatus.SignalNotFound, plan.Signals[0].Status);
+ Assert.Equal(0, plan.CoveredSignalCount);
+ }
+
+ [Fact]
+ public void DataSet_Without_Rcb_Is_Not_Promoted_To_Report_Coverage()
+ {
+ var model = BuildCanonical();
+ var withoutReports = new CanonicalIedModel
+ {
+ SchemaVersion = model.SchemaVersion,
+ GeneratedAtUtc = model.GeneratedAtUtc,
+ Identity = model.Identity,
+ Communication = model.Communication,
+ Source = model.Source,
+ Strings = model.Strings,
+ AccessPoints = model.AccessPoints,
+ LogicalDevices = model.LogicalDevices,
+ LogicalNodes = model.LogicalNodes,
+ DataObjects = model.DataObjects,
+ Signals = model.Signals,
+ DataSets = model.DataSets,
+ ReportControls = Array.Empty(),
+ Diagnostics = model.Diagnostics
+ };
+
+ var plan = CanonicalStaticReportCoverageResolver.Resolve(
+ withoutReports,
+ [
+ new CanonicalStaticReportSelection
+ {
+ Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f",
+ FunctionalConstraint = "MX"
+ }
+ ]);
+
+ Assert.Equal(
+ CanonicalStaticReportSignalCoverageStatus.StaticDataSetWithoutReportControl,
+ plan.Signals[0].Status);
+ Assert.Empty(plan.Segments);
+ }
+
+ private static CanonicalIedModel BuildStatusDiscoveryCanonical()
+ {
+ var live = new LiveIedModelDiscoveryDocument
+ {
+ Source = "LiveMmsDiscovery",
+ IedName = "IEDA",
+ AccessPointName = "P1",
+ IedIdentity = new LiveIedIdentity
+ {
+ IedName = "IEDA",
+ Confidence = LiveIedDiscoveryConfidenceLevel.Exact
+ },
+ LogicalDevices =
+ [
+ new LiveIedLogicalDeviceModel
+ {
+ MmsDomain = "IEDALD0",
+ Inst = "LD0",
+ LogicalNodes =
+ [
+ new LiveIedLogicalNodeModel
+ {
+ Name = "GGIO1",
+ LnClass = "GGIO",
+ LnInst = "1",
+ DataObjects =
+ [
+ new LiveIedDataObjectModel
+ {
+ Reference = "IEDALD0/GGIO1.Alm",
+ Name = "Alm",
+ InferredCdc = "SPS",
+ ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact,
+ Attributes =
+ [
+ new LiveIedDataAttributeModel
+ {
+ ObjectReference = "IEDALD0/GGIO1.Alm.stVal",
+ AttributePath = "stVal",
+ FunctionalConstraint = "ST",
+ SclBType = "BOOLEAN",
+ MmsType = "boolean",
+ MmsTypeSignature = "boolean",
+ TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ DataSets =
+ [
+ new LiveIedDataSetModel
+ {
+ Reference = "IEDALD0/LLN0.Indications",
+ Domain = "IEDALD0",
+ LogicalNode = "LLN0",
+ Name = "Indications",
+ MemberCount = 1,
+ Members =
+ [
+ new LiveIedDataSetMemberModel
+ {
+ Index = 1,
+ Reference = "IEDALD0/GGIO1.Alm",
+ FunctionalConstraint = "ST"
+ }
+ ]
+ }
+ ],
+ ReportControls =
+ [
+ new LiveIedReportControlModel
+ {
+ Reference = "IEDALD0/LLN0.BR.Rpt01",
+ Domain = "IEDALD0",
+ LogicalNode = "LLN0",
+ Name = "Rpt01",
+ Buffered = true,
+ DataSetReference = "IEDALD0/LLN0.Indications"
+ }
+ ]
+ };
+
+ return CanonicalLiveModelAdapter.FromLiveDiscovery(live);
+ }
+
+ private static string BuildEquivalentStatusScl()
+ => """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ private static CanonicalIedModel BuildCanonical()
+ {
+ var live = new LiveIedModelDiscoveryDocument
+ {
+ Source = "LiveMmsDiscovery",
+ IedName = "IEDA",
+ IedIdentity = new LiveIedIdentity
+ {
+ IedName = "IEDA",
+ Confidence = LiveIedDiscoveryConfidenceLevel.Exact
+ },
+ LogicalDevices =
+ [
+ new LiveIedLogicalDeviceModel
+ {
+ MmsDomain = "IEDALD0",
+ Inst = "LD0",
+ LogicalNodes =
+ [
+ new LiveIedLogicalNodeModel
+ {
+ Name = "MMXU1",
+ LnClass = "MMXU",
+ LnInst = "1",
+ DataObjects =
+ [
+ new LiveIedDataObjectModel
+ {
+ Reference = "IEDALD0/MMXU1.A",
+ Name = "A",
+ InferredCdc = "WYE",
+ ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact,
+ Attributes =
+ [
+ Attribute("IEDALD0/MMXU1.A.phsA.cVal.mag.f", "phsA.cVal.mag.f"),
+ Attribute("IEDALD0/MMXU1.A.phsB.cVal.mag.f", "phsB.cVal.mag.f")
+ ]
+ },
+ new LiveIedDataObjectModel
+ {
+ Reference = "IEDALD0/MMXU1.TotW",
+ Name = "TotW",
+ InferredCdc = "MV",
+ ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact,
+ Attributes =
+ [
+ Attribute("IEDALD0/MMXU1.TotW.mag.f", "mag.f")
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ DataSets =
+ [
+ new LiveIedDataSetModel
+ {
+ Reference = "IEDALD0/LLN0.Analog",
+ Domain = "IEDALD0",
+ LogicalNode = "LLN0",
+ Name = "Analog",
+ MemberCount = 2,
+ Members =
+ [
+ new LiveIedDataSetMemberModel
+ {
+ Index = 0,
+ Reference = "IEDALD0/MMXU1.A.phsA",
+ FunctionalConstraint = "MX"
+ },
+ new LiveIedDataSetMemberModel
+ {
+ Index = 1,
+ Reference = "IEDALD0/MMXU1.TotW",
+ FunctionalConstraint = "MX"
+ }
+ ]
+ }
+ ],
+ ReportControls =
+ [
+ new LiveIedReportControlModel
+ {
+ Reference = "IEDALD0/LLN0.BR.Buffer01",
+ Domain = "IEDALD0",
+ LogicalNode = "LLN0",
+ Name = "Buffer01",
+ Buffered = true,
+ DataSetReference = "IEDALD0/LLN0.Analog"
+ },
+ new LiveIedReportControlModel
+ {
+ Reference = "IEDALD0/LLN0.RP.Unbuffer01",
+ Domain = "IEDALD0",
+ LogicalNode = "LLN0",
+ Name = "Unbuffer01",
+ Buffered = false,
+ DataSetReference = "IEDALD0/LLN0.Analog"
+ }
+ ]
+ };
+
+ return CanonicalLiveModelAdapter.FromLiveDiscovery(live);
+ }
+
+ private static LiveIedDataAttributeModel Attribute(string reference, string path)
+ => new()
+ {
+ ObjectReference = reference,
+ AttributePath = path,
+ FunctionalConstraint = "MX",
+ SclBType = "FLOAT32",
+ MmsType = "floating-point",
+ MmsTypeSignature = "floating-point",
+ TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact
+ };
+
+ private static CanonicalIedModel WithIngress(
+ CanonicalIedModel model,
+ CanonicalIngressKind ingress)
+ => new()
+ {
+ SchemaVersion = model.SchemaVersion,
+ GeneratedAtUtc = model.GeneratedAtUtc,
+ Identity = model.Identity,
+ Communication = model.Communication,
+ Source = new CanonicalSourceEnvelope
+ {
+ Ingress = ingress,
+ SourceName = ingress == CanonicalIngressKind.SclFile ? "sample.scd" : "LiveMmsDiscovery"
+ },
+ Strings = model.Strings,
+ AccessPoints = model.AccessPoints,
+ LogicalDevices = model.LogicalDevices,
+ LogicalNodes = model.LogicalNodes,
+ DataObjects = model.DataObjects,
+ Signals = model.Signals,
+ DataSets = model.DataSets,
+ ReportControls = model.ReportControls,
+ Diagnostics = model.Diagnostics
+ };
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs b/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs
index 7e42dc89..e4c3e154 100644
--- a/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs
+++ b/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs
@@ -47,4 +47,40 @@ public void StandardMmsDataAccessErrors_HaveExplicitNames(int code, string expec
[Fact]
public void VendorSpecificUnknownAccessError_RemainsNumericallyVisible()
=> Assert.Equal("data-access-error-99", MmsWriteResponseDecoder.NameDataAccessError(99));
+ [Theory]
+ [InlineData(2, MmsInteropFailureKind.TemporarilyUnavailable, MmsInteropRecoveryHint.RetryLaterAfterStateRefresh)]
+ [InlineData(3, MmsInteropFailureKind.AccessDenied, MmsInteropRecoveryHint.FailClosed)]
+ [InlineData(6, MmsInteropFailureKind.TypeUnsupported, MmsInteropRecoveryHint.RevalidateType)]
+ [InlineData(7, MmsInteropFailureKind.TypeMismatch, MmsInteropRecoveryHint.RevalidateType)]
+ [InlineData(8, MmsInteropFailureKind.AttributeInconsistent, MmsInteropRecoveryHint.RevalidateRuntimeState)]
+ [InlineData(10, MmsInteropFailureKind.ObjectNotFound, MmsInteropRecoveryHint.RevalidateObjectIdentity)]
+ [InlineData(11, MmsInteropFailureKind.InvalidValue, MmsInteropRecoveryHint.FailClosed)]
+ public void DataAccessErrors_Map_To_Structured_Interop_Recovery(
+ int code,
+ MmsInteropFailureKind expectedKind,
+ MmsInteropRecoveryHint expectedRecovery)
+ {
+ var access = new MmsWriteAccessResult
+ {
+ IsSuccess = false,
+ FailureCode = code,
+ FailureName = MmsWriteResponseDecoder.NameDataAccessError(code)
+ };
+
+ var classified = MmsInteropFailureClassifier.Classify(access);
+
+ Assert.Equal(expectedKind, classified.Kind);
+ Assert.Equal(expectedRecovery, classified.RecoveryHint);
+ }
+
+ [Fact]
+ public void Successful_Access_Has_No_Recovery_Action()
+ {
+ var classified = MmsInteropFailureClassifier.Classify(
+ new MmsWriteAccessResult { IsSuccess = true });
+
+ Assert.Equal(MmsInteropFailureKind.None, classified.Kind);
+ Assert.Equal(MmsInteropRecoveryHint.None, classified.RecoveryHint);
+ }
+
}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs
new file mode 100644
index 00000000..d8863e5e
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs
@@ -0,0 +1,146 @@
+using AR.Iec61850.Engineering.Canonical;
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsCanonicalReportInventoryProjectionTests
+{
+ [Fact]
+ public void SclCanonicalProjection_PreservesExactConfiguredIdentity_WithoutInventingLiveEvidence()
+ {
+ var model = Model(
+ CanonicalIngressKind.SclFile,
+ reportReference: "LD0/LLN0.BR.Rpt",
+ dataSetReference: "LD0/LLN0.Events");
+
+ var inventory = MmsCanonicalReportInventoryProjection.Build(model);
+
+ Assert.Equal(MmsReportInventoryAuthority.SclDesignProjection, inventory.Authority);
+ var report = Assert.Single(inventory.ReportControls);
+ Assert.Equal("LD0/LLN0.BR.Rpt", report.Reference);
+ Assert.DoesNotContain("01", report.Reference, StringComparison.Ordinal);
+ Assert.Equal("LD0/LLN0.Events", report.DataSetReference);
+ Assert.True(report.Buffered);
+ Assert.Equal("BR", report.FunctionalConstraint);
+ Assert.Empty(report.Attributes);
+ Assert.Equal(string.Empty, report.EnabledState);
+ Assert.Equal(string.Empty, report.ReservationTimeSeconds);
+ Assert.Equal(string.Empty, report.Owner);
+ }
+
+ [Fact]
+ public void DiscoveryDerivedCanonicalProjection_IsStillStructural_NotLiveRuntimeAuthority()
+ {
+ var model = Model(
+ CanonicalIngressKind.LiveMmsDiscovery,
+ reportReference: "LD0/LLN0.RP.Rpt01",
+ dataSetReference: "LD0/LLN0.Analog");
+
+ var inventory = MmsCanonicalReportInventoryProjection.Build(model);
+
+ Assert.Equal(MmsReportInventoryAuthority.CanonicalModelProjection, inventory.Authority);
+ var report = Assert.Single(inventory.ReportControls);
+ Assert.Equal("LD0/LLN0.RP.Rpt01", report.Reference);
+ Assert.False(report.Buffered);
+ Assert.Equal("RP", report.FunctionalConstraint);
+ Assert.Empty(report.Attributes);
+ }
+
+ [Fact]
+ public void CanonicalProjection_CannotAuthorizeOperationalStaticTargeting()
+ {
+ var model = Model(
+ CanonicalIngressKind.SclFile,
+ reportReference: "LD0/LLN0.BR.Rpt01",
+ dataSetReference: "LD0/LLN0.Events");
+ var inventory = MmsCanonicalReportInventoryProjection.Build(model);
+
+ var coverage = new CanonicalStaticReportCoveragePlan
+ {
+ Ingress = CanonicalIngressKind.SclFile,
+ Signals =
+ [
+ new CanonicalStaticReportSignalCoverage
+ {
+ RequestedReference = "LD0/XCBR1.Pos.stVal",
+ CanonicalReference = "LD0/XCBR1.Pos.stVal",
+ Status = CanonicalStaticReportSignalCoverageStatus.Covered
+ }
+ ],
+ Segments =
+ [
+ new CanonicalStaticReportCoverageSegment
+ {
+ DataSetReference = "LD0/LLN0.Events",
+ SourceDataSetReference = "LD0/LLN0.Events",
+ SelectedSignalReferences = ["LD0/XCBR1.Pos.stVal"],
+ ReportControls = model.ReportControls,
+ OrderedMembers = model.DataSets[0].Members
+ }
+ ]
+ };
+
+ var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build(coverage, inventory);
+
+ Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, plan.Status);
+ Assert.False(plan.HasExactOperationalTargets);
+ }
+
+ private static CanonicalIedModel Model(
+ CanonicalIngressKind ingress,
+ string reportReference,
+ string dataSetReference)
+ => new()
+ {
+ Source = new CanonicalSourceEnvelope
+ {
+ Ingress = ingress,
+ SourceName = ingress == CanonicalIngressKind.SclFile ? "relay.cid" : "LiveMmsDiscovery"
+ },
+ DataSets =
+ [
+ new CanonicalDataSet
+ {
+ Reference = dataSetReference,
+ MmsDomain = "LD0",
+ LogicalNode = "LLN0",
+ Name = dataSetReference.Split('.').Last(),
+ Members =
+ [
+ new CanonicalDataSetMember(
+ 0,
+ "LD0/XCBR1.Pos",
+ "ST",
+ new CanonicalProvenance(
+ ingress == CanonicalIngressKind.SclFile
+ ? CanonicalEvidenceSource.SclDeclared
+ : CanonicalEvidenceSource.LiveMms,
+ CanonicalConfidence.Exact))
+ ]
+ }
+ ],
+ ReportControls =
+ [
+ new CanonicalReportControl
+ {
+ Reference = reportReference,
+ MmsDomain = "LD0",
+ LogicalNode = "LLN0",
+ Name = reportReference.Split('.').Last(),
+ Buffered = reportReference.Contains(".BR.", StringComparison.Ordinal),
+ DataSetReference = dataSetReference,
+ ReportId = "RID",
+ ConfRev = "1",
+ TriggerOptions = "dchg,qchg,gi",
+ OptionalFields = "seqNum,timeStamp,dataSet,reasonCode",
+ BufferTimeMs = "100",
+ IntegrityPeriodMs = "0",
+ Provenance = new CanonicalProvenance(
+ ingress == CanonicalIngressKind.SclFile
+ ? CanonicalEvidenceSource.SclDeclared
+ : CanonicalEvidenceSource.LiveMms,
+ CanonicalConfidence.Exact)
+ }
+ ]
+ };
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs
new file mode 100644
index 00000000..484ebd86
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs
@@ -0,0 +1,275 @@
+using AR.Iec61850.Engineering.Canonical;
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsCanonicalStaticAcquisitionPreflightTests
+{
+ [Fact]
+ public void SclProjectedInventory_IsNeverPromotedToLiveOperationalTargetEvidence()
+ {
+ var coverage = Coverage(
+ Segment(
+ "LD0/LLN0.Events",
+ "LD0/LLN0.BR.Rpt01",
+ buffered: true,
+ "LD0/XCBR1.Pos.stVal"));
+
+ var inventory = Inventory(
+ MmsReportInventoryAuthority.SclDesignProjection,
+ Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true));
+
+ var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build(
+ coverage,
+ inventory);
+
+ Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, plan.Status);
+ Assert.False(plan.HasExactOperationalTargets);
+ Assert.Empty(plan.ExactTargetReportControlReferences);
+ Assert.Contains(plan.Warnings, warning =>
+ warning.Contains("structural evidence only", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void LiveInventory_ProducesOnlyExactStaticTargets()
+ {
+ var coverage = Coverage(
+ Segment(
+ "LD0/LLN0.Events",
+ "LD0/LLN0.BR.Rpt01",
+ buffered: true,
+ "LD0/XCBR1.Pos.stVal"));
+
+ var inventory = Inventory(
+ MmsReportInventoryAuthority.LiveMmsObserved,
+ Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true),
+ Candidate("LD0/LLN0.BR.Other01", "LD0/LLN0.Other", buffered: true));
+
+ var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build(
+ coverage,
+ inventory);
+
+ Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady, plan.Status);
+ Assert.True(plan.HasExactOperationalTargets);
+ Assert.Equal(
+ new[] { "LD0/LLN0.BR.Rpt01" },
+ plan.ExactTargetReportControlReferences.OrderBy(x => x).ToArray());
+ Assert.Equal(1, plan.ResolvedSegmentCount);
+ Assert.Equal(0, plan.UnresolvedSegmentCount);
+ }
+
+ [Fact]
+ public void PartialResolution_RemainsExplicit_AndNeverBroadensUnresolvedSegment()
+ {
+ var coverage = Coverage(
+ Segment(
+ "LD0/LLN0.Events",
+ "LD0/LLN0.BR.Rpt01",
+ buffered: true,
+ "LD0/XCBR1.Pos.stVal"),
+ Segment(
+ "LD0/LLN0.Analog",
+ "LD0/LLN0.RP.Meas",
+ buffered: false,
+ "LD0/MMXU1.A.phsA.cVal.mag.f"));
+
+ var inventory = Inventory(
+ MmsReportInventoryAuthority.LiveMmsObserved,
+ Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true),
+ Candidate("LD0/LLN0.RP.Meas01", string.Empty, buffered: false),
+ Candidate("LD0/LLN0.RP.Meas02", string.Empty, buffered: false));
+
+ var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build(
+ coverage,
+ inventory);
+
+ Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.PartialExactTargetsReady, plan.Status);
+ Assert.Equal(1, plan.ResolvedSegmentCount);
+ Assert.Equal(1, plan.UnresolvedSegmentCount);
+ Assert.Equal(new[] { "LD0/LLN0.BR.Rpt01" }, plan.ExactTargetReportControlReferences);
+ Assert.DoesNotContain(
+ plan.ExactTargetReportControlReferences,
+ reference => reference.Contains("Meas", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task NonOperationalPlan_DoesNotRequireConnectedSession()
+ {
+ var coverage = Coverage(
+ Segment(
+ "LD0/LLN0.Events",
+ "LD0/LLN0.BR.Rpt01",
+ buffered: true,
+ "LD0/XCBR1.Pos.stVal"));
+ var inventory = Inventory(
+ MmsReportInventoryAuthority.SclDesignProjection,
+ Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true));
+ var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build(
+ coverage,
+ inventory);
+
+ await using var session = new MmsClientSession();
+ var result = await session.ProbeCanonicalStaticAcquisitionAsync(plan, inventory);
+
+ Assert.False(result.NetworkProbePerformed);
+ Assert.Null(result.Availability);
+ Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, result.Status);
+ }
+
+ [Fact]
+ public async Task ExecutorRejectsInventoryAuthoritySwapBeforeNetworkWork()
+ {
+ var coverage = Coverage(
+ Segment(
+ "LD0/LLN0.Events",
+ "LD0/LLN0.BR.Rpt01",
+ buffered: true,
+ "LD0/XCBR1.Pos.stVal"));
+ var liveInventory = Inventory(
+ MmsReportInventoryAuthority.LiveMmsObserved,
+ Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true));
+ var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build(
+ coverage,
+ liveInventory);
+
+ var structuralOnlyInventory = Inventory(
+ MmsReportInventoryAuthority.SclDesignProjection,
+ Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true));
+
+ await using var session = new MmsClientSession();
+ var result = await session.ProbeCanonicalStaticAcquisitionAsync(
+ plan,
+ structuralOnlyInventory);
+
+ Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, result.Status);
+ Assert.False(result.NetworkProbePerformed);
+ Assert.Null(result.Availability);
+ }
+
+ [Fact]
+ public async Task TargetBudgetFailsBeforeAnyNetworkWork_AndNeverRunsPartialProbe()
+ {
+ var targetSet = Enumerable.Range(1, 3)
+ .Select(index => $"LD0/LLN0.BR.Rpt{index:00}")
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var targetResolution = new MmsCanonicalStaticRcbTargetResolution
+ {
+ ExactLiveReportControlReferences = targetSet,
+ Segments =
+ [
+ new MmsCanonicalStaticRcbTargetSegment
+ {
+ DataSetReference = "LD0/LLN0.Events",
+ ExactLiveReportControlReferences = targetSet.ToArray(),
+ Resolution = "exact-live-dataset-binding"
+ }
+ ]
+ };
+ var plan = new MmsCanonicalStaticAcquisitionProbePlan
+ {
+ InventoryAuthority = MmsReportInventoryAuthority.LiveMmsObserved,
+ Coverage = Coverage(
+ Segment(
+ "LD0/LLN0.Events",
+ "LD0/LLN0.BR.Rpt01",
+ buffered: true,
+ "LD0/XCBR1.Pos.stVal")),
+ TargetResolution = targetResolution,
+ Status = MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady,
+ ExactTargetReportControlReferences = targetSet
+ };
+
+ await using var session = new MmsClientSession();
+ var result = await session.ProbeCanonicalStaticAcquisitionAsync(
+ plan,
+ Inventory(
+ MmsReportInventoryAuthority.LiveMmsObserved,
+ Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", true),
+ Candidate("LD0/LLN0.BR.Rpt02", "LD0/LLN0.Events", true),
+ Candidate("LD0/LLN0.BR.Rpt03", "LD0/LLN0.Events", true)),
+ options: new MmsCanonicalStaticAcquisitionProbeOptions
+ {
+ MaxExactTargets = 2
+ });
+
+ Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.TargetBudgetExceeded, result.Status);
+ Assert.False(result.NetworkProbePerformed);
+ Assert.Null(result.Availability);
+ Assert.Contains(result.Warnings, warning =>
+ warning.Contains("no partial probe", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static CanonicalStaticReportCoveragePlan Coverage(
+ params CanonicalStaticReportCoverageSegment[] segments)
+ {
+ var signals = segments
+ .SelectMany(segment => segment.SelectedSignalReferences)
+ .Distinct(StringComparer.Ordinal)
+ .Select(reference => new CanonicalStaticReportSignalCoverage
+ {
+ RequestedReference = reference,
+ CanonicalReference = reference,
+ Status = CanonicalStaticReportSignalCoverageStatus.Covered
+ })
+ .ToArray();
+
+ return new CanonicalStaticReportCoveragePlan
+ {
+ Ingress = CanonicalIngressKind.SclFile,
+ Signals = signals,
+ Segments = segments
+ };
+ }
+
+ private static CanonicalStaticReportCoverageSegment Segment(
+ string dataSet,
+ string rcbReference,
+ bool buffered,
+ string selectedSignal)
+ => new()
+ {
+ DataSetReference = dataSet,
+ SourceDataSetReference = dataSet,
+ SelectedSignalReferences = [selectedSignal],
+ OrderedMembers =
+ [
+ new CanonicalDataSetMember(
+ 0,
+ selectedSignal[..selectedSignal.LastIndexOf('.')],
+ selectedSignal.Contains("MMXU", StringComparison.Ordinal) ? "MX" : "ST",
+ new CanonicalProvenance(CanonicalEvidenceSource.SclDeclared, CanonicalConfidence.Exact))
+ ],
+ ReportControls =
+ [
+ new CanonicalReportControl
+ {
+ Reference = rcbReference,
+ DataSetReference = dataSet,
+ Buffered = buffered
+ }
+ ]
+ };
+
+ private static MmsReportInventory Inventory(
+ MmsReportInventoryAuthority authority,
+ params MmsReportControlCandidate[] controls)
+ {
+ var inventory = new MmsReportInventory
+ {
+ Authority = authority
+ };
+ inventory.ReportControls.AddRange(controls);
+ return inventory;
+ }
+
+ private static MmsReportControlCandidate Candidate(
+ string reference,
+ string dataSet,
+ bool buffered)
+ => new()
+ {
+ Reference = reference,
+ DataSetReference = dataSet,
+ Buffered = buffered
+ };
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveReconciliationPlannerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveReconciliationPlannerTests.cs
new file mode 100644
index 00000000..51a4ae5c
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveReconciliationPlannerTests.cs
@@ -0,0 +1,179 @@
+using AR.Iec61850.Engineering.Canonical;
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsCanonicalStaticLiveReconciliationPlannerTests
+{
+ [Fact]
+ public void Planner_Collapses_Selected_Static_Coverage_To_Minimum_Domain_Set()
+ {
+ var coverage = Coverage(
+ Segment(
+ "IEDLD0/LLN0.Events",
+ Report("IEDLD0/LLN0.BR.Rpt", "IEDLD0", buffered: true),
+ "IEDLD0/XCBR1.Pos.stVal"),
+ Segment(
+ "IEDLD0/LLN0.Analog",
+ Report("IEDLD0/LLN0.RP.Meas", "IEDLD0", buffered: false),
+ "IEDLD0/MMXU1.A.phsA.cVal.mag.f"),
+ Segment(
+ "IEDLD1/LLN0.Events",
+ Report("IEDLD1/LLN0.BR.Rpt", "IEDLD1", buffered: true),
+ "IEDLD1/GGIO1.Alm.stVal"));
+
+ var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build(coverage);
+
+ Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.Ready, plan.Status);
+ Assert.True(plan.RequiresNetwork);
+ Assert.Equal(2, plan.Domains.Count);
+
+ var ld0 = plan.Domains.Single(target => target.Domain == "IEDLD0");
+ Assert.True(ld0.NeedsBuffered);
+ Assert.True(ld0.NeedsUnbuffered);
+
+ var ld1 = plan.Domains.Single(target => target.Domain == "IEDLD1");
+ Assert.True(ld1.NeedsBuffered);
+ Assert.False(ld1.NeedsUnbuffered);
+ }
+
+ [Fact]
+ public void Planner_Uses_Exact_Reference_Domain_When_MmsDomain_Is_Not_Populated()
+ {
+ var coverage = Coverage(
+ Segment(
+ "IEDLD0/LLN0.Events",
+ Report("IEDLD0/LLN0.BR.Rpt", mmsDomain: string.Empty, buffered: true),
+ "IEDLD0/XCBR1.Pos.stVal"));
+
+ var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build(coverage);
+
+ var target = Assert.Single(plan.Domains);
+ Assert.Equal("IEDLD0", target.Domain);
+ Assert.True(target.NeedsBuffered);
+ }
+
+ [Fact]
+ public void Planner_FailsClosed_When_Domain_Budget_Would_Require_Truncation()
+ {
+ var coverage = Coverage(
+ Segment(
+ "IEDLD0/LLN0.Events",
+ Report("IEDLD0/LLN0.BR.Rpt", "IEDLD0", true),
+ "IEDLD0/XCBR1.Pos.stVal"),
+ Segment(
+ "IEDLD1/LLN0.Events",
+ Report("IEDLD1/LLN0.BR.Rpt", "IEDLD1", true),
+ "IEDLD1/XCBR1.Pos.stVal"));
+
+ var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build(
+ coverage,
+ new MmsCanonicalStaticLiveReconciliationOptions
+ {
+ MaxDomains = 1
+ });
+
+ Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.DomainBudgetExceeded, plan.Status);
+ Assert.False(plan.RequiresNetwork);
+ Assert.Equal(2, plan.Domains.Count);
+ Assert.Contains(plan.Warnings, warning =>
+ warning.Contains("no partial domain subset", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Planner_DoesNot_Request_Network_When_No_Configured_Static_Coverage_Exists()
+ {
+ var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build(
+ new CanonicalStaticReportCoveragePlan
+ {
+ Signals =
+ [
+ new CanonicalStaticReportSignalCoverage
+ {
+ RequestedReference = "IEDLD0/MMXU1.A.phsA.cVal.mag.f",
+ CanonicalReference = "IEDLD0/MMXU1.A.phsA.cVal.mag.f",
+ Status = CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership
+ }
+ ]
+ });
+
+ Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.NoStaticCoverage, plan.Status);
+ Assert.False(plan.RequiresNetwork);
+ Assert.Empty(plan.Domains);
+ }
+
+ [Fact]
+ public async Task Reconcile_NoStaticCoverage_Performs_Zero_Network_Work_On_Disconnected_Session()
+ {
+ await using var session = new MmsClientSession();
+
+ var result = await session.ReconcileCanonicalStaticReportInventoryAsync(
+ new CanonicalStaticReportCoveragePlan());
+
+ Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.NoStaticCoverage, result.Status);
+ Assert.Equal(MmsReportInventoryAuthority.LiveMmsObserved, result.Inventory.Authority);
+ Assert.Equal(0, result.EnumeratedVariableCount);
+ Assert.Equal(0, result.CandidateReportControlCount);
+ }
+
+ private static CanonicalStaticReportCoveragePlan Coverage(
+ params CanonicalStaticReportCoverageSegment[] segments)
+ {
+ var signals = segments
+ .SelectMany(segment => segment.SelectedSignalReferences)
+ .Distinct(StringComparer.Ordinal)
+ .Select(reference => new CanonicalStaticReportSignalCoverage
+ {
+ RequestedReference = reference,
+ CanonicalReference = reference,
+ Status = CanonicalStaticReportSignalCoverageStatus.Covered
+ })
+ .ToArray();
+
+ return new CanonicalStaticReportCoveragePlan
+ {
+ Ingress = CanonicalIngressKind.SclFile,
+ Signals = signals,
+ Segments = segments
+ };
+ }
+
+ private static CanonicalStaticReportCoverageSegment Segment(
+ string dataSetReference,
+ CanonicalReportControl report,
+ string selectedSignal)
+ => new()
+ {
+ DataSetReference = dataSetReference,
+ SourceDataSetReference = dataSetReference,
+ SelectedSignalReferences = [selectedSignal],
+ ReportControls = [report],
+ OrderedMembers =
+ [
+ new CanonicalDataSetMember(
+ 0,
+ selectedSignal[..selectedSignal.LastIndexOf('.')],
+ selectedSignal.Contains("MMXU", StringComparison.Ordinal) ? "MX" : "ST",
+ new CanonicalProvenance(CanonicalEvidenceSource.SclDeclared, CanonicalConfidence.Exact))
+ ]
+ };
+
+ private static CanonicalReportControl Report(
+ string reference,
+ string mmsDomain,
+ bool buffered)
+ => new()
+ {
+ Reference = reference,
+ MmsDomain = mmsDomain,
+ LogicalNode = "LLN0",
+ Name = reference.Split('.').Last(),
+ Buffered = buffered,
+ DataSetReference = reference.Contains("Meas", StringComparison.Ordinal)
+ ? $"{mmsDomain}/LLN0.Analog"
+ : $"{mmsDomain}/LLN0.Events",
+ Provenance = new CanonicalProvenance(
+ CanonicalEvidenceSource.SclDeclared,
+ CanonicalConfidence.Exact)
+ };
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs
new file mode 100644
index 00000000..12bcbef0
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs
@@ -0,0 +1,125 @@
+using AR.Iec61850.Engineering.Canonical;
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsCanonicalStaticRcbTargetResolverTests
+{
+ [Fact]
+ public void Exact_Live_Dataset_Binding_Targets_Only_Covered_Rcb_Family()
+ {
+ var coverage = Coverage(
+ dataSet: "LD0/LLN0.Events",
+ configured: new CanonicalReportControl
+ {
+ Reference = "LD0/LLN0.BR.Rpt",
+ DataSetReference = "LD0/LLN0.Events",
+ Buffered = true
+ });
+
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true),
+ Candidate("LD0/LLN0.BR.Rpt02", "LD0/LLN0.Events", buffered: true),
+ Candidate("LD0/LLN0.RP.Rpt01", "LD0/LLN0.Events", buffered: false),
+ Candidate("LD0/LLN0.BR.Other01", "LD0/LLN0.Other", buffered: true));
+
+ var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory);
+
+ Assert.True(resolution.HasTargets);
+ Assert.Equal(
+ new[] { "LD0/LLN0.BR.Rpt01", "LD0/LLN0.BR.Rpt02" },
+ resolution.ExactLiveReportControlReferences.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray());
+ Assert.Equal("exact-live-dataset-binding", resolution.Segments.Single().Resolution);
+ }
+
+ [Fact]
+ public void Exact_Configured_Reference_Can_Target_When_Live_Dataset_Binding_Is_Not_Yet_Populated()
+ {
+ var coverage = Coverage(
+ dataSet: "LD0/LLN0.Events",
+ configured: new CanonicalReportControl
+ {
+ Reference = "LD0/LLN0.BR.Rpt01",
+ DataSetReference = "LD0/LLN0.Events",
+ Buffered = true
+ });
+
+ var inventory = Inventory(
+ Candidate("LD0/LLN0$BR$Rpt01", string.Empty, buffered: true),
+ Candidate("LD0/LLN0.BR.Rpt02", string.Empty, buffered: true));
+
+ var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory);
+
+ Assert.Equal(new[] { "LD0/LLN0$BR$Rpt01" }, resolution.ExactLiveReportControlReferences);
+ Assert.Equal("exact-configured-rcb-reference", resolution.Segments.Single().Resolution);
+ }
+
+ [Fact]
+ public void Unresolved_Segment_Does_Not_Guess_Name_Family()
+ {
+ var coverage = Coverage(
+ dataSet: "LD0/LLN0.Events",
+ configured: new CanonicalReportControl
+ {
+ Reference = "LD0/LLN0.BR.Rpt",
+ DataSetReference = "LD0/LLN0.Events",
+ Buffered = true
+ });
+
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.Rpt01", string.Empty, buffered: true),
+ Candidate("LD0/LLN0.BR.Rpt02", string.Empty, buffered: true));
+
+ var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory);
+
+ Assert.False(resolution.HasTargets);
+ Assert.Empty(resolution.ExactLiveReportControlReferences);
+ Assert.Equal("unresolved", resolution.Segments.Single().Resolution);
+ Assert.Contains(
+ resolution.Warnings,
+ warning => warning.Contains("No sibling/name-family guess", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static CanonicalStaticReportCoveragePlan Coverage(
+ string dataSet,
+ CanonicalReportControl configured)
+ => new()
+ {
+ Segments = new[]
+ {
+ new CanonicalStaticReportCoverageSegment
+ {
+ DataSetReference = dataSet,
+ SourceDataSetReference = dataSet,
+ SelectedSignalReferences = new[] { "LD0/XCBR1.Pos.stVal" },
+ OrderedMembers = new[]
+ {
+ new CanonicalDataSetMember(
+ 0,
+ "LD0/XCBR1.Pos",
+ "ST",
+ new CanonicalProvenance(CanonicalEvidenceSource.LiveMms, CanonicalConfidence.Exact))
+ },
+ ReportControls = new[] { configured }
+ }
+ }
+ };
+
+ private static MmsReportInventory Inventory(params MmsReportControlCandidate[] controls)
+ {
+ var inventory = new MmsReportInventory();
+ inventory.ReportControls.AddRange(controls);
+ return inventory;
+ }
+
+ private static MmsReportControlCandidate Candidate(
+ string reference,
+ string dataSet,
+ bool buffered)
+ => new()
+ {
+ Reference = reference,
+ DataSetReference = dataSet,
+ Buffered = buffered
+ };
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs
new file mode 100644
index 00000000..e69606b5
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs
@@ -0,0 +1,154 @@
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsConfiguredStaticActivationVerifierTests
+{
+ [Fact]
+ public void BeforeWrite_Requires_Exact_Disabled_Readback()
+ {
+ var unknown = Snapshot(success: true, enabled: "-", dataSet: "LD0/LLN0.DS01", stage: "before");
+ var enabled = Snapshot(success: true, enabled: "true", dataSet: "LD0/LLN0.DS01", stage: "before");
+ var disabled = Snapshot(success: true, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before");
+
+ Assert.Equal(
+ MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch,
+ MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(unknown, "LD0/LLN0.DS01").Kind);
+ Assert.Equal(
+ MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch,
+ MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(enabled, "LD0/LLN0.DS01").Kind);
+ Assert.True(
+ MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(disabled, "LD0/LLN0.DS01").IsProven);
+ }
+
+ [Fact]
+ public void BeforeWrite_Blocks_Positive_DataSet_Contradiction_But_Not_Unread_DataSet()
+ {
+ var mismatch = Snapshot(
+ success: true,
+ enabled: "false",
+ dataSet: "LD0/LLN0.Other",
+ stage: "before",
+ dataSetProbeState: MmsRcbDataSetProbeState.ReadSucceeded);
+ var unread = Snapshot(
+ success: true,
+ enabled: "false",
+ dataSet: string.Empty,
+ stage: "before",
+ dataSetProbeState: MmsRcbDataSetProbeState.ReadFailed);
+
+ Assert.Equal(
+ MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch,
+ MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(mismatch, "LD0/LLN0.DS01").Kind);
+ Assert.True(
+ MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(unread, "LD0/LLN0.DS01").IsProven);
+ }
+
+ [Fact]
+ public void BeforeWrite_Blocks_Positive_Empty_Live_DataSet()
+ {
+ var empty = Snapshot(
+ success: true,
+ enabled: "false",
+ dataSet: string.Empty,
+ stage: "before",
+ dataSetProbeState: MmsRcbDataSetProbeState.ReadSucceeded);
+
+ var proof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(empty, "LD0/LLN0.DS01");
+
+ Assert.Equal(MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, proof.Kind);
+ }
+
+ [Fact]
+ public void BeforeWrite_Blocks_Positive_Busy_Evidence()
+ {
+ var brcb = Snapshot(
+ success: true,
+ enabled: "false",
+ dataSet: "LD0/LLN0.DS01",
+ stage: "before",
+ buffered: true,
+ reservationTimeSeconds: "30");
+
+ var urcb = Snapshot(
+ success: true,
+ enabled: "false",
+ dataSet: "LD0/LLN0.DS01",
+ stage: "before",
+ buffered: false,
+ reservationState: "true");
+
+ Assert.Equal(
+ MmsConfiguredStaticActivationProofKind.BusyRuntimeEvidence,
+ MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(brcb, "LD0/LLN0.DS01").Kind);
+ Assert.Equal(
+ MmsConfiguredStaticActivationProofKind.BusyRuntimeEvidence,
+ MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(urcb, "LD0/LLN0.DS01").Kind);
+ }
+
+ [Fact]
+ public void BeforeWrite_Fails_Closed_When_Jit_Read_Fails()
+ {
+ var snapshot = Snapshot(success: false, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before");
+
+ var proof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(snapshot, "LD0/LLN0.DS01");
+
+ Assert.Equal(MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, proof.Kind);
+ Assert.False(proof.IsProven);
+ }
+
+ [Fact]
+ public void AfterEnable_One_Exact_Readback_Is_Sufficient()
+ {
+ var snapshot = Snapshot(
+ success: true,
+ enabled: "true",
+ dataSet: "LD0/LLN0.DS01",
+ stage: "after-enable");
+
+ var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(snapshot, "LD0/LLN0.DS01");
+
+ Assert.True(proof.IsProven);
+ Assert.Equal(MmsConfiguredStaticActivationProofKind.Proven, proof.Kind);
+ }
+
+ [Fact]
+ public void AfterEnable_Rejects_Write_Accepted_But_Readback_False()
+ {
+ var snapshot = Snapshot(
+ success: true,
+ enabled: "false",
+ dataSet: "LD0/LLN0.DS01",
+ stage: "after-enable");
+
+ var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(snapshot, "LD0/LLN0.DS01");
+
+ Assert.Equal(MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, proof.Kind);
+ Assert.False(proof.IsProven);
+ }
+
+ private static MmsReportRcbSnapshot Snapshot(
+ bool success,
+ string enabled,
+ string dataSet,
+ string stage,
+ bool buffered = true,
+ MmsRcbDataSetProbeState dataSetProbeState = MmsRcbDataSetProbeState.NotAttempted,
+ string reservationState = "",
+ string reservationTimeSeconds = "",
+ string owner = "")
+ => new()
+ {
+ Stage = stage,
+ IsSuccess = success,
+ Reference = buffered ? "LD0/LLN0.BR.B01" : "LD0/LLN0.RP.U01",
+ Buffered = buffered,
+ EnabledState = enabled,
+ DataSetReference = dataSet,
+ DataSetProbeState = dataSetProbeState,
+ ReservationState = reservationState,
+ ReservationTimeSeconds = reservationTimeSeconds,
+ Owner = owner,
+ Message = success ? "snapshot ok" : "snapshot failed"
+ };
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs
new file mode 100644
index 00000000..5031f57d
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs
@@ -0,0 +1,185 @@
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsConfiguredStaticRcbEligibilityPolicyTests
+{
+ [Fact]
+ public void Exact_Explicit_Free_Brcb_Remains_Preferred()
+ {
+ var snapshot = StaticSnapshot(
+ buffered: true,
+ availability: MmsRcbOperationalAvailability.Available,
+ confidence: MmsRcbAvailabilityConfidence.Exact,
+ reservationTime: "0");
+
+ var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate(
+ snapshot,
+ allowCallerOwned: true,
+ allowReducedMissingReservationEvidence: false);
+
+ Assert.True(result.IsEligible);
+ Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.ExplicitFree, result.Kind);
+ Assert.False(result.UsesReducedEvidence);
+ }
+
+ [Fact]
+ public void Missing_Brcb_Reservation_Evidence_Is_Blocked_By_Default()
+ {
+ var snapshot = StaticSnapshot(
+ buffered: true,
+ availability: MmsRcbOperationalAvailability.Unknown,
+ confidence: MmsRcbAvailabilityConfidence.Reduced,
+ reservationTime: string.Empty);
+
+ var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate(
+ snapshot,
+ allowCallerOwned: true,
+ allowReducedMissingReservationEvidence: false);
+
+ Assert.False(result.IsEligible);
+ Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.Blocked, result.Kind);
+ }
+
+ [Fact]
+ public void Explicit_OptIn_Allows_Only_Genuinely_Missing_Brcb_Reservation_Evidence()
+ {
+ var snapshot = StaticSnapshot(
+ buffered: true,
+ availability: MmsRcbOperationalAvailability.Unknown,
+ confidence: MmsRcbAvailabilityConfidence.Reduced,
+ reservationTime: "-");
+
+ var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate(
+ snapshot,
+ allowCallerOwned: true,
+ allowReducedMissingReservationEvidence: true);
+
+ Assert.True(result.IsEligible);
+ Assert.True(result.RequiresWrite);
+ Assert.True(result.UsesReducedEvidence);
+ Assert.Equal(
+ MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence,
+ result.Kind);
+ }
+
+ [Fact]
+ public void Malformed_Reservation_Evidence_Is_Not_Downgraded_To_Missing()
+ {
+ var snapshot = StaticSnapshot(
+ buffered: true,
+ availability: MmsRcbOperationalAvailability.Unknown,
+ confidence: MmsRcbAvailabilityConfidence.Reduced,
+ reservationTime: "vendor-weird");
+
+ var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate(
+ snapshot,
+ allowCallerOwned: true,
+ allowReducedMissingReservationEvidence: true);
+
+ Assert.False(result.IsEligible);
+ Assert.Contains("neither explicitly free", result.Reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Positive_Owner_Evidence_Always_Blocks_Reduced_Path()
+ {
+ var snapshot = StaticSnapshot(
+ buffered: true,
+ availability: MmsRcbOperationalAvailability.Unknown,
+ confidence: MmsRcbAvailabilityConfidence.Reduced,
+ reservationTime: string.Empty,
+ owner: "01020304");
+
+ var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate(
+ snapshot,
+ allowCallerOwned: true,
+ allowReducedMissingReservationEvidence: true);
+
+ Assert.False(result.IsEligible);
+ Assert.Contains("ownership/reservation", result.Reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Missing_Urcb_Resv_Can_Use_The_Same_Explicit_Reduced_Policy()
+ {
+ var snapshot = StaticSnapshot(
+ buffered: false,
+ availability: MmsRcbOperationalAvailability.Unknown,
+ confidence: MmsRcbAvailabilityConfidence.Reduced,
+ reservationState: string.Empty);
+
+ var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate(
+ snapshot,
+ allowCallerOwned: true,
+ allowReducedMissingReservationEvidence: true);
+
+ Assert.True(result.IsEligible);
+ Assert.Equal(
+ MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence,
+ result.Kind);
+ }
+
+ [Fact]
+ public void Dynamic_Empty_Rcb_Is_Never_Promoted_By_Configured_Static_Policy()
+ {
+ var snapshot = StaticSnapshot(
+ buffered: true,
+ availability: MmsRcbOperationalAvailability.NoDataSet,
+ confidence: MmsRcbAvailabilityConfidence.Exact,
+ reservationTime: string.Empty,
+ dataSetReference: string.Empty,
+ members: Array.Empty());
+
+ var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate(
+ snapshot,
+ allowCallerOwned: true,
+ allowReducedMissingReservationEvidence: true);
+
+ Assert.False(result.IsEligible);
+ Assert.Contains("configured static", result.Reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static MmsRcbAvailabilitySnapshot StaticSnapshot(
+ bool buffered,
+ MmsRcbOperationalAvailability availability,
+ MmsRcbAvailabilityConfidence confidence,
+ string reservationTime = "",
+ string reservationState = "",
+ string owner = "",
+ string dataSetReference = "LD0/LLN0.dsA",
+ IReadOnlyList? members = null)
+ {
+ members ??=
+ [
+ new MmsDataSetDirectoryMember
+ {
+ Domain = "LD0",
+ MmsItemName = "GGIO1$ST$Ind1",
+ UserReference = "LD0/GGIO1.Ind1",
+ FunctionalConstraint = "ST"
+ }
+ ];
+
+ return new MmsRcbAvailabilitySnapshot
+ {
+ Reference = buffered ? "LD0/LLN0.BR.B01" : "LD0/LLN0.RP.U01",
+ Domain = "LD0",
+ LogicalNode = "LLN0",
+ Name = buffered ? "B01" : "U01",
+ Buffered = buffered,
+ DataSetReference = dataSetReference,
+ DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded,
+ EnabledState = "false",
+ ReservationTimeSeconds = reservationTime,
+ ReservationState = reservationState,
+ Owner = owner,
+ DataSetDirectoryRead = true,
+ DataSetDirectorySuccess = members.Count > 0,
+ DataSetMemberCount = members.Count,
+ DataSetMembers = members,
+ Availability = availability,
+ Confidence = confidence
+ };
+ }
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs
index 1d112a7c..b709fbd8 100644
--- a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs
+++ b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs
@@ -17,6 +17,7 @@ public void DefaultOptions_AreBoundedAndFailClosed()
Assert.True(options.AllowCallerOwnedReports);
Assert.True(options.AllowStaticBrcb);
Assert.True(options.AllowStaticUrcb);
+ Assert.False(options.AllowConfiguredStaticWithMissingReservationEvidence);
Assert.True(options.AllowDynamicBrcb);
Assert.True(options.AllowDynamicUrcb);
Assert.True(options.AllowPollingFallback);
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs
index a8c9ee3a..0634f2f4 100644
--- a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs
+++ b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs
@@ -213,6 +213,76 @@ public void PollingDisabled_LeavesResidualUncovered_AndNeverCallsItMissing()
Assert.All(plan.Assignments, x => Assert.Contains("No absence conclusion", x.Reason, StringComparison.OrdinalIgnoreCase));
}
+ [Fact]
+ public void ConfiguredStatic_WithMissingReservationEvidence_RemainsPollingByDefault()
+ {
+ var signal = Signal(
+ "LD0/GGIO1.Ind1.stVal",
+ "LD0/GGIO1$ST$Ind1$stVal",
+ "ST",
+ "LD0/LLN0.dsA",
+ "LD0/GGIO1.Ind1");
+ var inventory = Inventory(Rcb("LD0/LLN0.BR.B01", true, "LD0/LLN0.dsA"));
+ var unknown = Copy(
+ StaticAvailable(
+ "LD0/LLN0.BR.B01",
+ true,
+ "LD0/LLN0.dsA",
+ Member("LD0", "GGIO1$ST$Ind1", "LD0/GGIO1.Ind1", "ST")),
+ availability: MmsRcbOperationalAvailability.Unknown,
+ reservationTimeSeconds: string.Empty,
+ confidence: MmsRcbAvailabilityConfidence.Reduced);
+
+ var plan = Build([signal], inventory, Availability(unknown), EmptyDirectory());
+
+ Assert.Equal(MmsHybridAcquisitionPlanStatus.PollingOnly, plan.Status);
+ Assert.Equal(0, plan.StaticBrcbSignalCount);
+ Assert.Equal(1, plan.PollingFallbackSignalCount);
+ Assert.Equal(0, plan.Capability.StaticUsableCount);
+ }
+
+ [Fact]
+ public void Explicit_Reduced_Static_OptIn_Uses_ConfiguredBrcb_BeforePolling()
+ {
+ var signal = Signal(
+ "LD0/GGIO1.Ind1.stVal",
+ "LD0/GGIO1$ST$Ind1$stVal",
+ "ST",
+ "LD0/LLN0.dsA",
+ "LD0/GGIO1.Ind1");
+ var inventory = Inventory(Rcb("LD0/LLN0.BR.B01", true, "LD0/LLN0.dsA"));
+ var unknown = Copy(
+ StaticAvailable(
+ "LD0/LLN0.BR.B01",
+ true,
+ "LD0/LLN0.dsA",
+ Member("LD0", "GGIO1$ST$Ind1", "LD0/GGIO1.Ind1", "ST")),
+ availability: MmsRcbOperationalAvailability.Unknown,
+ reservationTimeSeconds: string.Empty,
+ confidence: MmsRcbAvailabilityConfidence.Reduced);
+
+ var plan = Build(
+ [signal],
+ inventory,
+ Availability(unknown),
+ EmptyDirectory(),
+ new MmsHybridReportAcquisitionOptions
+ {
+ AllowConfiguredStaticWithMissingReservationEvidence = true
+ });
+
+ Assert.Equal(MmsHybridAcquisitionPlanStatus.FullReportCoverage, plan.Status);
+ Assert.Equal(1, plan.StaticBrcbSignalCount);
+ Assert.Equal(0, plan.PollingFallbackSignalCount);
+ Assert.Equal(1, plan.Capability.StaticUsableCount);
+ var segment = Assert.Single(plan.Segments);
+ Assert.Equal(MmsHybridAcquisitionKind.StaticBrcb, segment.Kind);
+ Assert.True(segment.RequiresWrite);
+ Assert.Contains("reduced", segment.Reason, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains(plan.Warnings, warning =>
+ warning.Contains("reduced reservation-evidence", StringComparison.OrdinalIgnoreCase));
+ }
+
[Fact]
public void Capability_DistinguishesDiscoveredCheckedUsableBusyAndUnknownRcbs()
{
@@ -389,7 +459,10 @@ private static MmsRcbAvailabilitySnapshot Copy(
MmsRcbAvailabilitySnapshot source,
MmsRcbOperationalAvailability? availability = null,
string? enabledState = null,
- string? reservationState = null)
+ string? reservationState = null,
+ string? reservationTimeSeconds = null,
+ string? owner = null,
+ MmsRcbAvailabilityConfidence? confidence = null)
=> new()
{
CheckedAtUtc = source.CheckedAtUtc,
@@ -410,15 +483,15 @@ private static MmsRcbAvailabilitySnapshot Copy(
OptionalFields = source.OptionalFields,
EnabledState = enabledState ?? source.EnabledState,
ReservationState = reservationState ?? source.ReservationState,
- ReservationTimeSeconds = source.ReservationTimeSeconds,
- Owner = source.Owner,
+ ReservationTimeSeconds = reservationTimeSeconds ?? source.ReservationTimeSeconds,
+ Owner = owner ?? source.Owner,
DataSetDirectoryRead = source.DataSetDirectoryRead,
DataSetDirectorySuccess = source.DataSetDirectorySuccess,
DataSetIsDeletable = source.DataSetIsDeletable,
DataSetMemberCount = source.DataSetMemberCount,
DataSetMembers = source.DataSetMembers,
Availability = availability ?? source.Availability,
- Confidence = source.Confidence,
+ Confidence = confidence ?? source.Confidence,
Reason = source.Reason,
ProbeDiagnostics = source.ProbeDiagnostics
};
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs
new file mode 100644
index 00000000..cd5ed253
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs
@@ -0,0 +1,123 @@
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsRcbAvailabilityTargetSelectorTests
+{
+ [Fact]
+ public void Empty_Target_Set_Preserves_Broad_Diagnostic_Mode()
+ {
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.B01", buffered: true),
+ Candidate("LD0/LLN0.RP.U01", buffered: false));
+
+ var selection = MmsRcbAvailabilityTargetSelector.Select(
+ inventory,
+ new MmsRcbAvailabilityOptions());
+
+ Assert.False(selection.TargetFilterApplied);
+ Assert.Equal(2, selection.Candidates.Count);
+ Assert.Empty(selection.Warnings);
+ }
+
+ [Fact]
+ public void Exact_Targets_Probe_Only_Selected_Live_Rcbs()
+ {
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.B01", buffered: true),
+ Candidate("LD0/LLN0.BR.B02", buffered: true),
+ Candidate("LD0/LLN0.RP.U01", buffered: false));
+
+ var selection = MmsRcbAvailabilityTargetSelector.Select(
+ inventory,
+ new MmsRcbAvailabilityOptions
+ {
+ TargetReportControlReferences = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "LD0/LLN0$BR$B02",
+ "LD0/LLN0.RP.U01"
+ }
+ });
+
+ Assert.True(selection.TargetFilterApplied);
+ Assert.Equal(2, selection.RequestedTargetCount);
+ Assert.Equal(2, selection.MatchedTargetCount);
+ Assert.Equal(
+ new[] { "LD0/LLN0.BR.B02", "LD0/LLN0.RP.U01" },
+ selection.Candidates.Select(candidate => candidate.Reference).OrderBy(x => x, StringComparer.Ordinal).ToArray());
+ }
+
+ [Fact]
+ public void Missing_Target_Is_Not_Broadened_To_Sibling_Rcb()
+ {
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.Rpt01", buffered: true),
+ Candidate("LD0/LLN0.BR.Rpt02", buffered: true));
+
+ var selection = MmsRcbAvailabilityTargetSelector.Select(
+ inventory,
+ new MmsRcbAvailabilityOptions
+ {
+ TargetReportControlReferences = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "LD0/LLN0.BR.Rpt03"
+ }
+ });
+
+ Assert.Empty(selection.Candidates);
+ Assert.Equal(0, selection.MatchedTargetCount);
+ Assert.Contains(
+ selection.Warnings,
+ warning => warning.Contains("not broadened", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Targeted_Mode_Still_Respects_Hard_Maximum()
+ {
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.B01", buffered: true),
+ Candidate("LD0/LLN0.BR.B02", buffered: true),
+ Candidate("LD0/LLN0.BR.B03", buffered: true));
+
+ var targets = inventory.ReportControls
+ .Select(candidate => candidate.Reference)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var selection = MmsRcbAvailabilityTargetSelector.Select(
+ inventory,
+ new MmsRcbAvailabilityOptions
+ {
+ MaxReportControls = 2,
+ TargetReportControlReferences = targets
+ });
+
+ Assert.Equal(3, selection.EligibleCountBeforeLimit);
+ Assert.Equal(2, selection.Candidates.Count);
+ Assert.Contains(
+ selection.Warnings,
+ warning => warning.Contains("bounded to 2 of 3", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static MmsReportInventory Inventory(params MmsReportControlCandidate[] candidates)
+ {
+ var inventory = new MmsReportInventory();
+ inventory.ReportControls.AddRange(candidates);
+ return inventory;
+ }
+
+ private static MmsReportControlCandidate Candidate(string reference, bool buffered)
+ {
+ var slash = reference.IndexOf('/');
+ var tail = reference[(slash + 1)..].Split('.');
+ return new MmsReportControlCandidate
+ {
+ Domain = reference[..slash],
+ LogicalNode = tail[0],
+ FunctionalConstraint = tail[1],
+ Name = tail[2],
+ Reference = reference,
+ Buffered = buffered,
+ DataSetReference = "LD0/LLN0.Events"
+ };
+ }
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs
index 31451bd1..4910d871 100644
--- a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs
+++ b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs
@@ -99,6 +99,69 @@ public async Task Route_FansOutInformationReportWithoutStealingLegacyQueue()
Assert.True(queued.IsInformationReport);
}
+ [Fact]
+ public async Task WaitForInformationReportAsync_ReturnsImmediately_WhenQueueAlreadyHasReport()
+ {
+ var router = new MmsReceiveRouter();
+ router.Route(BuildInformationReport());
+
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1));
+ var available = await router.WaitForInformationReportAsync(
+ TimeSpan.FromSeconds(1),
+ timeout.Token);
+
+ Assert.True(available);
+ Assert.True(router.TryDequeueInformationReport(out var queued));
+ Assert.True(queued.IsInformationReport);
+ }
+
+ [Fact]
+ public async Task WaitForInformationReportAsync_WakesFromFutureReport_WithoutPolling()
+ {
+ var router = new MmsReceiveRouter();
+
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1));
+ var wait = router.WaitForInformationReportAsync(
+ TimeSpan.FromSeconds(1),
+ timeout.Token);
+
+ await Task.Yield();
+ Assert.False(wait.IsCompleted);
+
+ router.Route(BuildInformationReport());
+
+ Assert.True(await wait);
+ Assert.True(router.TryDequeueInformationReport(out var queued));
+ Assert.True(queued.IsInformationReport);
+ }
+
+ [Fact]
+ public async Task WaitForInformationReportAsync_CoalescesBurstIntoSingleWake()
+ {
+ var router = new MmsReceiveRouter();
+ router.Route(BuildInformationReport());
+ router.Route(BuildInformationReport());
+
+ Assert.True(await router.WaitForInformationReportAsync(TimeSpan.FromSeconds(1)));
+ Assert.True(router.TryDequeueInformationReport(out _));
+ Assert.True(router.TryDequeueInformationReport(out _));
+
+ var staleWake = await router.WaitForInformationReportAsync(TimeSpan.FromMilliseconds(20));
+ Assert.False(staleWake);
+ }
+
+ [Fact]
+ public async Task WaitForInformationReportAsync_TimesOut_WhenNoReportArrives()
+ {
+ var router = new MmsReceiveRouter();
+
+ var available = await router.WaitForInformationReportAsync(
+ TimeSpan.FromMilliseconds(20));
+
+ Assert.False(available);
+ Assert.Equal(0, router.QueuedInformationReportCount);
+ }
+
[Fact]
public async Task Clear_FaultsActiveInformationReportSubscribers()
{
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs
new file mode 100644
index 00000000..0c16a681
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs
@@ -0,0 +1,158 @@
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsReportControlSemanticWriteTests
+{
+ [Fact]
+ public void ResvTms_Uses_Signed_Mms_Integer_On_Wire()
+ {
+ var write = MmsReportControlSemanticWrite.ReservationTime(60);
+
+ Assert.Equal("ResvTms", write.Attribute);
+ Assert.Equal(MmsDataKind.Integer, write.ExpectedKind);
+ Assert.Equal(MmsDataKind.Integer, write.Value.Kind);
+ Assert.Equal(60L, write.Value.Value);
+
+ var encoded = MmsDataCodec.Encode(write.Value);
+
+ Assert.NotEmpty(encoded);
+ Assert.Equal(0x85, encoded[0]); // MMS Data integer [5], never unsigned [6] (0x86).
+ }
+
+ [Theory]
+ [InlineData(-32768)]
+ [InlineData(-1)]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(60)]
+ [InlineData(32767)]
+ public void ResvTms_Accepts_Signed_Int16_Range(int seconds)
+ {
+ var write = MmsReportControlSemanticWrite.ReservationTime(seconds);
+
+ Assert.Equal(MmsDataKind.Integer, write.Value.Kind);
+ Assert.Equal((long)seconds, write.Value.Value);
+ }
+
+ [Theory]
+ [InlineData(-32769)]
+ [InlineData(32768)]
+ public void ResvTms_Rejects_Values_Outside_Signed_Int16_Range(int seconds)
+ {
+ Assert.Throws(
+ () => MmsReportControlSemanticWrite.ReservationTime(seconds));
+ }
+
+ [Fact]
+ public void Boolean_Rcb_Fields_Are_Bound_To_Mms_Boolean()
+ {
+ var values = new[]
+ {
+ MmsReportControlSemanticWrite.ReportEnable(true),
+ MmsReportControlSemanticWrite.Reservation(true),
+ MmsReportControlSemanticWrite.GeneralInterrogation(true)
+ };
+
+ Assert.Equal(new[] { "RptEna", "Resv", "GI" }, values.Select(x => x.Attribute));
+ Assert.All(values, write =>
+ {
+ Assert.Equal(MmsDataKind.Boolean, write.ExpectedKind);
+ Assert.Equal(MmsDataKind.Boolean, write.Value.Kind);
+ Assert.Equal(true, write.Value.Value);
+ Assert.Equal(0x83, MmsDataCodec.Encode(write.Value)[0]); // MMS Data boolean [3].
+ });
+ }
+
+ [Fact]
+ public void Live_Type_Compatibility_Is_Exact_And_Does_Not_Guess()
+ {
+ var integer = new MmsTypeSpecificationNode { MmsType = "integer" };
+ var unsigned = new MmsTypeSpecificationNode { MmsType = "unsigned" };
+ var boolean = new MmsTypeSpecificationNode { MmsType = "boolean" };
+
+ var resvTms = MmsReportControlSemanticWrite.ReservationTime(60);
+ Assert.True(resvTms.IsCompatibleWith(integer));
+ Assert.False(resvTms.IsCompatibleWith(unsigned));
+ Assert.False(resvTms.IsCompatibleWith(boolean));
+
+ var rptEna = MmsReportControlSemanticWrite.ReportEnable(true);
+ Assert.True(rptEna.IsCompatibleWith(boolean));
+ Assert.False(rptEna.IsCompatibleWith(integer));
+ Assert.False(rptEna.IsCompatibleWith(null));
+ }
+ [Fact]
+ public void Exact_Live_Type_Mismatch_Is_A_Positive_Blocker()
+ {
+ var write = MmsReportControlSemanticWrite.ReservationTime(60);
+ var live = new MmsVariableAccessAttributesResult
+ {
+ IsSuccess = true,
+ Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$ResvTms", "BR"),
+ TypeSpecification = new MmsTypeSpecificationNode
+ {
+ MmsType = "unsigned",
+ SclBType = "INT32U"
+ }
+ };
+
+ var evidence = MmsReportSemanticTypePolicy.Evaluate(
+ write,
+ "LD0/LLN0.BR.Brcb01.ResvTms [BR]",
+ live);
+
+ Assert.Equal(MmsReportSemanticTypeEvidenceStatus.ExactMismatch, evidence.Status);
+ Assert.True(evidence.IsExact);
+ Assert.False(evidence.AllowsMutation);
+ Assert.Equal("integer", evidence.ExpectedMmsType);
+ Assert.Equal("unsigned", evidence.LiveMmsType);
+ }
+
+ [Fact]
+ public void Missing_Live_Type_Evidence_Does_Not_Become_A_False_Blocker()
+ {
+ var write = MmsReportControlSemanticWrite.ReportEnable(true);
+ var live = new MmsVariableAccessAttributesResult
+ {
+ IsSuccess = false,
+ Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$RptEna", "BR"),
+ Message = "GetVariableAccessAttributes unsupported by this server."
+ };
+
+ var evidence = MmsReportSemanticTypePolicy.Evaluate(
+ write,
+ "LD0/LLN0.BR.Brcb01.RptEna [BR]",
+ live);
+
+ Assert.Equal(MmsReportSemanticTypeEvidenceStatus.Unavailable, evidence.Status);
+ Assert.False(evidence.IsExact);
+ Assert.True(evidence.AllowsMutation);
+ Assert.Contains("IEC semantic contract", evidence.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Exact_Live_Type_Match_Confirms_Semantic_Mutation()
+ {
+ var write = MmsReportControlSemanticWrite.ReportEnable(true);
+ var live = new MmsVariableAccessAttributesResult
+ {
+ IsSuccess = true,
+ Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$RptEna", "BR"),
+ TypeSpecification = new MmsTypeSpecificationNode
+ {
+ MmsType = "boolean",
+ SclBType = "BOOLEAN"
+ }
+ };
+
+ var evidence = MmsReportSemanticTypePolicy.Evaluate(
+ write,
+ "LD0/LLN0.BR.Brcb01.RptEna [BR]",
+ live);
+
+ Assert.Equal(MmsReportSemanticTypeEvidenceStatus.ExactMatch, evidence.Status);
+ Assert.True(evidence.IsExact);
+ Assert.True(evidence.AllowsMutation);
+ }
+
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs
index 1844f73e..d4ac0166 100644
--- a/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs
+++ b/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs
@@ -27,6 +27,7 @@ public void BuildInventoryFindsDataSetsAndReportControls()
var inventory = MmsReportDiscoveryMapper.BuildInventory(snapshot);
+ Assert.Equal(MmsReportInventoryAuthority.LiveMmsObserved, inventory.Authority);
Assert.Single(inventory.DataSets);
Assert.Equal("IED1LD0/LLN0.DataSet", inventory.DataSets[0].Reference);
Assert.Equal(2, inventory.ReportControls.Count);
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs
index e1e84ef1..eabc10d6 100644
--- a/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs
+++ b/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs
@@ -108,4 +108,70 @@ public void Diagnostics_treats_sequence_zero_after_nonzero_as_reset_warning_not_
Assert.Contains(diagnostics.WarningMessages, x => x.Contains("reset-to-zero", StringComparison.OrdinalIgnoreCase));
}
+ [Fact]
+ public void Diagnostics_surfaces_structured_RCB_type_and_preflight_failures()
+ {
+ var writes = new[]
+ {
+ new MmsReportAttributeWriteStep
+ {
+ Attribute = "ResvTms",
+ Reference = "LD0/LLN0.BR.B01.ResvTms",
+ Attempted = true,
+ IsSuccess = false,
+ FailureCode = 7,
+ FailureName = "type-inconsistent",
+ FailureKind = MmsInteropFailureKind.TypeMismatch,
+ RecoveryHint = MmsInteropRecoveryHint.RevalidateType,
+ TypeEvidence = new MmsReportSemanticTypeEvidence
+ {
+ Status = MmsReportSemanticTypeEvidenceStatus.Unavailable,
+ Attribute = "ResvTms",
+ ExpectedMmsType = "integer",
+ Message = "Live TypeSpecification unavailable."
+ }
+ },
+ new MmsReportAttributeWriteStep
+ {
+ Attribute = "RptEna",
+ Reference = "LD0/LLN0.BR.B01.RptEna",
+ Attempted = false,
+ IsSuccess = false,
+ TypeEvidence = new MmsReportSemanticTypeEvidence
+ {
+ Status = MmsReportSemanticTypeEvidenceStatus.ExactMismatch,
+ Attribute = "RptEna",
+ ExpectedMmsType = "boolean",
+ LiveMmsType = "integer"
+ }
+ },
+ new MmsReportAttributeWriteStep
+ {
+ Attribute = "GI",
+ Reference = "LD0/LLN0.BR.B01.GI",
+ Attempted = true,
+ IsSuccess = false,
+ FailureCode = 3,
+ FailureName = "object-access-denied",
+ FailureKind = MmsInteropFailureKind.AccessDenied,
+ RecoveryHint = MmsInteropRecoveryHint.FailClosed
+ }
+ };
+
+ var diagnostics = MmsReportSessionDiagnostics.Analyze(
+ Array.Empty(),
+ writeSteps: writes);
+
+ Assert.Equal("FAIL", diagnostics.OverallStatus);
+ Assert.Equal(3, diagnostics.WriteFailureCount);
+ Assert.Equal(1, diagnostics.WritePreflightBlockedCount);
+ Assert.Equal(1, diagnostics.WriteTypeMismatchCount);
+ Assert.Equal(1, diagnostics.WriteAccessDeniedCount);
+ Assert.Equal(1, diagnostics.WriteTypeEvidenceConflictCount);
+ Assert.Equal(1, diagnostics.WriteTypeEvidenceUnavailableCount);
+ Assert.Contains("typeMismatch=1", diagnostics.Summary, StringComparison.Ordinal);
+ Assert.Contains("preflightBlocked=1", diagnostics.Summary, StringComparison.Ordinal);
+ }
+
+
}
diff --git a/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs
index d3235ef4..e048ebc5 100644
--- a/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs
+++ b/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs
@@ -57,6 +57,17 @@ public void Brcb_Explicit_Reservation_Is_Retry_Only()
fallback);
}
+ [Fact]
+ public void ConfiguredStaticActivation_Is_SourceNeutral_PublicEntryPoint()
+ {
+ var configured = typeof(MmsClientSession).GetMethod(nameof(MmsClientSession.StartConfiguredStaticReportMonitorAsync));
+ var scl = typeof(MmsClientSession).GetMethod(nameof(MmsClientSession.StartStaticSclReportMonitorAsync));
+
+ Assert.NotNull(configured);
+ Assert.NotNull(scl);
+ Assert.Equal(configured!.ReturnType, scl!.ReturnType);
+ }
+
[Fact]
public void Urcb_Has_No_Brcb_Reservation_Fallback()
{
diff --git a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs
index 6110c990..5ec16bd8 100644
--- a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs
+++ b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs
@@ -57,7 +57,7 @@ public void DomainInventoryReader_Fails_Closed_When_Selected_AccessPoint_Has_No_
Assert.False(inventory.IsSuccess);
Assert.Empty(inventory.ExpectedDomains);
- Assert.Contains(inventory.Errors, error => error.Contains("no direct Server", StringComparison.Ordinal));
+ Assert.Contains(inventory.Errors, error => error.Contains("no resolvable Server/ServerAt model", StringComparison.Ordinal));
}
[Fact]
diff --git a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs
new file mode 100644
index 00000000..cecab309
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs
@@ -0,0 +1,146 @@
+using AR.Iec61850.Scl;
+using AR.Iec61850.Scl.Engineering;
+using AR.Iec61850.Scl.Workspace;
+
+namespace AR.Iec61850.Tests.Scl;
+
+public sealed class StationServerAtRegressionTests
+{
+ [Fact]
+ public void Parse_SiemensStyle_7Sx85_ServerAt_Preserves_Model_On_Both_Mms_Endpoints()
+ {
+ // Sanitized topology derived from a real Siemens station SCD.
+ // Do not commit the customer's exported SCD or network configuration.
+ var document = new SclWorkspaceService().Parse(StationFixture(), "station.scd");
+
+ Assert.Equal(2, document.MmsEndpoints.Count);
+ Assert.Equal(2, document.Ieds.Count);
+
+ var j = document.Ieds.Single(ied => ied.AccessPointName == "J");
+ var f = document.Ieds.Single(ied => ied.AccessPointName == "F");
+
+ Assert.Equal("192.0.2.11", j.PreferredEndpoint!.IpAddress);
+ Assert.Equal("198.51.100.12", f.PreferredEndpoint!.IpAddress);
+ Assert.Equal(102, j.PreferredEndpoint.Port);
+ Assert.Equal(102, f.PreferredEndpoint.Port);
+ Assert.False(j.RequiresEndpointBinding);
+ Assert.False(f.RequiresEndpointBinding);
+ Assert.True(j.CanBrowseOffline);
+ Assert.True(f.CanBrowseOffline);
+ Assert.Single(j.DesignModel.LogicalDevices);
+ Assert.Single(f.DesignModel.LogicalDevices);
+ Assert.Equal(j.DesignModel.LogicalDevices[0].MmsDomain, f.DesignModel.LogicalDevices[0].MmsDomain);
+ Assert.Equal("GR_X_7SX85Application", f.DesignModel.LogicalDevices[0].MmsDomain);
+ Assert.Equal("F", f.DesignModel.AccessPointName);
+ Assert.Equal("198.51.100.12", f.DesignModel.Host);
+ Assert.Single(f.DataSets);
+ Assert.Single(f.ReportControls);
+ Assert.DoesNotContain(f.Findings, finding => finding.Code == "SCL_ACCESS_POINT_WITHOUT_SERVER");
+ Assert.DoesNotContain(f.Findings, finding => finding.Code == "SCL_SERVER_AT_UNRESOLVED");
+ }
+
+ [Fact]
+ public void Engineering_Profile_Valid_ServerAt_Is_Not_Reported_As_Missing_Server()
+ {
+ var profile = new SclEngineeringProfileBuilder().Parse(StationFixture(), "station.scd");
+
+ Assert.Equal(2, profile.AccessPoints.Count);
+ var f = profile.AccessPoints.Single(ap => ap.Name == "F");
+ Assert.False(f.HasServer); // Not a physical Server: references J via ServerAt.
+ Assert.Equal(1, f.LogicalDeviceCount);
+ Assert.DoesNotContain(profile.Findings, item =>
+ item.Code == "SCL_ACCESS_POINT_WITHOUT_SERVER" ||
+ item.Code == "SCL_SERVER_AT_UNRESOLVED");
+ }
+
+ [Fact]
+ public void SclAssisted_Preparation_Resolves_F_ServerAt_To_J_Domains_And_Fc_Roots()
+ {
+ var xml = StationFixture();
+ var domainsJ = SclMmsDomainInventoryReader.Read(xml, "GR_X_7SX85", "J");
+ var domainsF = SclMmsDomainInventoryReader.Read(xml, "GR_X_7SX85", "F");
+
+ Assert.True(domainsJ.IsSuccess);
+ Assert.True(domainsF.IsSuccess);
+ Assert.Equal(domainsJ.ExpectedDomains, domainsF.ExpectedDomains);
+ Assert.Equal("F", domainsF.AccessPointName);
+ Assert.Single(domainsF.ExpectedDomains);
+
+ var initialJ = SclInitialFcReadDesignBuilder.Read(xml, "GR_X_7SX85", "J");
+ var initialF = SclInitialFcReadDesignBuilder.Read(xml, "GR_X_7SX85", "F");
+ Assert.True(initialJ.IsSuccess, string.Join(" | ", initialJ.Errors));
+ Assert.True(initialF.IsSuccess, string.Join(" | ", initialF.Errors));
+ Assert.Equal("F", initialF.Model.AccessPointName);
+ Assert.Equal(initialJ.Model.LogicalDevices.Count, initialF.Model.LogicalDevices.Count);
+ Assert.Equal(initialJ.Model.DataSets.Count, initialF.Model.DataSets.Count);
+ Assert.Equal(initialJ.Model.ReportControls.Count, initialF.Model.ReportControls.Count);
+ Assert.Single(initialF.Model.LogicalDevices);
+ }
+
+ [Fact]
+ public void Dangling_ServerAt_Fails_Closed_Without_Fabricating_Mms_Model_Or_Losing_Ip()
+ {
+ var broken = StationFixture().Replace(
+ "",
+ "",
+ StringComparison.Ordinal);
+ var document = new SclWorkspaceService().Parse(broken, "dangling.scd");
+
+ var j = document.Ieds.Single(ied => ied.AccessPointName == "J");
+ var f = document.Ieds.Single(ied => ied.AccessPointName == "F");
+ Assert.True(j.CanBrowseOffline);
+ Assert.False(f.CanBrowseOffline);
+ Assert.Equal("198.51.100.12", f.PreferredEndpoint!.IpAddress);
+ Assert.Contains(document.Findings, finding =>
+ finding.Code == "SCL_SERVER_AT_UNRESOLVED" && finding.Severity == "High");
+ var invalidDomains = SclMmsDomainInventoryReader.Read(broken, "GR_X_7SX85", "F");
+ var invalidInitial = SclInitialFcReadDesignBuilder.Read(broken, "GR_X_7SX85", "F");
+ Assert.False(invalidDomains.IsSuccess);
+ Assert.False(invalidInitial.IsSuccess);
+ Assert.Empty(invalidDomains.ExpectedDomains);
+ }
+
+ private static string StationFixture() => """
+
+
+
+
+
+ 192.0.2.11
+
+
+
+
+ 198.51.100.12
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+}