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