From 06351dc9f307f40bc77fc9c70e738efa4486a534 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:52:04 +0700 Subject: [PATCH 01/13] fix(m217i): recognize exact SCL-backed FCDA leaves without cross-FC exposure --- Models/IedBrowserStructuredDetails.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Models/IedBrowserStructuredDetails.cs b/Models/IedBrowserStructuredDetails.cs index 985c1d7bb..feaf45ce3 100644 --- a/Models/IedBrowserStructuredDetails.cs +++ b/Models/IedBrowserStructuredDetails.cs @@ -66,8 +66,10 @@ public static class IedBrowserStructuredDetails if (!scl.Owner.Attributes.Any(a => string.Equals(a.FunctionalConstraint, memberFc, StringComparison.OrdinalIgnoreCase) && - IedBrowserLiveValues.CanonicalReference(a.MmsReference) - .StartsWith(memberReference + ".", StringComparison.Ordinal))) + (string.Equals(IedBrowserLiveValues.CanonicalReference(a.MmsReference), + memberReference, StringComparison.Ordinal) || + IedBrowserLiveValues.CanonicalReference(a.MmsReference) + .StartsWith(memberReference + ".", StringComparison.Ordinal)))) return live.Owner; sclOnly = true; return scl.Owner; From c6d29f275f458f7c9ff30aa0d134e1280303a487 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:52:35 +0700 Subject: [PATCH 02/13] test(m217i): exact FCDA SCL leaf allowed only for matching IED and FC, no invented value --- .../M217GDataSetSclSchemaFallbackTests.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs b/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs index 12b1a6756..8364730be 100644 --- a/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs +++ b/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs @@ -125,6 +125,47 @@ public void OtherFcAndDifferentIedReferenceCannotSupplyMxDirectory() Assert.Contains(children, x => x.Kind == "Message"); } + [Fact] + public void ExactLeafFcdaUsesOnlyMatchingSclLeafSchemaWithoutInventingValues() + { + // FCDA may reference a single DA, not only an FCD root with + // descendants. A matching leaf is a legitimate SCL *shape*. + var exact = Owner + ".phsA.q"; + var scl = Model(Obj(Attr("MX", "phsA.q"))); + var live = Model(); + var member = Member(reference: exact); + var row = IedBrowserStructuredDetails.Member(member, live, sclModel: scl); + Assert.Contains("SCL structure only", row.Tooltip); + Assert.Equal(exact, row.ValueReference); + var children = IedBrowserStructuredDetails.DataSetMemberChildren( + member, live, 1, scl); + Assert.NotEmpty(children); + Assert.DoesNotContain(children, child => child.Kind == "Message"); + + var values = new IedBrowserLiveValues(); + var association = values.BeginAssociation(); + Assert.False(IedBrowserDetailEvidenceResolver.TryResolve( + values, row, null, monitoring: true, out _, out _)); + // The legitimate observation must still come from this same exact + // reference/FC; the selected SCL cannot supply a scalar. + Assert.True(values.PublishForAssociation(association, + exact, "MX", "0x0000", "", "", "Report")); + Assert.True(IedBrowserDetailEvidenceResolver.TryResolve( + values, row, null, monitoring: true, out var observed, out var resolved)); + Assert.Equal(exact, resolved); + Assert.Equal("0x0000", observed.Value); + + var wrongFc = IedBrowserStructuredDetails.DataSetMemberChildren( + Member("ST", exact), live, 1, scl); + Assert.Contains(wrongFc, x => x.Kind == "Message"); + var wrongLn = IedBrowserStructuredDetails.DataSetMemberChildren( + Member(reference: "LD/MMXU2.PhV.phsA.q"), live, 1, scl); + Assert.Contains(wrongLn, x => x.Kind == "Message"); + var wrongDomain = IedBrowserStructuredDetails.DataSetMemberChildren( + Member(reference: "OTHER/MMXU1.PhV.phsA.q"), live, 1, scl); + Assert.Contains(wrongDomain, x => x.Kind == "Message"); + } + [Fact] public void SclPresentationCannotAuthorizeMmsReadTargets() { From 009d461c8cc7ffe990e7472d55b63dafd6c18019 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:53:46 +0700 Subject: [PATCH 03/13] feat(m217i): seed exact live FCDA phasor q/t and angles within original finite MMS Read budget --- Models/IedBrowserReadPlan.cs | 67 +++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 5a0c7d134..85e9e5f24 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -248,6 +248,43 @@ public static IReadOnlyList BuildInitialPriority( string.IsNullOrWhiteSpace(attr.SclBType) ? attr.MmsType : attr.SclBType)) .ToArray(); + // A bounded set of exact companions makes the phasor seed useful + // without presenting missing angle/quality/timestamp as "exposed" + // evidence. Unlike SCL presentation, these must be real live MMS + // attributes covered by the exact live DataSet and FC. + const int initialAnalogCompanionLimit = 9; + var companionOrder = analog + .SelectMany(seed => AnalogCompanionReferences( + IedBrowserLiveValues.CanonicalReference(seed.Reference))) + .Distinct(StringComparer.Ordinal) + .Select((reference, index) => (reference, index)) + .ToDictionary(x => x.reference, x => x.index, StringComparer.Ordinal); + var companions = verifiedLiveModel.LogicalDevices + .SelectMany(ld => ld.LogicalNodes) + .SelectMany(ln => ln.DataObjects) + .SelectMany(obj => obj.Attributes) + .Where(attr => string.Equals(attr.FunctionalConstraint, "MX", + StringComparison.OrdinalIgnoreCase)) + .Select(attr => new + { + Attr = attr, + Reference = IedBrowserLiveValues.CanonicalReference(attr.MmsReference) + }) + .Where(item => companionOrder.ContainsKey(item.Reference) && + verifiedLiveModel.DataSets.Any(set => + schema.ContainsDataSetMember(set.Reference, item.Reference, "MX"))) + .GroupBy(item => item.Reference, StringComparer.Ordinal) + // Ambiguous duplicate live FC declarations cannot authorize a read. + .Where(group => group.Count() == 1) + .Select(group => group.Single()) + .Where(item => !ledger.TryGet(item.Reference, "MX", out var observed) || + observed.IsStale || observed.Association != association) + .OrderBy(item => companionOrder[item.Reference]) + .Take(initialAnalogCompanionLimit) + .Select(item => new IedBrowserReadTarget(item.Attr.MmsReference, "MX", + string.IsNullOrWhiteSpace(item.Attr.SclBType) + ? item.Attr.MmsType : item.Attr.SclBType)) + .ToArray(); // Critical station Mod/Beh/Health must never starve behind multiple // optional control blocks or slow LActTm reads on a 10s envelope. // The priority-sorted LN plan already puts these at priority zero. @@ -262,13 +299,41 @@ public static IReadOnlyList BuildInitialPriority( target.Reference.EndsWith(".GoEna", StringComparison.Ordinal)) .Take(2); return critical.Concat(fastSg.Take(4)).Concat(firstGo).Concat(analog) - .Concat(settings).Concat(goose).Concat(candidates) + .Concat(companions).Concat(settings).Concat(goose).Concat(candidates) .DistinctBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + target.FunctionalConstraint.Trim().ToUpperInvariant(), StringComparer.Ordinal) .Take(maxTargets).ToArray(); } + private static IEnumerable AnalogCompanionReferences(string magnitude) + { + // Only the exact IEC phasor/measurement owner of a selected live + // magnitude can authorize companions. Similar suffixes, other LNs, + // missing FC and SCL-only leaves are not candidates. + const string cValMagnitude = ".cVal.mag.f"; + const string simpleMagnitude = ".mag.f"; + string owner; + string angle; + if (magnitude.EndsWith(cValMagnitude, StringComparison.Ordinal)) + { + owner = magnitude[..^cValMagnitude.Length]; + angle = owner + ".cVal.ang.f"; + } + else if (magnitude.EndsWith(simpleMagnitude, StringComparison.Ordinal)) + { + owner = magnitude[..^simpleMagnitude.Length]; + angle = owner + ".ang.f"; + } + else + yield break; + // Observe raw IEC quality/time before optional angle, never infer + // these from a magnitude or another phase's Report. + yield return owner + ".q"; + yield return owner + ".t"; + yield return angle; + } + private static int AnalogSeedRank(string reference) { // Deterministic priority for canonical IEC three-phase magnitude. From 013273614f84227302869206e22c9c656a488710 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:54:18 +0700 Subject: [PATCH 04/13] fix(m217i): retain q/t angle companion eligibility when initial magnitude arrived by Report --- Models/IedBrowserReadPlan.cs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 85e9e5f24..69727352e 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -207,7 +207,7 @@ public static IReadOnlyList BuildInitialPriority( // Six values share the SAME existing 32-target/10-second envelope, // not an extra background poll or MMS association. const int initialAnalogSeedLimit = 6; - var analog = verifiedLiveModel.LogicalDevices + var analogSeeds = verifiedLiveModel.LogicalDevices .SelectMany(ld => ld.LogicalNodes) .SelectMany(ln => ln.DataObjects) .Where(obj => @@ -234,8 +234,6 @@ public static IReadOnlyList BuildInitialPriority( + "|MX", StringComparer.Ordinal) .Where(group => group.Count() == 1) .Select(group => group.First()) - .Where(attr => !ledger.TryGet(attr.MmsReference, "MX", out var observed) || - observed.IsStale || observed.Association != association) // Fill a small coherent phasor group per owner rather than // selecting only phase-A from dozens of unrelated MMXU nodes. .OrderBy(attr => IedBrowserLiveValues.CanonicalReference( @@ -244,18 +242,25 @@ public static IReadOnlyList BuildInitialPriority( IedBrowserLiveValues.CanonicalReference(attr.MmsReference))) .ThenBy(attr => attr.MmsReference, StringComparer.Ordinal) .Take(initialAnalogSeedLimit) + .ToArray(); + // Selecting the canonical six owners must not depend on whether a + // Report has already supplied a magnitude. A received magnitude may + // still need its independently declared q/t and angle Read evidence. + var analog = analogSeeds + .Where(attr => !ledger.TryGet(attr.MmsReference, "MX", out var observed) || + observed.IsStale || observed.Association != association) .Select(attr => new IedBrowserReadTarget(attr.MmsReference, "MX", - string.IsNullOrWhiteSpace(attr.SclBType) ? - attr.MmsType : attr.SclBType)) + string.IsNullOrWhiteSpace(attr.SclBType) + ? attr.MmsType : attr.SclBType)) .ToArray(); // A bounded set of exact companions makes the phasor seed useful // without presenting missing angle/quality/timestamp as "exposed" // evidence. Unlike SCL presentation, these must be real live MMS // attributes covered by the exact live DataSet and FC. const int initialAnalogCompanionLimit = 9; - var companionOrder = analog + var companionOrder = analogSeeds .SelectMany(seed => AnalogCompanionReferences( - IedBrowserLiveValues.CanonicalReference(seed.Reference))) + IedBrowserLiveValues.CanonicalReference(seed.MmsReference))) .Distinct(StringComparer.Ordinal) .Select((reference, index) => (reference, index)) .ToDictionary(x => x.reference, x => x.index, StringComparer.Ordinal); From 2598a7fc2c97af9b1f258d1ce0642e7049a330e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:54:51 +0700 Subject: [PATCH 05/13] test(m217i): live FCDA q/t-angle companion completeness, report reuse, bounded reads and FC isolation --- .../M217HInitialMxHydrationPlanTests.cs | 83 +++++++++++++++++-- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/tests/ARSAS.Tests/M217HInitialMxHydrationPlanTests.cs b/tests/ARSAS.Tests/M217HInitialMxHydrationPlanTests.cs index eea82cc1e..23d2284b1 100644 --- a/tests/ARSAS.Tests/M217HInitialMxHydrationPlanTests.cs +++ b/tests/ARSAS.Tests/M217HInitialMxHydrationPlanTests.cs @@ -18,7 +18,8 @@ private static LiveIedDataAttributeModel Leaf( MmsReference = "LD/MMXU1$" + fc + "$" + obj + "$" + relative.Replace('.', '$'), FunctionalConstraint = fc, - SclBType = "FLOAT32" + SclBType = relative.EndsWith(".q", StringComparison.Ordinal) ? "Quality" + : relative.EndsWith(".t", StringComparison.Ordinal) ? "UtcTime" : "FLOAT32" }; private static LiveIedModelDiscoveryDocument Model( @@ -58,7 +59,7 @@ private static string[] Canonical(IReadOnlyList plan) .ToArray(); [Fact] - public void LiveFcdMxMagnitudesAreSeededInPhaseOrderWithoutAngleOrQualityGuesses() + public void LiveFcdMxMagnitudesAndDeclaredCompanionsAreSeededWithoutGuessedValues() { var attrs = new[] { @@ -70,27 +71,95 @@ public void LiveFcdMxMagnitudesAreSeededInPhaseOrderWithoutAngleOrQualityGuesses var ledger = new IedBrowserLiveValues(); ledger.BeginAssociation(); var plan = IedBrowserReadPlan.BuildInitialPriority(live, ledger, 32); - Assert.Equal(3, plan.Count); + Assert.Equal(6, plan.Count); Assert.Equal(new[] { "LD/MMXU1.PhV.phsA.cVal.mag.f", "LD/MMXU1.PhV.phsB.cVal.mag.f", - "LD/MMXU1.PhV.phsC.cVal.mag.f" + "LD/MMXU1.PhV.phsC.cVal.mag.f", + "LD/MMXU1.PhV.phsA.q", + "LD/MMXU1.PhV.phsA.t", + "LD/MMXU1.PhV.phsA.cVal.ang.f" }, Canonical(plan)); Assert.All(plan, t => Assert.Equal("MX", t.FunctionalConstraint)); - Assert.All(plan, t => Assert.Equal("FLOAT32", t.DataType)); + Assert.Equal("Quality", plan[3].DataType); + Assert.Equal("UtcTime", plan[4].DataType); + Assert.Equal("FLOAT32", plan[5].DataType); Assert.Single(IedBrowserReadPlan.BuildInitialPriority(live, ledger, 1)); var association = ledger.Association; Assert.True(ledger.PublishForAssociation(association, plan[0].Reference, "MX", "0", "", "", "Report")); - Assert.Equal(2, IedBrowserReadPlan.BuildInitialPriority(live, ledger, 32).Count); + var afterReport = IedBrowserReadPlan.BuildInitialPriority(live, ledger, 32); + Assert.Equal(5, afterReport.Count); + // Existing magnitude from actual Report does not disqualify its + // independent live-declared q/t and angle from bounded initial reads. + Assert.Contains(afterReport, t => IedBrowserLiveValues.CanonicalReference(t.Reference) + == "LD/MMXU1.PhV.phsA.q"); ledger.BeginAssociation(); - Assert.Equal(3, IedBrowserReadPlan.BuildInitialPriority(live, ledger, 32).Count); + Assert.Equal(6, IedBrowserReadPlan.BuildInitialPriority(live, ledger, 32).Count); ledger.MarkOffline(); Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(live, ledger, 32)); } + [Fact] + public void ThreePhaseMxReadsAreFiniteAndIndependentOfReceivedCompanionValues() + { + var attrs = new[] { "phsA", "phsB", "phsC" }.SelectMany(phase => + new[] { Leaf("PhV", phase + ".cVal.mag.f"), + Leaf("PhV", phase + ".cVal.ang.f"), + Leaf("PhV", phase + ".q"), + Leaf("PhV", phase + ".t") }).ToArray(); + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + var live = Model(attrs); + var plan = IedBrowserReadPlan.BuildInitialPriority(live, ledger, 32); + Assert.Equal(12, plan.Count); + Assert.Equal(3, plan.Count(t => + IedBrowserLiveValues.CanonicalReference(t.Reference).EndsWith(".cVal.mag.f", StringComparison.Ordinal))); + Assert.Equal(3, plan.Count(t => + IedBrowserLiveValues.CanonicalReference(t.Reference).EndsWith(".cVal.ang.f", StringComparison.Ordinal))); + Assert.Equal(3, plan.Count(t => + IedBrowserLiveValues.CanonicalReference(t.Reference).EndsWith(".q", StringComparison.Ordinal))); + Assert.Equal(3, plan.Count(t => + IedBrowserLiveValues.CanonicalReference(t.Reference).EndsWith(".t", StringComparison.Ordinal))); + Assert.All(plan, t => Assert.Equal("MX", t.FunctionalConstraint)); + Assert.Equal(4, IedBrowserReadPlan.BuildInitialPriority(live, ledger, 4).Count); + + var quality = "LD/MMXU1.PhV.phsA.q"; + Assert.True(ledger.PublishForAssociation(association, quality, "MX", + "0x0000", "", "", "Report")); + var remaining = IedBrowserReadPlan.BuildInitialPriority(live, ledger, 32); + Assert.Equal(11, remaining.Count); + Assert.DoesNotContain(remaining, t => + IedBrowserLiveValues.CanonicalReference(t.Reference) == quality); + } + + [Fact] + public void MxCompanionRequiresUnambiguousExactLiveFcdaAndIsNeverGuessed() + { + var live = Model(new[] + { + Leaf("PhV", "phsA.cVal.mag.f"), + Leaf("PhV", "phsA.q", "ST"), // wrong FC + Leaf("PhV", "phsA.t"), // right FC and exact member owner + Leaf("PhV", "phsA.cVal.ang.f"), + Leaf("PhV", "phsA.q2"), // vendor lookalike never eligible + Leaf("PhV", "phsB.q"), // other phase never eligible + Leaf("PhV", "phsA.t") // duplicate live declaration invalidates t + }); + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + var plan = IedBrowserReadPlan.BuildInitialPriority(live, ledger, 32); + Assert.Equal(new[] { "LD/MMXU1.PhV.phsA.cVal.mag.f", + "LD/MMXU1.PhV.phsA.cVal.ang.f" }, Canonical(plan)); + + // FCDA coverage must be live, not SCL-only or same-domain inference. + var noDataset = Model(new[] { Leaf("PhV", "phsA.cVal.mag.f"), + Leaf("PhV", "phsA.q"), Leaf("PhV", "phsA.t") }, datasetFc: null); + Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(noDataset, ledger, 32)); + } + [Fact] public void WithoutExactLiveMxDatasetAuthorityNoAutomaticAnalogTrafficIsPlanned() { From 83457225d0aca72dfbac7110ea8b29bd9cdbf9e7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:56:40 +0700 Subject: [PATCH 06/13] feat(m217i): distinguish bounded initial Read deferral from actual MMS failure in association-scoped evidence --- Models/IedBrowserReadFailureEvidence.cs | 57 +++++++++++++++++++++---- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/Models/IedBrowserReadFailureEvidence.cs b/Models/IedBrowserReadFailureEvidence.cs index 65f70cc9c..6fae19af8 100644 --- a/Models/IedBrowserReadFailureEvidence.cs +++ b/Models/IedBrowserReadFailureEvidence.cs @@ -9,8 +9,18 @@ namespace ArIED61850Tester.Models; /// conversion, retry or live state. Exact reference/FC, same IED object, /// association and projection epoch are required on every UI lookup. /// +public enum IedBrowserReadAbsenceKind +{ + Failed, + Deferred +} + public sealed record IedBrowserReadFailureSample( - string Reference, string FunctionalConstraint, string Reason); + string Reference, string FunctionalConstraint, string Reason, + IedBrowserReadAbsenceKind Kind = IedBrowserReadAbsenceKind.Failed); + +internal sealed record IedBrowserReadAbsence( + string Reason, IedBrowserReadAbsenceKind Kind); public sealed class IedBrowserReadFailureEvidence { @@ -21,7 +31,7 @@ public sealed class IedBrowserReadFailureEvidence private readonly bool _modelBound; private readonly object? _model; private readonly object _gate = new(); - private readonly Dictionary _failed = new(StringComparer.Ordinal); + private readonly Dictionary _failed = new(StringComparer.Ordinal); public IedBrowserReadFailureEvidence(Iec61850MonitorDevice owner, bool bindToLiveModel = false) @@ -46,6 +56,17 @@ private static string Key(string? reference, string? fc) (fc ?? "").Trim().ToUpperInvariant(); public void RecordFailure(IedBrowserReadTarget target, string reason) + => RecordAbsence(target, reason, IedBrowserReadAbsenceKind.Failed); + + /// + /// A selected bounded initial target that never reached an MMS Read is + /// deferred, not failed. It does not authorize a value or retry. + /// + public void RecordDeferred(IedBrowserReadTarget target, string reason) + => RecordAbsence(target, reason, IedBrowserReadAbsenceKind.Deferred); + + private void RecordAbsence(IedBrowserReadTarget target, string reason, + IedBrowserReadAbsenceKind kind) { ArgumentNullException.ThrowIfNull(target); if (!IsCurrent(_owner) || string.IsNullOrWhiteSpace(reason) || @@ -58,8 +79,14 @@ public void RecordFailure(IedBrowserReadTarget target, string reason) { if (_failed.Count >= MaxFailureEntries && !_failed.ContainsKey(key)) return; - if (IsCurrent(_owner)) - _failed[key] = text.Length > 320 ? text[..320] + "…" : text; + if (!IsCurrent(_owner)) return; + // A budget-deferred marker cannot erase an actual wire failure. + if (kind == IedBrowserReadAbsenceKind.Deferred && + _failed.TryGetValue(key, out var previous) && + previous.Kind == IedBrowserReadAbsenceKind.Failed) + return; + _failed[key] = new IedBrowserReadAbsence( + text.Length > 320 ? text[..320] + "…" : text, kind); } } @@ -91,7 +118,7 @@ public IReadOnlyList SnapshotCurrent( var separator = item.Key.LastIndexOf('|'); return new IedBrowserReadFailureSample( item.Key[..separator], item.Key[(separator + 1)..], - item.Value); + item.Value.Reason, item.Value.Kind); }) .ToArray(); } @@ -99,8 +126,14 @@ public IReadOnlyList SnapshotCurrent( public bool TryGet(Iec61850MonitorDevice device, string reference, string fallbackReference, string fc, out string reason) + => TryGet(device, reference, fallbackReference, fc, out reason, out _); + + public bool TryGet(Iec61850MonitorDevice device, + string reference, string fallbackReference, string fc, out string reason, + out IedBrowserReadAbsenceKind kind) { reason = ""; + kind = IedBrowserReadAbsenceKind.Failed; if (!IsCurrent(device) || string.IsNullOrWhiteSpace(fc)) return false; // A fallback is permitted only when structured projection declared it @@ -109,10 +142,18 @@ public bool TryGet(Iec61850MonitorDevice device, { if (!IsCurrent(device)) return false; if (!string.IsNullOrWhiteSpace(reference) && - _failed.TryGetValue(Key(reference, fc), out reason)) + _failed.TryGetValue(Key(reference, fc), out var exact)) + { + reason = exact.Reason; + kind = exact.Kind; return true; - return !string.IsNullOrWhiteSpace(fallbackReference) && - _failed.TryGetValue(Key(fallbackReference, fc), out reason); + } + if (string.IsNullOrWhiteSpace(fallbackReference) || + !_failed.TryGetValue(Key(fallbackReference, fc), out var fallback)) + return false; + reason = fallback.Reason; + kind = fallback.Kind; + return true; } } } From c62a04c96e0dcfc80cb14bbe8c13e129265ddaa8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:57:03 +0700 Subject: [PATCH 07/13] fix(m217i): track selected initial-read targets skipped by budget as deferred not MMS failures --- Services/Iec61850MonitorRuntime.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 1b846b6f9..c58755352 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -228,8 +228,13 @@ await ReadBrowserValuesAsync(device, plan, inbox, var published = 0; var failed = 0; + var completedTargets = new HashSet(StringComparer.Ordinal); + static string TargetKey(IedBrowserReadTarget target) => + IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + + target.FunctionalConstraint.Trim().ToUpperInvariant(); var completed = inbox.Drain(result => { + completedTargets.Add(TargetKey(result.Target)); // All read results are observationally tied to this exact // association, model object, runtime session and schema epoch. if (!_sessions.TryGetValue(device.DeviceId, out var current) || @@ -253,9 +258,21 @@ await ReadBrowserValuesAsync(device, plan, inbox, failed++; } }); + // The 10s envelope may end before later selected MX q/t leaves + // receive even one wire attempt. Never mislabel those as a rejected + // MMS service; retain only bounded, association-fenced absence evidence. + var deferred = 0; + foreach (var target in plan) + { + if (completedTargets.Contains(TargetKey(target))) continue; + failures.RecordDeferred(target, exhausted + ? "Not attempted: initial 10-second MMS Read budget exhausted. Use Read DO/DataSet for an explicit follow-up." + : "Not attempted: initial MMS Read sequence ended before this target. Use Read DO/DataSet for an explicit follow-up."); + deferred++; + } Log("INFO", device.Name, $"Bounded priority initial MMS evidence: planned={plan.Count}, " + - $"completed={completed}, published={published}, failed={failed}, " + + $"completed={completed}, published={published}, failed={failed}, deferred={deferred}, " + $"budget={InitialHydrationTotalBudget.TotalSeconds:0}s, exhausted={exhausted}, " + $"association={association}; no implicit Read All or RCB/control activity."); } From beb81fff5f9aed6587e550c4bf429755782f18b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:57:25 +0700 Subject: [PATCH 08/13] fix(m217i): show honest Not read for deferred initial targets, not misleading MMS failure --- MainWindow.IedBrowser.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 928ac777b..e98e3c375 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -584,17 +584,19 @@ private void RefreshIedBrowserReadButtons() } private bool TryGetBrowserReadFailure( - Iec61850MonitorDevice device, IedBrowserDetailRow row, out string reason) + Iec61850MonitorDevice device, IedBrowserDetailRow row, out string reason, + out IedBrowserReadAbsenceKind kind) { if (_iedBrowserReadFailureEvidence?.TryGet( device, row.ValueReference, row.FallbackValueReference, - row.FunctionalConstraint, out reason) == true) + row.FunctionalConstraint, out reason, out kind) == true) return true; if (device.InitialHydrationFailures?.TryGet( device, row.ValueReference, row.FallbackValueReference, - row.FunctionalConstraint, out reason) == true) + row.FunctionalConstraint, out reason, out kind) == true) return true; reason = ""; + kind = IedBrowserReadAbsenceKind.Failed; return false; } @@ -681,10 +683,15 @@ private void RefreshIedBrowserVisibleValues() row.ObservationSource = "Discovery snapshot"; row.ObservationTooltip = provenance; } - else if (TryGetBrowserReadFailure(device, row, out var readFailure)) + else if (TryGetBrowserReadFailure(device, row, + out var readFailure, out var absenceKind)) { - row.Value = "Unavailable"; - row.ObservationSource = "MMS Read failed"; + // No initial wire attempt is not an MMS rejection. + // Keep the small table calm; explain the cause in the + // existing per-row tooltip, without fake process values. + var deferred = absenceKind == IedBrowserReadAbsenceKind.Deferred; + row.Value = deferred ? "Not read" : "Unavailable"; + row.ObservationSource = deferred ? "Initial read deferred" : "MMS Read failed"; row.ObservationTooltip = $"{row.ValueReference} [{row.FunctionalConstraint}] · {readFailure}"; } From f69555148d81fef6c794a40e4409afe55343bbf5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:57:41 +0700 Subject: [PATCH 09/13] test(m217i): typed deferred vs actual wire failure and association invalidation --- .../M214BrowserReadFailureEvidenceTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs b/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs index e55171d71..888cceaa3 100644 --- a/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs +++ b/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs @@ -59,6 +59,40 @@ public void ReconnectSchemaReplacementAndOtherIed_InvalidateAllFailureEvidence() Assert.False(current.TryGet(device, reference, "", "SG", out _)); } + [Fact] + public void DeferredBudgetIsNotMisreportedAsMmsFailureAndCannotEraseWireFailure() + { + var device = Device(); + var absences = new IedBrowserReadFailureEvidence(device); + var target = new IedBrowserReadTarget( + "LD/MMXU1$MX$PhV$phsA$q", "MX", "Quality"); + + absences.RecordDeferred(target, "Not attempted: initial 10-second budget exhausted"); + Assert.True(absences.TryGet(device, "LD/MMXU1.PhV.phsA.q", "", + "MX", out var reason, out var kind)); + Assert.Equal(IedBrowserReadAbsenceKind.Deferred, kind); + Assert.Contains("Not attempted", reason); + Assert.Equal(IedBrowserReadAbsenceKind.Deferred, + Assert.Single(absences.SnapshotCurrent(device)).Kind); + + absences.RecordFailure(target, "IED data-access-error: object-non-existent"); + absences.RecordDeferred(target, "Not attempted: later budget exhausted"); + Assert.True(absences.TryGet(device, target.Reference, "", + "MX", out reason, out kind)); + Assert.Equal(IedBrowserReadAbsenceKind.Failed, kind); + Assert.Contains("object-non-existent", reason); + Assert.False(absences.TryGet(device, target.Reference, "", + "ST", out _, out _)); + + absences.ClearFailure(target); + Assert.False(absences.TryGet(device, target.Reference, "", + "MX", out _, out _)); + absences.RecordDeferred(target, "Not attempted: initial budget exhausted"); + device.LiveValues.BeginAssociation(); + Assert.False(absences.TryGet(device, target.Reference, "", + "MX", out _, out _)); + } + [Fact] public void MissingAndOversizedFailuresRemainBoundedAndNeverLookLikeValues() { From 494e6c0c446df057b528bd60a08ee04ebcd88665 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:03:13 +0700 Subject: [PATCH 10/13] fix(m217i): classify interrupted initial MMS Read by producer-side attempt, never mistake for unattempted --- Services/Iec61850MonitorRuntime.cs | 50 +++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index c58755352..26c5d11f9 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -121,12 +121,24 @@ public IReadOnlySet GetActiveBrowserRcbReferences(string deviceId) /// association and a single sequential request lane. No report subscription, /// GI, DataSet modification, control write or background verification polling. /// - public async Task ReadBrowserValuesAsync( + public Task ReadBrowserValuesAsync( Iec61850MonitorDevice device, IReadOnlyList targets, IProgress values, IProgress progress, CancellationToken cancellationToken) + => ReadBrowserValuesAsync(device, targets, values, progress, + cancellationToken, onAttempt: null); + + // One producer-side attempt callback is used only by finite initial + // hydration. No WPF scheduling, new transport, or extra Read call. + private async Task ReadBrowserValuesAsync( + Iec61850MonitorDevice device, + IReadOnlyList targets, + IProgress values, + IProgress progress, + CancellationToken cancellationToken, + Action? onAttempt) { ArgumentNullException.ThrowIfNull(device); if (!_sessions.TryGetValue(device.DeviceId, out var session) || @@ -141,6 +153,7 @@ public async Task ReadBrowserValuesAsync( { cancellationToken.ThrowIfCancellationRequested(); var target = targets[index]; + onAttempt?.Invoke(target); try { using var singleRead = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -201,6 +214,10 @@ private async Task HydratePriorityInitialEvidenceAsync( var plan = IedBrowserReadPlan.BuildInitialPriority( model, device.LiveValues, InitialHydrationMaxTargets); if (plan.Count == 0) return; + static string TargetKey(IedBrowserReadTarget target) => + IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + + target.FunctionalConstraint.Trim().ToUpperInvariant(); + var attemptedTargets = new HashSet(StringComparer.Ordinal); // This existing failure-only type is bound to the accepted live model // instead of the mutable UI Signals collection, which is rebuilt after @@ -215,7 +232,8 @@ private async Task HydratePriorityInitialEvidenceAsync( { await ReadBrowserValuesAsync(device, plan, inbox, new Progress(static _ => { }), - budget.Token).ConfigureAwait(false); + budget.Token, + target => attemptedTargets.Add(TargetKey(target))).ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { @@ -229,9 +247,6 @@ await ReadBrowserValuesAsync(device, plan, inbox, var published = 0; var failed = 0; var completedTargets = new HashSet(StringComparer.Ordinal); - static string TargetKey(IedBrowserReadTarget target) => - IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + - target.FunctionalConstraint.Trim().ToUpperInvariant(); var completed = inbox.Drain(result => { completedTargets.Add(TargetKey(result.Target)); @@ -262,17 +277,30 @@ static string TargetKey(IedBrowserReadTarget target) => // receive even one wire attempt. Never mislabel those as a rejected // MMS service; retain only bounded, association-fenced absence evidence. var deferred = 0; + var interrupted = 0; foreach (var target in plan) { - if (completedTargets.Contains(TargetKey(target))) continue; - failures.RecordDeferred(target, exhausted - ? "Not attempted: initial 10-second MMS Read budget exhausted. Use Read DO/DataSet for an explicit follow-up." - : "Not attempted: initial MMS Read sequence ended before this target. Use Read DO/DataSet for an explicit follow-up."); - deferred++; + var key = TargetKey(target); + if (completedTargets.Contains(key)) continue; + if (attemptedTargets.Contains(key)) + { + // The wire request may already exist. Cancellation is NOT + // proof of NotSent; do not claim this leaf was unattempted. + failures.RecordInterrupted(target, + "MMS Read started without a terminal scalar result before cancellation/budget. Wire outcome unknown; verify the live IED and reconnect if needed."); + interrupted++; + } + else + { + failures.RecordDeferred(target, exhausted + ? "Not attempted: initial 10-second MMS Read budget exhausted. Use Read DO/DataSet for an explicit follow-up." + : "Not attempted: initial MMS Read sequence ended before this target. Use Read DO/DataSet for an explicit follow-up."); + deferred++; + } } Log("INFO", device.Name, $"Bounded priority initial MMS evidence: planned={plan.Count}, " + - $"completed={completed}, published={published}, failed={failed}, deferred={deferred}, " + + $"completed={completed}, published={published}, failed={failed}, interrupted={interrupted}, deferred={deferred}, " + $"budget={InitialHydrationTotalBudget.TotalSeconds:0}s, exhausted={exhausted}, " + $"association={association}; no implicit Read All or RCB/control activity."); } From 46a70cc3560781f291b19c3c5cb7b12d3ebe6b4d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:03:27 +0700 Subject: [PATCH 11/13] feat(m217i): add interrupted native MMS Read absence without conflating deferred and wire failure --- Models/IedBrowserReadFailureEvidence.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Models/IedBrowserReadFailureEvidence.cs b/Models/IedBrowserReadFailureEvidence.cs index 6fae19af8..37d80e1e2 100644 --- a/Models/IedBrowserReadFailureEvidence.cs +++ b/Models/IedBrowserReadFailureEvidence.cs @@ -4,7 +4,7 @@ namespace ArIED61850Tester.Models; /// -/// Failure-only evidence for one explicit Browser MMS Read sweep. +/// Bounded absence-only evidence for one association-scoped Browser MMS Read sweep. /// This is NOT another values cache and cannot authorize a value, FC, /// conversion, retry or live state. Exact reference/FC, same IED object, /// association and projection epoch are required on every UI lookup. @@ -12,6 +12,7 @@ namespace ArIED61850Tester.Models; public enum IedBrowserReadAbsenceKind { Failed, + Interrupted, Deferred } @@ -65,6 +66,10 @@ public void RecordFailure(IedBrowserReadTarget target, string reason) public void RecordDeferred(IedBrowserReadTarget target, string reason) => RecordAbsence(target, reason, IedBrowserReadAbsenceKind.Deferred); + /// A Read was started, but its terminal wire result is unknown. + public void RecordInterrupted(IedBrowserReadTarget target, string reason) + => RecordAbsence(target, reason, IedBrowserReadAbsenceKind.Interrupted); + private void RecordAbsence(IedBrowserReadTarget target, string reason, IedBrowserReadAbsenceKind kind) { @@ -81,9 +86,11 @@ private void RecordAbsence(IedBrowserReadTarget target, string reason, return; if (!IsCurrent(_owner)) return; // A budget-deferred marker cannot erase an actual wire failure. - if (kind == IedBrowserReadAbsenceKind.Deferred && - _failed.TryGetValue(key, out var previous) && - previous.Kind == IedBrowserReadAbsenceKind.Failed) + if (_failed.TryGetValue(key, out var previous) && + (kind == IedBrowserReadAbsenceKind.Deferred && + previous.Kind != IedBrowserReadAbsenceKind.Deferred || + kind == IedBrowserReadAbsenceKind.Interrupted && + previous.Kind == IedBrowserReadAbsenceKind.Failed)) return; _failed[key] = new IedBrowserReadAbsence( text.Length > 320 ? text[..320] + "…" : text, kind); From eeb19db0ff284465ee5729cbfa023b3e3d411762 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:03:37 +0700 Subject: [PATCH 12/13] fix(m217i): label canceled in-flight MMS Read as interrupted rather than failed/deferred --- MainWindow.IedBrowser.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index e98e3c375..0b338bbff 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -691,7 +691,12 @@ private void RefreshIedBrowserVisibleValues() // existing per-row tooltip, without fake process values. var deferred = absenceKind == IedBrowserReadAbsenceKind.Deferred; row.Value = deferred ? "Not read" : "Unavailable"; - row.ObservationSource = deferred ? "Initial read deferred" : "MMS Read failed"; + row.ObservationSource = absenceKind switch + { + IedBrowserReadAbsenceKind.Deferred => "Initial read deferred", + IedBrowserReadAbsenceKind.Interrupted => "MMS Read interrupted", + _ => "MMS Read failed" + }; row.ObservationTooltip = $"{row.ValueReference} [{row.FunctionalConstraint}] · {readFailure}"; } From da9ab79e1d54e0d206cb786096477dfc4a543b56 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:03:52 +0700 Subject: [PATCH 13/13] test(m217i): interrupted Read is unknown, cannot be downgraded to deferred, and association fenced --- .../M214BrowserReadFailureEvidenceTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs b/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs index 888cceaa3..eab72838b 100644 --- a/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs +++ b/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs @@ -93,6 +93,35 @@ public void DeferredBudgetIsNotMisreportedAsMmsFailureAndCannotEraseWireFailure( "MX", out _, out _)); } + [Fact] + public void InterruptedReadIsUncertainNotDeferredAndCannotBeDowngraded() + { + var device = Device(); + var absences = new IedBrowserReadFailureEvidence(device); + var target = new IedBrowserReadTarget("LD/MMXU1.PhV.phsA.t", "MX", "UtcTime"); + absences.RecordInterrupted(target, + "MMS Read started, but no terminal response was observed"); + Assert.True(absences.TryGet(device, target.Reference, "", + "MX", out var reason, out var kind)); + Assert.Equal(IedBrowserReadAbsenceKind.Interrupted, kind); + Assert.Contains("no terminal response", reason); + + absences.RecordDeferred(target, "Not attempted: budget exhausted"); + Assert.True(absences.TryGet(device, target.Reference, "", + "MX", out reason, out kind)); + Assert.Equal(IedBrowserReadAbsenceKind.Interrupted, kind); + Assert.DoesNotContain("Not attempted", reason); + + absences.RecordFailure(target, "IED explicitly rejected Read"); + Assert.True(absences.TryGet(device, target.Reference, "", + "MX", out reason, out kind)); + Assert.Equal(IedBrowserReadAbsenceKind.Failed, kind); + Assert.Contains("rejected", reason); + device.LiveValues.MarkOffline(); + Assert.False(absences.TryGet(device, target.Reference, "", + "MX", out _, out _)); + } + [Fact] public void MissingAndOversizedFailuresRemainBoundedAndNeverLookLikeValues() {