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()
{