Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using AR.Iec61850.Engineering.Canonical;

namespace AR.Iec61850.Mms;

/// <summary>
/// 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.
/// </summary>
public static class MmsCanonicalStaticLiveCandidatePlanner
{
public static MmsCanonicalStaticLiveCandidatePage Select(
CanonicalStaticReportCoveragePlan coverage,
IReadOnlyList<MmsReportControlCandidate> 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<MmsReportControlCandidate> Candidates,
int CatalogCount,
int ExactReferenceCount,
int? NextOffset);
53 changes: 28 additions & 25 deletions src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ public sealed class MmsCanonicalStaticLiveReconciliationOptions
/// The operation fails closed rather than reading an arbitrary subset.
/// </summary>
public int MaxReportControlCandidates { get; init; } = 64;

/// <summary>Stable index for resuming deferred candidate READs in this inventory.</summary>
public int CandidateContinuationOffset { get; init; }
}

public sealed class MmsCanonicalStaticLiveReconciliationResult
Expand All @@ -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<string> Warnings { get; init; } = Array.Empty<string>();

public bool HasLiveInventory =>
Expand Down Expand Up @@ -280,31 +285,21 @@ public async Task<MmsCanonicalStaticLiveReconciliationResult> 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();

Expand All @@ -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<string>() :
[$"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();

Expand All @@ -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
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>();
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()
}]
};
}