Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> _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;
Expand All @@ -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))
Expand All @@ -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.";
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions Models/IedBrowserReadPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,65 @@ page.Kind is "SGCB" or "GoCB"
.ToArray();
}

/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<IedBrowserReadTarget> 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<IedBrowserReadTarget>();

var schema = new IedBrowserSchemaIndex(verifiedLiveModel);
var association = ledger.Association;
var found = new List<IedBrowserReadTarget>(maxTargets);
var seen = new HashSet<string>(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;
}

/// <summary>
/// Finite, evidence-first initial acquisition for a freshly discovered
/// LIVE MMS schema. This is not Browser Read All or a new polling plan:
Expand Down
122 changes: 122 additions & 0 deletions tests/ARSAS.Tests/M217LBrowserFocusedHydrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;

namespace ARSAS.Tests;

/// <summary>
/// Exact, finite selection of MMS evidence for SGCB, LN and MX DataSet unit CF.
/// This does not manufacture process values or authorize writes.
/// </summary>
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)));
}
}
Loading