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
22 changes: 17 additions & 5 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,13 @@ _iedBrowserReadFailureEvidence is { } previousFailures &&
return;
}

// Plan transparency: the clicked scope, not whichever DO is visually
// expanded elsewhere, determines Read targets. Show FC admission so a
// bounded focused sample cannot be mistaken for a whole LN Read.
var scheduledFc = IedBrowserReadPlan.DescribeScheduledFunctionalConstraints(targets);
var readScope = readAll ? "IED" :
selectedDo is not null ? "DO" :
currentPage?.Kind ?? "Selection";
// Admission is claimed only after a concrete, nonempty verified plan
// reaches the read handler. A queued Dispatcher callback is NOT a read.
// Rejected/busy/cancelled preflight cannot poison a future visit.
Expand Down Expand Up @@ -575,6 +582,9 @@ void DrainReadResults()
RefreshIedBrowserVisibleValues();
}
}
if (!automatic)
IedBrowserSubtitle.Text =
$"Read {readScope} · {targets.Count:N0} verified target(s) · FC {scheduledFc}";
if (!automatic)
device.ResetDiscoveryProgress("Preparing read-only MMS values…",
readAll ? "Reading all IED values" : selectedDo is not null
Expand Down Expand Up @@ -703,12 +713,14 @@ bool HasTerminalFailure(IedBrowserReadTarget target)
}
else
IedBrowserSubtitle.Text =
$"Read finished · {successes:N0} verified scalar values / {targets.Count:N0} scheduled attributes · " +
$"Read {readScope} finished · {successes:N0}/{targets.Count:N0} observed · " +
$"{failures:N0} failed · {unverifiedReadTargets:N0} deferred/unverified";
IedBrowserSubtitle.ToolTip = failureExamples.Count == 0
? null
: "First unavailable attributes (no scalar was published):\n" +
string.Join("\n", failureExamples);
IedBrowserSubtitle.ToolTip =
$"Explicit scope: {readScope}; scheduled FC: {scheduledFc}" +
(failureExamples.Count == 0
? string.Empty
: "\nFirst unavailable attributes (no scalar was published):\n" +
string.Join("\n", failureExamples));
RefreshIedBrowserVisibleValues();
// A few permanently refused SG leaves must not block the next
// finite SGCB chunk. Per-page auto admission still caps attempts
Expand Down
70 changes: 63 additions & 7 deletions Models/IedBrowserReadPlan.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AR.Iec61850.Discovery;
using System.Runtime.CompilerServices;

namespace ArIED61850Tester.Models;

Expand Down Expand Up @@ -28,6 +29,54 @@ public static IedBrowserReadSelectionKind SelectionKind(IedBrowserTreeRow? page)
_ => IedBrowserReadSelectionKind.FcAttributes
};

// Per-canonical-model compiled READ-ONLY admission catalog. A completed
// LiveDiscoveryModel is a frozen per-association snapshot; reconnect creates
// a new model object. Weak keys release all index memory when that snapshot
// is replaced. This is an index of model metadata, NOT a value cache.
private sealed class CompiledCatalog
{
internal IedBrowserSchemaIndex Schema { get; }
internal LiveIedDataAttributeModel[] Attributes { get; }
internal Dictionary<(string Reference, string Fc), LiveIedDataAttributeModel>
Authorized { get; }

internal CompiledCatalog(LiveIedModelDiscoveryDocument model)
{
Schema = new IedBrowserSchemaIndex(model);
Attributes = model.LogicalDevices
.SelectMany(ld => ld.LogicalNodes)
.SelectMany(ln => ln.DataObjects)
.SelectMany(obj => obj.Attributes)
.Where(a => ReadableFc.Contains(a.FunctionalConstraint))
.ToArray();
// Exact wire reference + FC. A duplicate declaration makes that
// identity ambiguous; never grant first-inventory wins authority.
Authorized = Attributes
.Where(a => !string.IsNullOrWhiteSpace(a.MmsReference))
.GroupBy(a => (a.MmsReference, Fc: a.FunctionalConstraint.ToUpperInvariant()))
.Where(group => group.Count() == 1)
.ToDictionary(group => group.Key, group => group.Single());
}
}

private static readonly ConditionalWeakTable<LiveIedModelDiscoveryDocument,
CompiledCatalog> Compiled = new();

/// <summary>
/// Describe exactly the FC namespaces admitted for THIS explicit read;
/// the result is plan metadata, never an inferred network observation.
/// </summary>
public static string DescribeScheduledFunctionalConstraints(
IReadOnlyList<IedBrowserReadTarget> targets)
{
ArgumentNullException.ThrowIfNull(targets);
return targets.Count == 0 ? "none" : string.Join(", ",
targets.GroupBy(t => (t.FunctionalConstraint ?? "").Trim().ToUpperInvariant())
.OrderBy(group => group.Key, StringComparer.Ordinal)
.Select(group => group.Key + ":" + group.Count().ToString(
System.Globalization.CultureInfo.InvariantCulture)));
}

// The live MMS model may declare status-only substitution (SV), operation
// supervision (OR), and blocking (BL) leaves. They are safe to *read*
// explicitly, never to operate or modify. FC CO remains excluded.
Expand Down Expand Up @@ -58,12 +107,9 @@ public static IReadOnlyList<IedBrowserReadTarget> Build(
bool readAll, string? selectedDoReference = null)
{
if (model is null) return Array.Empty<IedBrowserReadTarget>();
var schema = new IedBrowserSchemaIndex(model);
var all = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes)
.SelectMany(ln => ln.DataObjects)
.SelectMany(obj => obj.Attributes)
.Where(a => ReadableFc.Contains(a.FunctionalConstraint))
.ToArray();
var compiled = Compiled.GetValue(model, static m => new CompiledCatalog(m));
var schema = compiled.Schema;
var all = compiled.Attributes;

IEnumerable<LiveIedDataAttributeModel> selected = all;
if (!readAll)
Expand Down Expand Up @@ -108,10 +154,20 @@ public static IReadOnlyList<IedBrowserReadTarget> Build(
};
}

// Page payloads can outlive a discovery/association change. Never turn
// their detached DA objects into MMS targets merely because they contain
// a plausible reference. Resolve every selection back through THIS live
// model's exact MMS reference and FC, and use the model's declared type.
// Duplicate identities in the model are ambiguous and fail closed.
var authorized = compiled.Authorized;

