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/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/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/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);