diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 0b338bbff..ccc6cf9ad 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -263,6 +263,41 @@ private async void IedBrowserReadRcb_Click(object sender, RoutedEventArgs e) private void IedBrowserCancelRead_Click(object sender, RoutedEventArgs e) => CancelIedBrowserRead(); + // One finite read per selected live association/page. Report ingress remains + // authoritative; this set is only a bounded navigation admission ledger. + // No polling timer, process-value mirror or write service is introduced. + private readonly HashSet _iedBrowserAutoReadPages = new(StringComparer.Ordinal); + + private void QueueIedBrowserFocusedRead(IedBrowserTreeRow? page, + string? focusedObjectReference = null) + { + if (page?.Kind is not ("SGCB" or "LN" or "DataSet") || + SelectedDevice is not { IsConnected: true, IsDemo: false, IsBusy: false } device || + _iedBrowserReadOperation is not null || device.IsBrowserReading || + device.LiveDiscoveryModel is null || device.LiveValues.IsOffline) + return; + // RCB and bulk inventory navigation stay entirely passive. + // LN gets an exact ST/MX image, SGCB its verified control state, + // and DataSet only the declared CF SIUnit/multiplier pair. + var key = device.LiveValues.Association + "|" + device.Name + "|" + + page.Key + "|" + (focusedObjectReference ?? ""); + // A very large IED may have thousands of LNs/DOs. Keep only a + // small admission history; it is not a process-value cache. + if (_iedBrowserAutoReadPages.Count >= 128) + _iedBrowserAutoReadPages.Clear(); + if (!_iedBrowserAutoReadPages.Add(key)) return; + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (!ReferenceEquals(SelectedDevice, device) || + !ReferenceEquals(_iedBrowserNavigation.Current, page) || + !device.IsConnected || device.IsBusy || + _iedBrowserReadOperation is not null) + return; + IedBrowserReadValues_Click(new Button { Tag = "FocusedAuto" }, + new RoutedEventArgs()); + })); + } + private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) { var device = SelectedDevice; @@ -273,6 +308,7 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // Its bounded reads never interfere with the reporting session. var readAll = sender is Button { Tag: "All" }; + var focusedAuto = sender is Button { Tag: "FocusedAuto" }; if (!readAll) { switch (IedBrowserReadPlan.SelectionKind(_iedBrowserNavigation.Current)) @@ -297,8 +333,29 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) ReferenceEquals(_iedBrowserRenderedPage, currentPage) && currentPage?.Payload is LiveIedLogicalNodeModel ? _iedBrowserSelectedDoReference : null; - var targets = IedBrowserReadPlan.Build( - model, currentPage, readAll, selectedDo); + var targets = focusedAuto && currentPage?.Payload is LiveIedDataSetModel unitSet + ? IedBrowserReadPlan.BuildDataSetUnitRead( + device.LiveDiscoveryModel, unitSet, device.LiveValues) + : IedBrowserReadPlan.Build(model, currentPage, readAll, selectedDo); + if (focusedAuto) + { + // Never start a large LN or SG Read All implicitly. Keep the + // same existing isolated MMS read service and result inbox. + // ST process leaves are preferred for the Data Model; SGCB control + // state gets priority before settings within the finite envelope. + const int maxFocusedTargets = 12; + targets = currentPage?.Payload is LiveIedDataSetModel + ? targets.Take(maxFocusedTargets).ToArray() + : currentPage?.Kind == "SGCB" + ? targets.OrderBy(t => t.FunctionalConstraint == "SG" ? 1 : 0) + .ThenBy(t => t.Reference, StringComparer.Ordinal) + .Take(maxFocusedTargets).ToArray() + : targets.Where(t => t.FunctionalConstraint is "ST" or "MX") + .OrderBy(t => t.Reference.Contains("$ST$", StringComparison.Ordinal) ? 0 : 1) + .ThenBy(t => t.Reference.EndsWith("$stVal", StringComparison.Ordinal) ? 0 : 1) + .ThenBy(t => t.Reference, StringComparer.Ordinal) + .Take(maxFocusedTargets).ToArray(); + } if (targets.Count == 0) { IedBrowserSubtitle.Text = "No model-verified readable FC attributes in this selection."; @@ -314,6 +371,8 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // connection and reject a previously planned schema. IedBrowserReadBindingIndex? bindingIndex = null; var cts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); + if (focusedAuto) + cts.CancelAfter(TimeSpan.FromSeconds(8)); // Read All may include thousands of attributes. It is cancellable by the // operator, never a new continuous acquisition/polling process. _iedBrowserReadOperation = cts; @@ -1511,6 +1570,11 @@ private void IedBrowserDetailRow_MouseUp( { _iedBrowserSelectedDoReference = detail.ReadObjectReference; RefreshIedBrowserReadButtons(); // expose separate Read DO and Read LN scope + // The selected DO is independently eligible for a small exact + // read; do not leave an otherwise unobserved XCBR status at + // Not read merely because another DO used the LN's first budget. + QueueIedBrowserFocusedRead(_iedBrowserNavigation.Current, + detail.ReadObjectReference); } if (!detail.HasChildren) return; ToggleIedBrowserDetails(detail); @@ -1692,6 +1756,7 @@ private void RenderIedBrowserSelection( _iedBrowserLastLiveVersion = -1; RefreshIedBrowserVisibleValues(); RefreshIedBrowserReadButtons(); + QueueIedBrowserFocusedRead(row); // Folder entry opens at its first child, regardless of the scroll // position in the previously inspected RCB, LN or DataSet. if (_iedBrowserDetails.Rows.Count > 0) diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 69727352e..f8eb44c8b 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -117,6 +117,65 @@ page.Kind is "SGCB" or "GoCB" .ToArray(); } + /// + /// Strict, finite CF-unit evidence for the selected live MX DataSet. + /// The two standard leaves (SIUnit/multiplier) must belong to the EXACT + /// MX measurement object covered by this live DataSet. A nearby LN, + /// separate FC or SCL-only declaration is never authority to read. + /// This returns only a read plan, not a unit/value guess. + /// + public static IReadOnlyList BuildDataSetUnitRead( + LiveIedModelDiscoveryDocument? verifiedLiveModel, + LiveIedDataSetModel? selectedSet, + IedBrowserLiveValues ledger, int maxTargets = 12) + { + if (verifiedLiveModel is null || selectedSet is null || + maxTargets < 2 || ledger.IsOffline || + !verifiedLiveModel.DataSets.Any(ds => + ReferenceEquals(ds, selectedSet))) + return Array.Empty(); + + var schema = new IedBrowserSchemaIndex(verifiedLiveModel); + var association = ledger.Association; + var found = new List(maxTargets); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var obj in verifiedLiveModel.LogicalDevices.SelectMany( + ld => ld.LogicalNodes).SelectMany(ln => ln.DataObjects)) + { + if (found.Count + 2 > maxTargets) break; + var objectRef = IedBrowserLiveValues.CanonicalReference(obj.Reference); + var measurements = obj.Attributes.Where(attr => + attr.FunctionalConstraint == "MX" && + (IedBrowserLiveValues.CanonicalReference(attr.MmsReference) + .EndsWith(".mag.f", StringComparison.Ordinal))); + if (!measurements.Any(attr => schema.ContainsDataSetMember( + selectedSet.Reference, + IedBrowserLiveValues.CanonicalReference(attr.MmsReference), + "MX"))) + continue; + var expectedSi = objectRef + ".units.SIUnit"; + var expectedMul = objectRef + ".units.multiplier"; + var cf = obj.Attributes.Where(a => a.FunctionalConstraint == "CF") + .GroupBy(a => IedBrowserLiveValues.CanonicalReference(a.MmsReference), + StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToArray(), StringComparer.Ordinal); + if (!cf.TryGetValue(expectedSi, out var si) || si.Length != 1 || + !cf.TryGetValue(expectedMul, out var mul) || mul.Length != 1) + continue; + foreach (var attr in new[] { si[0], mul[0] }) + { + var key = IedBrowserLiveValues.CanonicalReference(attr.MmsReference) + "|CF"; + if (!seen.Add(key)) continue; + if (ledger.TryGet(attr.MmsReference, "CF", out var observed) && + !observed.IsStale && observed.Association == association) + continue; + found.Add(new IedBrowserReadTarget(attr.MmsReference, "CF", + string.IsNullOrWhiteSpace(attr.SclBType) ? attr.MmsType : attr.SclBType)); + } + } + return found; + } + /// /// Finite, evidence-first initial acquisition for a freshly discovered /// LIVE MMS schema. This is not Browser Read All or a new polling plan: diff --git a/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs new file mode 100644 index 000000000..d4d2c14a3 --- /dev/null +++ b/tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs @@ -0,0 +1,122 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +/// +/// Exact, finite selection of MMS evidence for SGCB, LN and MX DataSet unit CF. +/// This does not manufacture process values or authorize writes. +/// +public sealed class M217LBrowserFocusedHydrationTests +{ + [Fact] + public void FocusedHydrationUsesAssociationBoundExistingReadPath() + { + var source = ReadSource("MainWindow.IedBrowser.cs"); + Assert.Contains("QueueIedBrowserFocusedRead(row)", source); + Assert.Contains("QueueIedBrowserFocusedRead(_iedBrowserNavigation.Current,", source); + Assert.Contains("page.Key + \"|\" + (focusedObjectReference ?? \"\")", source); + Assert.Contains("_iedBrowserAutoReadPages.Count >= 128", source); + Assert.Contains("Tag = \"FocusedAuto\"", source); + Assert.Contains("const int maxFocusedTargets = 12", source); + Assert.Contains("cts.CancelAfter(TimeSpan.FromSeconds(8))", source); + Assert.Contains("device.LiveDiscoveryModel is null", source); + Assert.Contains("device.LiveValues.Association", source); + Assert.Contains("IedBrowserReadPlan.Build(", source); + Assert.Contains("IedBrowserReadPlan.BuildDataSetUnitRead(", source); + Assert.Contains("IedBrowserIsolatedReadService.ReadAsync(", source); + Assert.Contains("sourceDevice.LiveValues.PublishForAssociation(", source); + Assert.Contains("page?.Kind is not (\"SGCB\" or \"LN\" or \"DataSet\")", source); + } + + [Fact] + public void VerifiedSgAndDataModelPathsKeepTypedFcBoundaries() + { + var control = ReadSource("Models/IedBrowserControlStateEvidence.cs"); + Assert.Contains("NumOfSG", control); + Assert.Contains("ActSG", control); + Assert.Contains("LActTm", control); + var plan = ReadSource("Models/IedBrowserReadPlan.cs"); + Assert.Contains("IedBrowserControlStateEvidence.ReadTargets", plan); + var browser = ReadSource("MainWindow.IedBrowser.cs"); + Assert.Contains("currentPage?.Kind == \"SGCB\"", browser); + Assert.Contains("t.FunctionalConstraint == \"SG\" ? 1 : 0", browser); + Assert.Contains("targets.Where(t => t.FunctionalConstraint is \"ST\" or \"MX\")", browser); + } + + [Fact] + public void ExactLiveMxDataSetPlansOnlyPairedCfUnitReads() + { + const string owner = "LD/MMXU1.A"; + LiveIedDataAttributeModel Attribute(string fc, string suffix) => new() + { + ObjectReference = owner, + AttributePath = "A." + suffix, + MmsReference = "LD/MMXU1$" + fc + "$A$" + suffix.Replace('.', '$'), + FunctionalConstraint = fc, + SclBType = "Enum" + }; + var obj = new LiveIedDataObjectModel + { + Name = "A", Reference = owner, InferredCdc = "WYE", + Attributes = [ + Attribute("MX", "phsA.cVal.mag.f"), + Attribute("CF", "units.SIUnit"), + Attribute("CF", "units.multiplier") + ] + }; + var set = new LiveIedDataSetModel + { + Name = "Measurements", Reference = "LD/LLN0.Measurements", + Domain = "LD", LogicalNode = "LLN0", + Members = [new LiveIedDataSetMemberModel + { + Reference = owner, FunctionalConstraint = "MX" + }] + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "MMXU1", DataObjects = [obj] + }] + }], + DataSets = [set] + }; + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + var targets = IedBrowserReadPlan.BuildDataSetUnitRead(model, set, ledger); + Assert.Equal(2, targets.Count); + Assert.All(targets, target => Assert.Equal("CF", target.FunctionalConstraint)); + Assert.Contains(targets, t => t.Reference.EndsWith("$units$SIUnit", StringComparison.Ordinal)); + Assert.Contains(targets, t => t.Reference.EndsWith("$units$multiplier", StringComparison.Ordinal)); + Assert.True(ledger.PublishForAssociation(association, + owner + ".units.SIUnit", "CF", "5", "", "", "MMS Read")); + var missing = IedBrowserReadPlan.BuildDataSetUnitRead(model, set, ledger); + Assert.Single(missing); + Assert.EndsWith("$units$multiplier", missing[0].Reference); + var other = new LiveIedDataSetModel + { + Name = "Wrong", Reference = "LD/LLN0.Wrong", + Domain = "LD", LogicalNode = "LLN0", + Members = [new LiveIedDataSetMemberModel + { + Reference = owner, FunctionalConstraint = "ST" + }] + }; + Assert.Empty(IedBrowserReadPlan.BuildDataSetUnitRead(model, other, ledger)); + } + + private static string ReadSource(string path) + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root is not null && + !File.Exists(Path.Combine(root.FullName, "MainWindow.IedBrowser.cs"))) + root = root.Parent; + Assert.NotNull(root); + return File.ReadAllText(Path.Combine(root!.FullName, + path.Replace('/', Path.DirectorySeparatorChar))); + } +}