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
80 changes: 36 additions & 44 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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 =
Expand Down
53 changes: 47 additions & 6 deletions Models/IedBrowserReadCompletionAccounting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ public enum IedBrowserDeferredReadKind
public sealed record IedBrowserDeferredRead(
IedBrowserReadTarget Target, IedBrowserDeferredReadKind Kind);

/// <summary>
/// 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.
/// </summary>
public sealed record IedBrowserReadCoverageSummary(
int ConsideredTargets,
int ObservedTargets,
int TerminalFailures,
IReadOnlyList<IedBrowserDeferredRead> Gaps,
bool Truncated);

/// <summary>
/// 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.
Expand All @@ -22,23 +34,52 @@ public static IReadOnlyList<IedBrowserDeferredRead> Classify(
Func<IedBrowserReadTarget, bool> observed,
Func<IedBrowserReadTarget, bool> terminalFailure,
int maxTargets = 4096)
=> Summarize(declared, scheduled, observed, terminalFailure, maxTargets).Gaps;

/// <summary>
/// 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.
/// </summary>
public static IedBrowserReadCoverageSummary Summarize(
IReadOnlyList<IedBrowserReadTarget> declared,
IReadOnlyList<IedBrowserReadTarget> scheduled,
Func<IedBrowserReadTarget, bool> observed,
Func<IedBrowserReadTarget, bool> 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<IedBrowserDeferredRead>();
foreach (var target in declared.Take(maxTargets))
var gaps = new List<IedBrowserDeferredRead>();
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)
Expand Down
6 changes: 5 additions & 1 deletion tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
64 changes: 64 additions & 0 deletions tests/ARSAS.Tests/P0DReadCompletionAccountingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading