diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs
index 588da198e..aea39751d 100644
--- a/MainWindow.IedBrowser.cs
+++ b/MainWindow.IedBrowser.cs
@@ -636,28 +636,32 @@ await _runtime.ReadBrowserValuesAsync(device, targets,
// both cases as deferred/unknown, never as an IED AccessDenied.
// Only the current explicit scope is accounted; metadata,
// companion reads and unrelated model pages are excluded.
+ // Ledger truth, exact FC absence and the bounded queue must
+ // be classified once, identically for LN/DO/DataSet and SGCB.
+ // An Interrupted Read is NOT a confirmed terminal MMS failure.
+ bool IsObserved(IedBrowserReadTarget target)
+ => sourceDevice.LiveValues.TryGet(
+ target.Reference, target.FunctionalConstraint, out var value) &&
+ !value.IsStale && value.Association == readAssociation;
+ bool HasTerminalFailure(IedBrowserReadTarget target)
+ {
+ if (_iedBrowserReadFailureEvidence is not { } evidence)
+ return false;
+ return evidence.TryGet(sourceDevice, target.Reference, "",
+ target.FunctionalConstraint, out _, out var kind) &&
+ kind == IedBrowserReadAbsenceKind.Failed;
+ }
+
if (!readAll && currentPage?.Kind is "LN" or "DO" or "DA" or "DataSet" &&
configRequest is null && companionRequest is null &&
!reportUnitRequest)
{
var declaredSelection = IedBrowserReadPlan.Build(
model, currentPage, readAll: false, selectedDoReference: selectedDo);
- bool IsObserved(IedBrowserReadTarget target)
- => sourceDevice.LiveValues.TryGet(
- target.Reference, target.FunctionalConstraint, out var value) &&
- !value.IsStale && value.Association == readAssociation;
- bool HasTerminalFailure(IedBrowserReadTarget target)
- {
- if (_iedBrowserReadFailureEvidence is not { } evidence)
- return false;
- return evidence.TryGet(sourceDevice, target.Reference, "",
- target.FunctionalConstraint, out _, out var kind) &&
- kind != IedBrowserReadAbsenceKind.Deferred;
- }
- var gaps = IedBrowserReadCompletionAccounting.Classify(
+ var coverage = IedBrowserReadCompletionAccounting.Summarize(
declaredSelection, targets, IsObserved, HasTerminalFailure);
- unverifiedReadTargets = gaps.Count;
- foreach (var gap in gaps)
+ unverifiedReadTargets = coverage.Gaps.Count;
+ foreach (var gap in coverage.Gaps)
_iedBrowserReadFailureEvidence?.RecordDeferred(gap.Target,
gap.Kind == IedBrowserDeferredReadKind.NotScheduled
? "Not scheduled in this bounded selected-object Read."
@@ -666,38 +670,26 @@ bool HasTerminalFailure(IedBrowserReadTarget target)
if (!readAll && currentPage?.Kind == "SGCB")
{
- // The completed 32-target focused chunk is NOT the total
- // model inventory. Expose the accounted deferred remainder,
- // including leaves never scheduled for MMS on this selection.
+ // SGCB uses the SAME exact-target accounting as LN/DO:
+ // 'queued' is not 'wire attempted', and one completed chunk
+ // is never represented as the whole setting-group inventory.
var declared = IedBrowserReadPlan.Build(model, currentPage, readAll: false);
- var observed = 0;
- var unavailable = 0;
- foreach (var target in declared)
- {
- if (sourceDevice.LiveValues.TryGet(target.Reference,
- target.FunctionalConstraint, out var present) &&
- !present.IsStale && present.Association == readAssociation)
- {
- observed++;
- continue;
- }
- if (_iedBrowserReadFailureEvidence?.TryGet(
- sourceDevice, target.Reference, "",
- target.FunctionalConstraint, out _, out var kind) == true &&
- kind != IedBrowserReadAbsenceKind.Deferred)
- {
- unavailable++;
- continue;
- }
- _iedBrowserReadFailureEvidence?.RecordDeferred(target,
- "Not yet scheduled within the bounded selected SGCB read; " +
- "use explicit Read to request all verified SG leaves.");
- }
- var deferred = Math.Max(0, declared.Count - observed - unavailable);
+ var coverage = IedBrowserReadCompletionAccounting.Summarize(
+ declared, targets, IsObserved, HasTerminalFailure);
+ unverifiedReadTargets = coverage.Gaps.Count;
+ foreach (var gap in coverage.Gaps)
+ _iedBrowserReadFailureEvidence?.RecordDeferred(gap.Target,
+ gap.Kind == IedBrowserDeferredReadKind.NotScheduled
+ ? "Not scheduled in this bounded selected SGCB Read; " +
+ "use explicit Read for remaining verified SG leaves."
+ : "SGCB Read queued but no terminal result for this exact FC; " +
+ "on-wire attempt unverified.");
IedBrowserSubtitle.Text =
$"SGCB focused batch · {successes:N0}/{targets.Count:N0} received · " +
- $"{observed:N0}/{declared.Count:N0} observed · " +
- $"{unavailable:N0} unavailable · {deferred:N0} deferred";
+ $"{coverage.ObservedTargets:N0}/{coverage.ConsideredTargets:N0} observed · " +
+ $"{coverage.TerminalFailures:N0} read failed · " +
+ $"{coverage.Gaps.Count:N0} deferred/unverified" +
+ (coverage.Truncated ? " · bounded evidence scope" : "");
}
else
IedBrowserSubtitle.Text =
diff --git a/Models/IedBrowserReadCompletionAccounting.cs b/Models/IedBrowserReadCompletionAccounting.cs
index 17aea6cb4..fe512d419 100644
--- a/Models/IedBrowserReadCompletionAccounting.cs
+++ b/Models/IedBrowserReadCompletionAccounting.cs
@@ -9,6 +9,18 @@ public enum IedBrowserDeferredReadKind
public sealed record IedBrowserDeferredRead(
IedBrowserReadTarget Target, IedBrowserDeferredReadKind Kind);
+///
+/// One bounded, metadata-only summary of the exact selected Read scope.
+/// A queued target is NOT proof that an MMS request reached the wire.
+/// Observed values are read only from the caller's association-scoped ledger.
+///
+public sealed record IedBrowserReadCoverageSummary(
+ int ConsideredTargets,
+ int ObservedTargets,
+ int TerminalFailures,
+ IReadOnlyList Gaps,
+ bool Truncated);
+
///
/// Describes read-coverage gaps on the operator's exact selection only.
/// Queuing a variable is NOT evidence that an MMS Request was put on the wire.
@@ -22,23 +34,52 @@ public static IReadOnlyList Classify(
Func observed,
Func terminalFailure,
int maxTargets = 4096)
+ => Summarize(declared, scheduled, observed, terminalFailure, maxTargets).Gaps;
+
+ ///
+ /// Count each exact FC target once, with observation preceding terminal
+ /// failure. Interrupted/unknown terminal results must be passed as false
+ /// to terminalFailure; the caller's absence ledger preserves their state.
+ ///
+ public static IedBrowserReadCoverageSummary Summarize(
+ IReadOnlyList declared,
+ IReadOnlyList scheduled,
+ Func observed,
+ Func terminalFailure,
+ int maxTargets = 4096)
{
ArgumentNullException.ThrowIfNull(declared);
ArgumentNullException.ThrowIfNull(scheduled);
ArgumentNullException.ThrowIfNull(observed);
ArgumentNullException.ThrowIfNull(terminalFailure);
- if (maxTargets < 1) return [];
+ if (maxTargets < 1)
+ return new IedBrowserReadCoverageSummary(0, 0, 0, [], declared.Count > 0);
+
+ var considered = Math.Min(declared.Count, maxTargets);
var scheduledKeys = scheduled.Select(Key).ToHashSet(StringComparer.Ordinal);
- var output = new List();
- foreach (var target in declared.Take(maxTargets))
+ var gaps = new List();
+ var observedCount = 0;
+ var failedCount = 0;
+ for (var i = 0; i < considered; i++)
{
- if (observed(target) || terminalFailure(target)) continue;
- output.Add(new IedBrowserDeferredRead(target,
+ var target = declared[i];
+ if (observed(target))
+ {
+ observedCount++;
+ continue;
+ }
+ if (terminalFailure(target))
+ {
+ failedCount++;
+ continue;
+ }
+ gaps.Add(new IedBrowserDeferredRead(target,
scheduledKeys.Contains(Key(target))
? IedBrowserDeferredReadKind.TerminalResultUnverified
: IedBrowserDeferredReadKind.NotScheduled));
}
- return output;
+ return new IedBrowserReadCoverageSummary(
+ considered, observedCount, failedCount, gaps, declared.Count > considered);
}
private static string Key(IedBrowserReadTarget target)
diff --git a/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs b/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs
index cdd99c4be..7cc613d57 100644
--- a/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs
+++ b/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs
@@ -88,7 +88,11 @@ public void UiReadHandlerUsesExplicitInboxCompletionBeforeResultsSubtitle()
var summary = normalized.IndexOf("Read finished ·", StringComparison.Ordinal);
Assert.True(finishedProducers >= 0 && finalDrain > finishedProducers &&
catalogFence > finalDrain && summary > catalogFence);
- Assert.Contains("IedBrowserReadCompletionAccounting.Classify(", normalized);
+ // SGCB and ordinary LN/DO reads share one bounded classification
+ // pass; the older Classify() API still delegates to this summary.
+ Assert.Contains("IedBrowserReadCompletionAccounting.Summarize(", normalized);
+ Assert.Contains("coverage.Gaps", normalized);
+ Assert.Contains("kind == IedBrowserReadAbsenceKind.Failed", normalized);
Assert.Contains("deferred/unverified", normalized);
Assert.Contains("sourceDevice.LiveValues.Association != readAssociation", source);
}
diff --git a/tests/ARSAS.Tests/P0DReadCompletionAccountingTests.cs b/tests/ARSAS.Tests/P0DReadCompletionAccountingTests.cs
index 9346408c4..49d2d8cc5 100644
--- a/tests/ARSAS.Tests/P0DReadCompletionAccountingTests.cs
+++ b/tests/ARSAS.Tests/P0DReadCompletionAccountingTests.cs
@@ -39,6 +39,70 @@ public void FailureAndValidObservationCannotBeErasedByDeferredMarker()
Assert.Empty(list);
}
+ [Fact]
+ public void SgcbFocusedChunk_DoesNotConflateQueuedUnknownWithNeverScheduled()
+ {
+ var declared = new[] {
+ Target("vendor", "SG"),
+ Target("swRev", "SG"),
+ Target("d", "SG"),
+ Target("configRev", "SG"),
+ Target("swRev", "SE")
+ };
+ var scheduled = new[] {declared[0], declared[1], declared[2]};
+ var coverage = IedBrowserReadCompletionAccounting.Summarize(
+ declared, scheduled,
+ target => target.Reference == declared[0].Reference ||
+ target.Reference == declared[4].Reference,
+ target => target.Reference == declared[2].Reference);
+
+ Assert.Equal(5, coverage.ConsideredTargets);
+ Assert.Equal(2, coverage.ObservedTargets);
+ Assert.Equal(1, coverage.TerminalFailures);
+ Assert.False(coverage.Truncated);
+ Assert.Equal(2, coverage.Gaps.Count);
+ Assert.Equal(declared[1], coverage.Gaps[0].Target);
+ Assert.Equal(IedBrowserDeferredReadKind.TerminalResultUnverified,
+ coverage.Gaps[0].Kind);
+ Assert.Equal(declared[3], coverage.Gaps[1].Target);
+ Assert.Equal(IedBrowserDeferredReadKind.NotScheduled,
+ coverage.Gaps[1].Kind);
+ // The SG target must never borrow the same-name SE observation.
+ Assert.DoesNotContain(coverage.Gaps, gap =>
+ gap.Target.FunctionalConstraint == "SE");
+ }
+
+ [Fact]
+ public void InterruptedQueuedRead_IsNotATerminalAccessResultFailure()
+ {
+ var requested = Target("setMag", "SG");
+ var summary = IedBrowserReadCompletionAccounting.Summarize(
+ [requested], [requested],
+ _ => false,
+ // An interrupted response has NO confirmed terminal wire result.
+ _ => false);
+ Assert.Equal(0, summary.TerminalFailures);
+ Assert.Equal(IedBrowserDeferredReadKind.TerminalResultUnverified,
+ Assert.Single(summary.Gaps).Kind);
+ }
+
+ [Fact]
+ public void SummaryBoundsAllEvidenceAndDoesNotExecuteBeyondScope()
+ {
+ var targets = Enumerable.Range(0, 8)
+ .Select(i => Target("attr" + i, "SG")).ToArray();
+ var called = 0;
+ var summary = IedBrowserReadCompletionAccounting.Summarize(
+ targets, targets,
+ _ => { called++; return false; }, _ => false, maxTargets: 3);
+ Assert.Equal(3, called);
+ Assert.Equal(3, summary.ConsideredTargets);
+ Assert.True(summary.Truncated);
+ Assert.Equal(3, summary.Gaps.Count);
+ Assert.All(summary.Gaps,
+ gap => Assert.Equal(IedBrowserDeferredReadKind.TerminalResultUnverified, gap.Kind));
+ }
+
[Fact]
public void CaseSensitiveIecReference_AndBoundedPageCannotBorrowSiblingEvidence()
{