diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs new file mode 100644 index 00000000..bb531efe --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs @@ -0,0 +1,56 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +/// +/// Selects exact configured RCB references ahead of bounded discovery fallback. +/// A reference match authorizes READ-only DatSet verification, not activation. +/// Unknown candidates remain deferred with a stable continuation offset. +/// +public static class MmsCanonicalStaticLiveCandidatePlanner +{ + public static MmsCanonicalStaticLiveCandidatePage Select( + CanonicalStaticReportCoveragePlan coverage, + IReadOnlyList candidates, + int maxReads, + int continuationOffset = 0) + { + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(candidates); + if (maxReads < 1) throw new ArgumentOutOfRangeException(nameof(maxReads)); + if (continuationOffset < 0) throw new ArgumentOutOfRangeException(nameof(continuationOffset)); + + var expected = coverage.Segments + .SelectMany(segment => segment.ReportControls) + .Select(report => report.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.Ordinal); + + var ordered = candidates + .OrderBy(candidate => candidate.Reference, StringComparer.Ordinal) + .ToArray(); + var exact = ordered.Where(candidate => expected.Contains(candidate.Reference)).ToArray(); + var unknown = ordered.Where(candidate => !expected.Contains(candidate.Reference)).ToArray(); + + // Discovery without an exact catalog reference is not evidence that an + // arbitrary first RCB belongs to the selected DataSet. Only bounded + // read-only DatSet checks may resolve it, and completion remains partial. + // If every configured target has an exact live catalog reference, + // never probe unrelated siblings. A failed DatSet verification remains + // unresolved rather than silently falling back to broad mutation. + var allTargetsMapped = expected.Count > 0 && expected.All(reference => + exact.Any(candidate => candidate.Reference == reference)); + var scope = allTargetsMapped ? exact : exact.Concat(unknown).ToArray(); + var selected = scope.Skip(continuationOffset).Take(maxReads).ToArray(); + var next = Math.Min(scope.Length, continuationOffset + selected.Length); + return new MmsCanonicalStaticLiveCandidatePage( + selected, ordered.Length, exact.Length, + next < scope.Length ? next : null); + } +} + +public sealed record MmsCanonicalStaticLiveCandidatePage( + IReadOnlyList Candidates, + int CatalogCount, + int ExactReferenceCount, + int? NextOffset); diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs index d2f3b82e..e33d9682 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs @@ -55,6 +55,9 @@ public sealed class MmsCanonicalStaticLiveReconciliationOptions /// The operation fails closed rather than reading an arbitrary subset. /// public int MaxReportControlCandidates { get; init; } = 64; + + /// Stable index for resuming deferred candidate READs in this inventory. + public int CandidateContinuationOffset { get; init; } } public sealed class MmsCanonicalStaticLiveReconciliationResult @@ -65,6 +68,8 @@ public sealed class MmsCanonicalStaticLiveReconciliationResult public int EnumeratedVariableCount { get; init; } public int CandidateReportControlCount { get; init; } public int DataSetBoundReportControlCount { get; init; } + public int? NextCandidateOffset { get; init; } + public int CandidateReadCount { get; init; } public IReadOnlyList Warnings { get; init; } = Array.Empty(); public bool HasLiveInventory => @@ -280,31 +285,21 @@ public async Task ReconcileCanonical .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 page = MmsCanonicalStaticLiveCandidatePlanner.Select( + coverage, candidates, maxCandidates, options.CandidateContinuationOffset); + // A page is a work budget, not an inventory completeness claim. + // Discovery never performs RCB writes or steals another association's reservation. var inventory = new MmsReportInventory { Authority = MmsReportInventoryAuthority.LiveMmsObserved }; - inventory.ReportControls.AddRange(candidates); + var expectedDataSets = coverage.Segments + .Select(segment => segment.DataSetReference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.Ordinal); - foreach (var candidate in candidates) + foreach (var candidate in page.Candidates) { cancellationToken.ThrowIfCancellationRequested(); @@ -322,18 +317,25 @@ await TryReadReportAttributeSmartAsync( .ConfigureAwait(false); } + // A catalog name alone never qualifies operational routing. + // DatSet must be read and match the selected static segment exactly. + if (expectedDataSets.Contains(candidate.DataSetReference)) + inventory.ReportControls.Add(candidate); + if (!IsMmsInitiated) break; } - var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete); + var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete) && page.NextOffset is null; var warnings = plan.Warnings + .Concat(page.NextOffset is null ? Array.Empty() : + [$"RCB candidate checks deferred; resume from candidate offset {page.NextOffset.Value}."]) .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.")) + .Concat(page.Candidates + .Where(candidate => !expectedDataSets.Contains(candidate.DataSetReference)) + .Select(candidate => $"Live RCB '{candidate.Reference}' is not proven bound to a selected exact DataSet.")) .Distinct(StringComparer.Ordinal) .ToArray(); @@ -348,8 +350,9 @@ await TryReadReportAttributeSmartAsync( Inventory = inventory, EnumeratedVariableCount = variables.Values.Sum(list => list.Count), CandidateReportControlCount = candidates.Length, - DataSetBoundReportControlCount = candidates.Count(candidate => - !string.IsNullOrWhiteSpace(candidate.DataSetReference)), + CandidateReadCount = page.Candidates.Count, + NextCandidateOffset = page.NextOffset, + DataSetBoundReportControlCount = inventory.ReportControls.Count, Warnings = warnings }; } diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveCandidatePlannerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveCandidatePlannerTests.cs new file mode 100644 index 00000000..e117440e --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveCandidatePlannerTests.cs @@ -0,0 +1,96 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticLiveCandidatePlannerTests +{ + [Theory] + [InlineData(2)] + [InlineData(66)] + [InlineData(300)] + public void Exact_Target_Reads_Are_Independent_Of_Global_Rcb_Count(int catalogSize) + { + var catalog = Enumerable.Range(0, catalogSize) + .Select(i => Candidate($"IEDLD0/LLN0.BR.R{i:000}")) + .Reverse().ToArray(); + var exact = $"IEDLD0/LLN0.BR.R{catalogSize-1:000}"; + var page = MmsCanonicalStaticLiveCandidatePlanner.Select( + Coverage(exact), catalog, maxReads: 4); + + Assert.Equal(catalogSize, page.CatalogCount); + Assert.Equal(1, page.ExactReferenceCount); + Assert.Equal(exact, Assert.Single(page.Candidates).Reference); + Assert.Null(page.NextOffset); + } + + [Fact] + public void Unresolved_Catalog_Pages_Resume_Without_Repeats_And_Never_Claim_Completeness() + { + var catalog = Enumerable.Range(0, 300) + .Select(i => Candidate($"IEDLD0/LLN0.BR.R{i:000}")).Reverse().ToArray(); + var coverage = Coverage("IEDLD0/LLN0.BR.UNKNOWN"); + var all = new List(); + int? offset = 0; + for (var pageNumber = 0; offset is not null; pageNumber++) + { + Assert.True(pageNumber < 10); + var page = MmsCanonicalStaticLiveCandidatePlanner.Select( + coverage, catalog, 64, offset.Value); + Assert.InRange(page.Candidates.Count, 1, 64); + all.AddRange(page.Candidates.Select(item => item.Reference)); + offset = page.NextOffset; + } + Assert.Equal(300, all.Count); + Assert.Equal(300, all.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(all.OrderBy(item => item, StringComparer.Ordinal), all); + } + + [Fact] + public void Exact_Multi_Target_Order_Is_Deterministic() + { + var catalog = Enumerable.Range(0, 66) + .Select(i => Candidate($"IEDLD0/LLN0.BR.R{i:000}")).Reverse().ToArray(); + var coverage = Coverage("IEDLD0/LLN0.BR.R030", "IEDLD0/LLN0.BR.R010"); + var page = MmsCanonicalStaticLiveCandidatePlanner.Select(coverage, catalog, 8); + Assert.Equal(new[] {"IEDLD0/LLN0.BR.R010", "IEDLD0/LLN0.BR.R030"}, + page.Candidates.Select(x => x.Reference)); + Assert.Null(page.NextOffset); + } + + private static MmsReportControlCandidate Candidate(string reference) => new() + { + Reference = reference, + Domain = "IEDLD0", + LogicalNode = "LLN0", + Buffered = true, + FunctionalConstraint = "BR", + Name = reference.Split('.').Last() + }; + + private static CanonicalStaticReportCoveragePlan Coverage(params string[] reports) => new() + { + Signals = [new CanonicalStaticReportSignalCoverage + { + RequestedReference = "IEDLD0/XCBR1.Pos.stVal", + CanonicalReference = "IEDLD0/XCBR1.Pos.stVal", + Status = CanonicalStaticReportSignalCoverageStatus.Covered + }], + Segments = [new CanonicalStaticReportCoverageSegment + { + DataSetReference = "IEDLD0/LLN0.Events", + SourceDataSetReference = "IEDLD0/LLN0.Events", + SelectedSignalReferences = ["IEDLD0/XCBR1.Pos.stVal"], + ReportControls = reports.Select(reference => new CanonicalReportControl + { + Reference = reference, + MmsDomain = "IEDLD0", + LogicalNode = "LLN0", + Buffered = true, + DataSetReference = "IEDLD0/LLN0.Events", + Provenance = new CanonicalProvenance( + CanonicalEvidenceSource.SclDeclared, CanonicalConfidence.Exact) + }).ToArray() + }] + }; +}