diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 928ac777b..0b338bbff 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,20 @@ 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 = absenceKind switch + { + IedBrowserReadAbsenceKind.Deferred => "Initial read deferred", + IedBrowserReadAbsenceKind.Interrupted => "MMS Read interrupted", + _ => "MMS Read failed" + }; row.ObservationTooltip = $"{row.ValueReference} [{row.FunctionalConstraint}] · {readFailure}"; } diff --git a/Models/IedBrowserReadFailureEvidence.cs b/Models/IedBrowserReadFailureEvidence.cs index 65f70cc9c..37d80e1e2 100644 --- a/Models/IedBrowserReadFailureEvidence.cs +++ b/Models/IedBrowserReadFailureEvidence.cs @@ -4,13 +4,24 @@ 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. /// +public enum IedBrowserReadAbsenceKind +{ + Failed, + Interrupted, + 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 +32,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 +57,21 @@ 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); + + /// 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) { ArgumentNullException.ThrowIfNull(target); if (!IsCurrent(_owner) || string.IsNullOrWhiteSpace(reason) || @@ -58,8 +84,16 @@ 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 (_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); } } @@ -91,7 +125,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 +133,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 +149,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; } } } diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 5a0c7d134..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,9 +242,53 @@ 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 = analogSeeds + .SelectMany(seed => AnalogCompanionReferences( + IedBrowserLiveValues.CanonicalReference(seed.MmsReference))) + .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. @@ -262,13 +304,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. 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; diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 1b846b6f9..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) { @@ -228,8 +246,10 @@ await ReadBrowserValuesAsync(device, plan, inbox, var published = 0; var failed = 0; + var completedTargets = new HashSet(StringComparer.Ordinal); 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 +273,34 @@ 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; + var interrupted = 0; + foreach (var target in plan) + { + 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}, " + + $"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."); } diff --git a/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs b/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs index e55171d71..eab72838b 100644 --- a/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs +++ b/tests/ARSAS.Tests/M214BrowserReadFailureEvidenceTests.cs @@ -59,6 +59,69 @@ 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 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() { 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() { 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() {