var attributes = selected.Where(a => ReadableFc.Contains(a.FunctionalConstraint))
.Where(a => !string.IsNullOrWhiteSpace(a.MmsReference))
.Select(a => authorized.GetValueOrDefault(
(a.MmsReference, a.FunctionalConstraint.ToUpperInvariant())))
.Where(a => a is not null)
.Select(a => new IedBrowserReadTarget(
a.MmsReference, a.FunctionalConstraint,
a!.MmsReference, a.FunctionalConstraint,
string.IsNullOrWhiteSpace(a.SclBType) ? a.MmsType : a.SclBType));
// Exact, model-inventory-proven control state is an additional
// read-only projection. It is never added to cyclic or Read All scans.
Expand Down
81 changes: 66 additions & 15 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
public List<ReportControlPlan> ActiveReportPlanOrder { get; } = new();
public Dictionary<string, string> PointPlanIds { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, Iec61850MonitorPoint> ReportReferenceIndex { get; } = new(StringComparer.OrdinalIgnoreCase);
// Alias collisions are never resolved by first-insertion order. Lifetime
// matches the report reference index and the current association.
public HashSet<string> AmbiguousReportReferenceKeys { get; } = new(StringComparer.OrdinalIgnoreCase);
public PriorityQueue<string, long> PollQueue { get; } = new();
public Dictionary<string, Iec61850ReportContinuityState> ReportStreams { get; } = new(StringComparer.OrdinalIgnoreCase);
// Active association only: exact ARIEC write-step outcomes for each
Expand Down Expand Up @@ -838,6 +841,7 @@
session.ActiveReportPlanOrder.Clear();
session.PointPlanIds.Clear();
session.ReportReferenceIndex.Clear();
session.AmbiguousReportReferenceKeys.Clear();
session.PollQueue.Clear();
session.ReportStreams.Clear();
session.ReportActivationByPlanId.Clear();
Expand Down Expand Up @@ -1822,7 +1826,7 @@
{
if (update.HasValue &&
IedBrowserReportValueAuthority.SafeScalar(update.Value) &&
!LooksLikeReferenceEcho(update.Value, update.Reference, update.Reference))

Check warning on line 1829 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 1829 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 1829 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.
session.Device.LiveValues.PublishForAssociation(
session.BrowserObservationAssociation,
update.Reference, reportFc, update.Value,
Expand Down Expand Up @@ -2958,6 +2962,7 @@
session.Points.Clear();
session.States.Clear();
session.ReportReferenceIndex.Clear();
session.AmbiguousReportReferenceKeys.Clear();
}
}

Expand Down Expand Up @@ -3351,37 +3356,83 @@
private static void IndexPointReference(DeviceSession session, Iec61850MonitorPoint point)
{
foreach (var key in GetReferenceKeys(point.IecReference))
session.ReportReferenceIndex.TryAdd(key, point);
AddReportReferenceCandidate(
session.ReportReferenceIndex, session.AmbiguousReportReferenceKeys, key, point);
}

private static Iec61850MonitorPoint? FindPointForReportReference(DeviceSession session, string reference)
// One association-scoped index only. When two distinct selected points
// claim a canonical reference, neither may own its Report value.
internal static void AddReportReferenceCandidate(
IDictionary<string, Iec61850MonitorPoint> index,
ISet<string> ambiguousKeys,
string key,
Iec61850MonitorPoint candidate)
{
if (ambiguousKeys.Contains(key))
return;
if (index.TryGetValue(key, out var prior))
{
if (!ReferenceEquals(prior, candidate))
{
index.Remove(key);
ambiguousKeys.Add(key);
}
return;
}
index.Add(key, candidate);
}

private static Iec61850MonitorPoint? FindPointForReportReference(
DeviceSession session, string reference)
{
foreach (var key in GetReferenceKeys(reference))
{
if (session.AmbiguousReportReferenceKeys.Contains(key))
continue;
if (session.ReportReferenceIndex.TryGetValue(key, out var indexed))
return indexed;
}

// Rare vendor projection fallback. Cache the resolved alias so subsequent report
// events do not scan every monitored point again.
var canonical = CanonicalDataReference(reference);
var point = session.Points.Values.FirstOrDefault(candidate =>
{
var candidateCanonical = CanonicalDataReference(candidate.IecReference);
return candidateCanonical.Equals(canonical, StringComparison.OrdinalIgnoreCase) ||
candidateCanonical.StartsWith(canonical + ".", StringComparison.OrdinalIgnoreCase) ||
canonical.StartsWith(candidateCanonical + ".", StringComparison.OrdinalIgnoreCase);
});

// Nonexact aliases occur on some older report projections. Never
// select the first child of a structured parent (e.g. phase A), or
// overwrite an existing alias. Only a unique selected point may own
// the alias; otherwise its Report observation remains unrouted.
var point = FindUniqueReportReferenceFallback(session.Points.Values, reference);
if (point != null)
{
foreach (var key in GetReferenceKeys(reference))
session.ReportReferenceIndex[key] = point;
AddReportReferenceCandidate(
session.ReportReferenceIndex, session.AmbiguousReportReferenceKeys,
key, point);
}

return point;
}

internal static Iec61850MonitorPoint? FindUniqueReportReferenceFallback(
IEnumerable<Iec61850MonitorPoint> points, string reference)
{
var canonical = CanonicalDataReference(reference);
if (string.IsNullOrWhiteSpace(canonical))
return null;

Iec61850MonitorPoint? unique = null;
foreach (var candidate in points)
{
var candidateCanonical = CanonicalDataReference(candidate.IecReference);
// Prefix ancestry does NOT prove a phase's scalar identity:
// one selected phsA under a three-phase parent is still ambiguous
// in the IED schema. ARIEC's typed structured projection must emit
// the exact semantic leaf; a parent cannot borrow its value.
if (!candidateCanonical.Equals(canonical, StringComparison.OrdinalIgnoreCase))
continue;

if (unique is not null && !ReferenceEquals(unique, candidate))
return null; // Ambiguous phase/child/FC — fail closed.
unique = candidate;
}
return unique;
}

private static IReadOnlyList<Iec61850MonitorPoint> ResolveCoveredPoints(
ReportControlPlan plan,
IReadOnlyList<string> coveredReferences)
Expand Down
29 changes: 29 additions & 0 deletions docs/P0_READ_COMMAND_INTEGRATION_CHECKPOINT_20261021.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# P0 Read / Command checkpoint — 2026-10-21

## Immutable ancestry (never resume this work from `main`)

- **Accepted newer UI/ZIV/GoCB line:** #650 `5a33fb101026707caf6410ff097a569cfabf3939`, inheriting field-accepted ZIV/GoCB #649 `4c232184f64584308396ea18ff6c82700ac02d29` and approved UI phases #646–#648. Full 11/11 exact-HEAD workflow PASS at the source checkpoint.
- **Independent field Browser/Report line:** #644 `80e4836affce0b1f5f142c2966199a2ef5af41b3` → #645 `2b4e4ea0b917b6c10fbe4b64b6a3a7b127870598`; source/CI green, original physical SV/OR/BL and all data model values **not proven**.
- **Current integration:** draft #652 stacked directly on #650. Exactly four authoritative files from #645 were replayed after verifying both pre-change files on #650 are blob-identical to original #642. Regressions and mainline UI/GoCB/Command Dock protections are retained.
- **Pinned engine:** ARIEC61850 #183 `c85d4e20db722bd0c2729f2533b323b13d3ef735`. The engine P0-C trial #184 is separate and **not pinned or consumed by #652**. CI-only draft #185 triggers Windows engine validation for #184 because the engine workflow omits the stacked base branch.
- **No main merge/public release** until the current *combined* app and engine exactly matched head passes Windows, package, and authorized physical FAT.

## P0-R production code

1. `Models/IedBrowserReadPlan.cs`: single source-of-truth exact MMS reference + FC admission; per-model weak compiled read-only metadata index and immutable `IedBrowserSchemaIndex` reused only for the same frozen `LiveDiscoveryModel`. Model replacement produces new index; duplicates remain quarantined and all observed values continue exclusively in `LiveValues`.
2. Explicit Read now states the *actual clicked scope* (IED, DO, LN, or other) and per-FC **scheduled** counts, not synthetic wire observations. Thus a 5-target DO Read is visibly distinct from a complete LN Read. Passive bounded focus still intentionally excludes SV/OR/BL, while verified explicit Read DO/LN/All admits them; CO is never a scalar Read.
3. `IedBrowserReadResultInboxTests` continues to enforce synchronous final inbox drain before the success subtitle; P0H tests preserve anti-spoof identity, epoch switch, FC count and duplicate rules. No new network call, timer or global mutable IED-state cache.

## P0-C source-proven delay and safe trial

Physical SIEBCU/CSWI4 samples show SBOw service 5–7ms + *post-response application-error observation* 399–404ms + Operate service 2–3ms. Engine `Iec61850ControlServiceOptions.ApplicationErrorGracePeriod` defaults to 400ms; `SelectWithValueCoreAsync` awaits it after successful SBOw and before Operate. **Do not simply set it to zero:** delayed asynchronous negative LastApplError must still prevent Operate. Engine draft #184 adds stage labels/configured grace to wire evidence and tests negative events arriving immediately or after 35ms. A qualified no-fixed-wait path needs event-correlated identity (association/object/ctlNum/origin) plus negative, late, collision, timeout and disconnect regressions and field acceptance; no such performance fix is claimed yet.

## Field limitations and required gates

- User's actual 57-row XCBR1 CSV: 41 observed; 15 without MMS attempt evidence; 1 failed scalar projection. Four `Pos.subEna/subVal/subQ/subID` SV leaves were **not attempted**, rather than server-rejected. Read actual XCBR1 LN/DO explicitly, record exact app+engine SHA, planned FCs, typed GVA responses, invoke/partial-failure evidence, per-IED ledger and UI. A separately observed client display supplies comparison data only; it is not a source of protocol code.
- `SumSwARs.units.multiplier` returned no scalar in application evidence without raw GVA; classify as unresolved read/projection, not a confirmed server refusal.
- Report anomalies: 52 received BRCB frames, 13 sequence irregularities, BufOvfl=1, EntryID absent. Never infer lost SOE or continuity completion without synchronized wire capture.
- User's endpoint `192.16.1.192` is also assigned to the Windows host's `KM_Test` adapter; do not label a same-host simulator observation as physical relay.
- Required: all 11 CI workflows, targeted+full positive/negative suites, exact engine lock, portable packaged smoke/manifest, actual Read SV/OR/BL and control stage evidence on authorized device, plus UI/DPI and installer qualification. No automatic command/RCB writes or cyclic MMS process polling.

See `AGENTS.md`, `docs/WORKSTREAM_COORDINATION.md` and Oct-20 Global Read & Command Strategy attachment for governing constraints.
2 changes: 1 addition & 1 deletion tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public void UiReadHandlerUsesExplicitInboxCompletionBeforeResultsSubtitle()
" DrainReadResults();", finishedProducers, StringComparison.Ordinal);
var catalogFence = normalized.IndexOf(
"if (bindingIndex is not { IsCurrent: true })", finalDrain, StringComparison.Ordinal);
var summary = normalized.IndexOf("Read finished ·", StringComparison.Ordinal);
var summary = normalized.IndexOf("Read {readScope} finished ·", catalogFence, StringComparison.Ordinal);
Assert.True(finishedProducers >= 0 && finalDrain > finishedProducers &&
catalogFence > finalDrain && summary > catalogFence);
Assert.Contains("IedBrowserReadCompletionAccounting.Classify(", normalized);
Expand Down
Loading
Loading