From 9b6d76864544ee1b78620413089ff2dedd0416c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:34:43 +0700 Subject: [PATCH 01/13] feat(reporting): add source-aware canonical static acquisition preflight (#144) --- .../MmsCanonicalStaticAcquisitionPreflight.cs | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs new file mode 100644 index 00000000..a45249d0 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs @@ -0,0 +1,264 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +/// +/// Declares where a ReportControl inventory came from. Operational target selection must +/// never mistake an SCL/design projection for live MMS evidence. +/// +public enum MmsReportInventoryAuthority +{ + Unknown, + LiveMmsObserved, + SclDesignProjection +} + +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, + MmsReportInventoryAuthority inventoryAuthority) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(selections); + ArgumentNullException.ThrowIfNull(inventory); + + var requested = selections.ToArray(); + var coverage = CanonicalStaticReportCoverageResolver.Resolve(model, requested); + return Build(coverage, inventory, inventoryAuthority); + } + + public static MmsCanonicalStaticAcquisitionProbePlan Build( + CanonicalStaticReportCoveragePlan coverage, + MmsReportInventory inventory, + MmsReportInventoryAuthority inventoryAuthority) + { + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(inventory); + + var warnings = new List(); + + if (coverage.RequestedSignalCount == 0) + { + return Result( + coverage, + inventoryAuthority, + 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, + inventoryAuthority, + new MmsCanonicalStaticRcbTargetResolution(), + MmsCanonicalStaticAcquisitionProbeStatus.NoConfiguredStaticCoverage, + warnings); + } + + if (inventoryAuthority != 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, + inventoryAuthority, + 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, + inventoryAuthority, + 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, inventoryAuthority, 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 +{ + /// + /// 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 (!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() + }; + } +} From 25d75fd1b514f22931c17eb64175076d25750f34 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:35:22 +0700 Subject: [PATCH 02/13] test(reporting): lock source-aware canonical acquisition preflight (#144) --- ...anonicalStaticAcquisitionPreflightTests.cs | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs new file mode 100644 index 00000000..15dac8c2 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs @@ -0,0 +1,239 @@ +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( + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + inventory, + MmsReportInventoryAuthority.SclDesignProjection); + + 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( + 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, + MmsReportInventoryAuthority.LiveMmsObserved); + + 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( + 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, + MmsReportInventoryAuthority.LiveMmsObserved); + + 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( + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + inventory, + MmsReportInventoryAuthority.SclDesignProjection); + + 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 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( + 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.Scl, CanonicalConfidence.Exact)) + ], + ReportControls = + [ + new CanonicalReportControl + { + Reference = rcbReference, + DataSetReference = dataSet, + Buffered = buffered + } + ] + }; + + 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 + }; +} From 3b93bfd48e6d676b2e09e1c9e89752dd33b86d64 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:37:18 +0700 Subject: [PATCH 03/13] feat(reporting): carry report inventory authority with the inventory (#144) --- src/AR.Iec61850/Mms/MmsReportInventory.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsReportInventory.cs b/src/AR.Iec61850/Mms/MmsReportInventory.cs index 9bc43697..dc6bf756 100644 --- a/src/AR.Iec61850/Mms/MmsReportInventory.cs +++ b/src/AR.Iec61850/Mms/MmsReportInventory.cs @@ -1,5 +1,12 @@ namespace AR.Iec61850.Mms; +public enum MmsReportInventoryAuthority +{ + Unknown, + LiveMmsObserved, + SclDesignProjection +} + public enum MmsRcbDataSetProbeState { NotAttempted, @@ -47,6 +54,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(); From 6baddc2d5ff38beed85fac798150cfc6d7ccbd4d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:37:22 +0700 Subject: [PATCH 04/13] feat(reporting): stamp MMS-discovered inventories as live observed evidence (#144) --- src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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; From b009965c6fd4ac87ba8d10728e979c9963ca7567 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:37:38 +0700 Subject: [PATCH 05/13] refactor(reporting): make inventory provenance self-describing (#144) --- .../MmsCanonicalStaticAcquisitionPreflight.cs | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs index a45249d0..a74cfe70 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs @@ -2,17 +2,6 @@ namespace AR.Iec61850.Mms; -/// -/// Declares where a ReportControl inventory came from. Operational target selection must -/// never mistake an SCL/design projection for live MMS evidence. -/// -public enum MmsReportInventoryAuthority -{ - Unknown, - LiveMmsObserved, - SclDesignProjection -} - public enum MmsCanonicalStaticAcquisitionProbeStatus { NoSelection, @@ -86,8 +75,7 @@ public static class MmsCanonicalStaticAcquisitionPreflightPlanner public static MmsCanonicalStaticAcquisitionProbePlan Build( CanonicalIedModel model, IEnumerable selections, - MmsReportInventory inventory, - MmsReportInventoryAuthority inventoryAuthority) + MmsReportInventory inventory) { ArgumentNullException.ThrowIfNull(model); ArgumentNullException.ThrowIfNull(selections); @@ -95,13 +83,12 @@ public static MmsCanonicalStaticAcquisitionProbePlan Build( var requested = selections.ToArray(); var coverage = CanonicalStaticReportCoverageResolver.Resolve(model, requested); - return Build(coverage, inventory, inventoryAuthority); + return Build(coverage, inventory); } public static MmsCanonicalStaticAcquisitionProbePlan Build( CanonicalStaticReportCoveragePlan coverage, - MmsReportInventory inventory, - MmsReportInventoryAuthority inventoryAuthority) + MmsReportInventory inventory) { ArgumentNullException.ThrowIfNull(coverage); ArgumentNullException.ThrowIfNull(inventory); @@ -112,7 +99,7 @@ public static MmsCanonicalStaticAcquisitionProbePlan Build( { return Result( coverage, - inventoryAuthority, + inventory.Authority, new MmsCanonicalStaticRcbTargetResolution(), MmsCanonicalStaticAcquisitionProbeStatus.NoSelection, warnings); @@ -123,20 +110,20 @@ public static MmsCanonicalStaticAcquisitionProbePlan Build( warnings.Add("No selected signal has configured static DataSet/RCB coverage; no operational RCB probe is required."); return Result( coverage, - inventoryAuthority, + inventory.Authority, new MmsCanonicalStaticRcbTargetResolution(), MmsCanonicalStaticAcquisitionProbeStatus.NoConfiguredStaticCoverage, warnings); } - if (inventoryAuthority != MmsReportInventoryAuthority.LiveMmsObserved) + 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, - inventoryAuthority, + inventory.Authority, new MmsCanonicalStaticRcbTargetResolution(), MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, warnings); @@ -151,7 +138,7 @@ public static MmsCanonicalStaticAcquisitionProbePlan Build( "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, - inventoryAuthority, + inventory.Authority, targetResolution, MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsUnresolved, warnings); @@ -163,18 +150,18 @@ public static MmsCanonicalStaticAcquisitionProbePlan Build( ? MmsCanonicalStaticAcquisitionProbeStatus.PartialExactTargetsReady : MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady; - return Result(coverage, inventoryAuthority, targetResolution, status, warnings); + return Result(coverage, inventory.Authority, targetResolution, status, warnings); } private static MmsCanonicalStaticAcquisitionProbePlan Result( CanonicalStaticReportCoveragePlan coverage, - MmsReportInventoryAuthority inventoryAuthority, + MmsReportInventoryAuthority inventory.Authority, MmsCanonicalStaticRcbTargetResolution targetResolution, MmsCanonicalStaticAcquisitionProbeStatus status, IReadOnlyList warnings) => new() { - InventoryAuthority = inventoryAuthority, + InventoryAuthority = inventory.Authority, Coverage = coverage, TargetResolution = targetResolution, Status = status, From 646b802e338b27e2787778b08f46adf4d2f04233 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:38:02 +0700 Subject: [PATCH 06/13] fix(reporting): bind P6 plan and execution to inventory provenance (#144) --- .../MmsCanonicalStaticAcquisitionPreflight.cs | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs index a74cfe70..f1d66004 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs @@ -155,13 +155,13 @@ public static MmsCanonicalStaticAcquisitionProbePlan Build( private static MmsCanonicalStaticAcquisitionProbePlan Result( CanonicalStaticReportCoveragePlan coverage, - MmsReportInventoryAuthority inventory.Authority, + MmsReportInventoryAuthority inventoryAuthority, MmsCanonicalStaticRcbTargetResolution targetResolution, MmsCanonicalStaticAcquisitionProbeStatus status, IReadOnlyList warnings) => new() { - InventoryAuthority = inventory.Authority, + InventoryAuthority = inventoryAuthority, Coverage = coverage, TargetResolution = targetResolution, Status = status, @@ -175,6 +175,35 @@ private static MmsCanonicalStaticAcquisitionProbePlan Result( 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. @@ -190,6 +219,23 @@ public async Task ProbeCanonicalStatic 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 From 10789a4a4b63f5c9c98981e989950678944eb944 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:38:23 +0700 Subject: [PATCH 07/13] test(reporting): bind P6 tests to self-describing inventory authority (#144) --- ...anonicalStaticAcquisitionPreflightTests.cs | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs index 15dac8c2..e3fe05fd 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs @@ -16,12 +16,12 @@ public void SclProjectedInventory_IsNeverPromotedToLiveOperationalTargetEvidence "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, - MmsReportInventoryAuthority.SclDesignProjection); + inventory); Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, plan.Status); Assert.False(plan.HasExactOperationalTargets); @@ -41,13 +41,13 @@ public void LiveInventory_ProducesOnlyExactStaticTargets() "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, - MmsReportInventoryAuthority.LiveMmsObserved); + inventory); Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady, plan.Status); Assert.True(plan.HasExactOperationalTargets); @@ -74,14 +74,14 @@ public void PartialResolution_RemainsExplicit_AndNeverBroadensUnresolvedSegment( "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, - MmsReportInventoryAuthority.LiveMmsObserved); + inventory); Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.PartialExactTargetsReady, plan.Status); Assert.Equal(1, plan.ResolvedSegmentCount); @@ -102,11 +102,11 @@ public async Task NonOperationalPlan_DoesNotRequireConnectedSession() 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, - MmsReportInventoryAuthority.SclDesignProjection); + inventory); await using var session = new MmsClientSession(); var result = await session.ProbeCanonicalStaticAcquisitionAsync(plan, inventory); @@ -116,6 +116,36 @@ public async Task NonOperationalPlan_DoesNotRequireConnectedSession() 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() { @@ -153,6 +183,7 @@ public async Task TargetBudgetFailsBeforeAnyNetworkWork_AndNeverRunsPartialProbe 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)), @@ -219,9 +250,14 @@ private static CanonicalStaticReportCoverageSegment Segment( ] }; - private static MmsReportInventory Inventory(params MmsReportControlCandidate[] controls) + private static MmsReportInventory Inventory( + MmsReportInventoryAuthority authority, + params MmsReportControlCandidate[] controls) { - var inventory = new MmsReportInventory(); + var inventory = new MmsReportInventory + { + Authority = authority + }; inventory.ReportControls.AddRange(controls); return inventory; } From 1cea2dc206b748d94cc3eb325b33e5462a58a1e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:39:47 +0700 Subject: [PATCH 08/13] test(reporting): prove live MMS inventory authority is stamped at discovery (#144) --- tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs | 1 + 1 file changed, 1 insertion(+) 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); From d0a318aa592b3917539dfc77ccfb708083f403bc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:41:50 +0700 Subject: [PATCH 09/13] feat(reporting): distinguish canonical structural inventory projection (#144) --- src/AR.Iec61850/Mms/MmsReportInventory.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsReportInventory.cs b/src/AR.Iec61850/Mms/MmsReportInventory.cs index dc6bf756..4bab6819 100644 --- a/src/AR.Iec61850/Mms/MmsReportInventory.cs +++ b/src/AR.Iec61850/Mms/MmsReportInventory.cs @@ -4,7 +4,8 @@ public enum MmsReportInventoryAuthority { Unknown, LiveMmsObserved, - SclDesignProjection + SclDesignProjection, + CanonicalModelProjection } public enum MmsRcbDataSetProbeState From e7bf1cfa2125abcee7085a24d4eb85c4b9a7a892 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:41:52 +0700 Subject: [PATCH 10/13] feat(reporting): project canonical RCB structure without live-evidence fabrication (#144) --- .../MmsCanonicalReportInventoryProjection.cs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsCanonicalReportInventoryProjection.cs 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(); +} From b89f803ea5b053131b70127dc6a862e6e807a42e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:42:11 +0700 Subject: [PATCH 11/13] test(reporting): lock canonical structural inventory projection boundaries (#144) --- ...CanonicalReportInventoryProjectionTests.cs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs new file mode 100644 index 00000000..9599f327 --- /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.Scl + : 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.Scl + : CanonicalEvidenceSource.LiveMms, + CanonicalConfidence.Exact) + } + ] + }; +} From 0b628ff3a4aa51625c8176a858fa7f7d99297461 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:43:32 +0700 Subject: [PATCH 12/13] test(reporting): use canonical SCL evidence enum name (#144) --- .../Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs index e3fe05fd..484ebd86 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs @@ -237,7 +237,7 @@ private static CanonicalStaticReportCoverageSegment Segment( 0, selectedSignal[..selectedSignal.LastIndexOf('.')], selectedSignal.Contains("MMXU", StringComparison.Ordinal) ? "MX" : "ST", - new CanonicalProvenance(CanonicalEvidenceSource.Scl, CanonicalConfidence.Exact)) + new CanonicalProvenance(CanonicalEvidenceSource.SclDeclared, CanonicalConfidence.Exact)) ], ReportControls = [ From 2faaff016bb2e475534128f7ec87453fd4d4e62f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:43:35 +0700 Subject: [PATCH 13/13] test(reporting): use canonical SCL evidence enum name (#144) --- .../Mms/MmsCanonicalReportInventoryProjectionTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs index 9599f327..d8863e5e 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs @@ -113,7 +113,7 @@ private static CanonicalIedModel Model( "ST", new CanonicalProvenance( ingress == CanonicalIngressKind.SclFile - ? CanonicalEvidenceSource.Scl + ? CanonicalEvidenceSource.SclDeclared : CanonicalEvidenceSource.LiveMms, CanonicalConfidence.Exact)) ] @@ -137,7 +137,7 @@ private static CanonicalIedModel Model( IntegrityPeriodMs = "0", Provenance = new CanonicalProvenance( ingress == CanonicalIngressKind.SclFile - ? CanonicalEvidenceSource.Scl + ? CanonicalEvidenceSource.SclDeclared : CanonicalEvidenceSource.LiveMms, CanonicalConfidence.Exact) }