From 4be7b51b9040a7a5f1d8ecaa1d90bbc1a3b9d081 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 13:12:35 +0700 Subject: [PATCH] feat(browser): coalesce exact IEC DO views and present compact phase hierarchy --- Models/IedBrowserEngineeringComposite.cs | 14 +- Models/IedBrowserEngineeringObjects.cs | 55 +++++++ Models/IedBrowserPresentation.cs | 8 +- Models/IedBrowserStructuredDetails.cs | 76 +++++++++- .../M214EngineeringHierarchyUxTests.cs | 140 ++++++++++++++++++ .../M214MergedCfAndVectorAuthorityTests.cs | 15 +- 6 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 Models/IedBrowserEngineeringObjects.cs create mode 100644 tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs diff --git a/Models/IedBrowserEngineeringComposite.cs b/Models/IedBrowserEngineeringComposite.cs index 4eabd51c3..48ba62366 100644 --- a/Models/IedBrowserEngineeringComposite.cs +++ b/Models/IedBrowserEngineeringComposite.cs @@ -99,7 +99,13 @@ public static bool TryCompose( // neutral/net/residual extensions not covered by three-phase plans. // The independent adapter shares CanPair: never bypass timestamp // or source checks through a secondary renderer in MainWindow. - if (row.VectorComponents.Count > 3) + // A top-level DO shows the canonical three-phase engineering preview + // without dumping every neutral/net/res component into one grid cell. + // Those extra exact components remain accessible on expansion. + // Independent inspection rows still use the full-vector adapter. + var compactDo = row.IsDataObject && row.PhasePlans.Count == 3 && + row.VectorComponents.Count > 3; + if (row.VectorComponents.Count > 3 && !compactDo) return TryIndependent(row, ledger, unitIndex, monitoring, out observed); IReadOnlyList plans = row.PhasePlans.Count > 0 ? row.PhasePlans : row.PolarPlan is { } polar @@ -162,7 +168,7 @@ public static bool TryCompose( references.Add(plan.AngleReference + "[" + plan.FunctionalConstraint + "]"); anglePaired = true; } - if (!anglePaired) + if (!anglePaired && !compactDo) text += " (angle not verified)"; components.Add(plan.Phase.Length == 0 ? text : plan.Phase + ": " + text); } @@ -173,7 +179,9 @@ public static bool TryCompose( observed = new IedBrowserCompositeObservation(prefix, sources, "Derived from exact " + string.Join(", ", references) + " · association " + generation + " · sources " + sources + - " · independently observed components; not a synchronous phasor sample", + (compactDo + ? " · three-phase summary only; other model-proven components on expansion" + : " · independently observed components; not a synchronous phasor sample"), stale); return true; } diff --git a/Models/IedBrowserEngineeringObjects.cs b/Models/IedBrowserEngineeringObjects.cs new file mode 100644 index 000000000..2b5ec785c --- /dev/null +++ b/Models/IedBrowserEngineeringObjects.cs @@ -0,0 +1,55 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Models; + +/// +/// Browser-only schema coalescing: a discovery or SCL importer may give +/// separate DO declarations per FC. Those declarations are one engineering +/// object only when the exact IEC DO reference AND object name match. +/// Distinct FC leaves remain separate and the canonical model is untouched. +/// +public static class IedBrowserEngineeringObjects +{ + public static IReadOnlyList GroupExact( + IReadOnlyList objects) + { + ArgumentNullException.ThrowIfNull(objects); + return objects.GroupBy(o => (o.Reference, o.Name)) + .Select(group => + { + var first = group.First(); + if (group.Count() == 1) return first; + // A conflicting CDC or type declaration cannot be silently + // resolved by first-value-wins. Keep ambiguous metadata blank. + var cdc = Unique(group.Select(o => o.InferredCdc)); + var doType = Unique(group.Select(o => o.ProposedDoTypeId)); + var attributes = group.SelectMany(o => o.Attributes) + .DistinctBy(a => (a.MmsReference, a.FunctionalConstraint, + a.AttributePath, a.MmsTypeSignature)) + .ToArray(); + return new LiveIedDataObjectModel + { + Reference = first.Reference, + Name = first.Name, + InferredCdc = cdc, + ProposedDoTypeId = doType, + CdcConfidence = cdc.Length > 0 ? group.Max(x => x.CdcConfidence) : 0, + ConfidenceLevel = group.All(x => x.ConfidenceLevel == first.ConfidenceLevel) + ? first.ConfidenceLevel : LiveIedDiscoveryConfidenceLevel.Unknown, + Evidence = group.SelectMany(x => x.Evidence) + .Distinct(StringComparer.Ordinal).ToArray(), + TypeDeclarationOrder = group.Select(x => x.TypeDeclarationOrder) + .Where(s => s.Length > 0) + .OrderBy(s => s, StringComparer.Ordinal).FirstOrDefault() ?? "", + Attributes = attributes + }; + }).ToArray(); + } + + private static string Unique(IEnumerable candidates) + { + var values = candidates.Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.Ordinal).Take(2).ToArray(); + return values.Length == 1 ? values[0] : ""; + } +} diff --git a/Models/IedBrowserPresentation.cs b/Models/IedBrowserPresentation.cs index c031b2f36..d572460a3 100644 --- a/Models/IedBrowserPresentation.cs +++ b/Models/IedBrowserPresentation.cs @@ -325,7 +325,8 @@ IedBrowserTreeRow Category(string name, string key, () => ld.LogicalNodes.Select(ln => new IedBrowserTreeRow("model/ln/" + ld.MmsDomain + "/" + ln.Name, ln.Name, "LN", 2, ln, - () => ln.DataObjects.Select(obj => + () => IedBrowserEngineeringObjects.GroupExact(ln.DataObjects) + .Select(obj => new IedBrowserTreeRow("model/do/" + obj.Reference, obj.Name, "DO", 3, obj, () => obj.Attributes.Select((attr, ordinal) => new IedBrowserTreeRow("model/da/" + attr.MmsReference + "/" + ordinal, @@ -711,7 +712,7 @@ void Member(string name, string value = "Not read", int depth = 0, string source rows.Add(new("DataHeader", "Name", "Live value · provenance")); // O(number of DO), with lazy expansion of each selected DO. // No recursive UI object creation for a large IED namespace. - foreach (var obj in ln.DataObjects) + foreach (var obj in IedBrowserEngineeringObjects.GroupExact(ln.DataObjects)) rows.Add(IedBrowserStructuredDetails.DataObject( obj, PrimaryValueFunctionalConstraint(obj))); break; @@ -722,7 +723,8 @@ void Member(string name, string value = "Not read", int depth = 0, string source Field("Type confidence", obj.ConfidenceLevel); Section("Data"); rows.Add(new("DataHeader", "Name", "Live value · provenance")); - foreach (var attr in IedBrowserStructuredDetails.AttributeRows(obj.Attributes, obj.Name)) + foreach (var attr in IedBrowserStructuredDetails.AttributeRows( + obj.Attributes, obj.Name, mergeFcRoots: true)) rows.Add(attr); break; case LiveIedDataAttributeModel attr: diff --git a/Models/IedBrowserStructuredDetails.cs b/Models/IedBrowserStructuredDetails.cs index 2e15274b6..5d7cf4ae4 100644 --- a/Models/IedBrowserStructuredDetails.cs +++ b/Models/IedBrowserStructuredDetails.cs @@ -57,7 +57,8 @@ public static IedBrowserDetailRow DataObject( Relative: TrimObjectName(a.AttributePath, obj.Name))), compoundFc), LoadChildren = () => AttributeRows( - obj.Attributes, obj.Name, depth + 1, allowVector) + obj.Attributes, obj.Name, depth + 1, allowVector, + mergeFcRoots: true) }; } @@ -165,14 +166,83 @@ public static IReadOnlyList DataSetMemberChildren( public static IReadOnlyList AttributeRows( IReadOnlyList attributes, string objectName, - int depth = 0, bool allowVector = true) + int depth = 0, bool allowVector = true, + bool mergeFcRoots = false) { var descriptors = attributes .Select(attr => (attr, parts: TrimObjectName(attr.AttributePath, objectName) .Split('.', StringSplitOptions.RemoveEmptyEntries))) .Where(x => x.parts.Length > 0) .ToArray(); - return BuildAt(descriptors, 0, depth, allowVector); + return mergeFcRoots + ? BuildCrossFcRoots(descriptors, depth, allowVector) + : BuildAt(descriptors, 0, depth, allowVector); + } + + + /// + /// One structured phase/SDO row in a DO page even when attributes are + /// distributed across MX/CF/DC. FC namespaces remain isolated in its lazy + /// children, never collapsed into a synthetic observed value. + /// + private static IReadOnlyList BuildCrossFcRoots( + (LiveIedDataAttributeModel attr, string[] parts)[] attrs, + int depth, bool allowVector) + { + var output = new List(); + foreach (var group in attrs.GroupBy(x => x.parts[0], StringComparer.Ordinal)) + { + var declarations = group.ToArray(); + var fcs = declarations.Select(x => x.attr.FunctionalConstraint) + .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (fcs.Length == 1) + { + output.AddRange(BuildAt(declarations, 0, depth, allowVector)); + continue; + } + // Multiple FC declarations of the *same* structured name: one + // display node, not phsA [MX], phsA [CF], phsA [DC] as siblings. + // Value selection is limited to one proven process FC. + var process = declarations.Where(x => + x.attr.FunctionalConstraint is "MX" or "ST").ToArray(); + var processFcs = process.Select(x => x.attr.FunctionalConstraint) + .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var fc = processFcs.Length == 1 ? processFcs[0] : ""; + var qualified = IedBrowserLiveValues.CanonicalReference( + declarations[0].attr.ObjectReference) + "." + group.Key; + var processOnly = process.Where(x => + string.Equals(x.attr.FunctionalConstraint, fc, + StringComparison.OrdinalIgnoreCase)).ToArray(); + var polar = allowVector && fc == "MX" + ? IedBrowserEngineeringComposite.BuildPolar( + processOnly.Select(x => x.attr), qualified) + : null; + var phases = allowVector && fc == "MX" && polar is null + ? IedBrowserEngineeringComposite.BuildThreePhases( + processOnly.Select(x => x.attr), qualified) + : Array.Empty(); + var scalar = fc.Length == 0 ? null : ChooseEngineeringLeaf( + processOnly.Where(x => x.parts.Length > 1).Select(x => ( + Reference: IedBrowserLiveValues.CanonicalReference(x.attr.MmsReference), + Relative: string.Join(".", x.parts.Skip(1))))); + var chosen = polar?.MagnitudeReference ?? scalar ?? qualified; + var descendants = declarations.Where(x => x.parts.Length > 1).ToArray(); + output.Add(new IedBrowserDetailRow("Data", group.Key, "Not read", depth, + "IEC structured object · exact FC namespaces: " + + string.Join(", ", fcs)) + { + IsDataObject = true, + ValueReference = chosen, + FunctionalConstraint = fc, + EngineeringUnitReference = scalar ?? polar?.MagnitudeReference ?? "", + IsEngineeringValue = scalar is not null || polar is not null, + PolarPlan = polar, + PhasePlans = phases, + LoadChildren = descendants.Length == 0 ? null + : () => BuildAt(descendants, 1, depth + 1, allowVector) + }); + } + return output; } private static IReadOnlyList BuildAt( diff --git a/tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs b/tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs new file mode 100644 index 000000000..b76a7d3c6 --- /dev/null +++ b/tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs @@ -0,0 +1,140 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M214EngineeringHierarchyUxTests +{ + private const string Owner = "LD/MMXU1.A"; + private static LiveIedDataAttributeModel Attr(string fc, string relative) + => new() + { + ObjectReference = Owner, AttributePath = "A." + relative, + FunctionalConstraint = fc, + MmsReference = "LD/MMXU1$" + fc + "$A$" + relative.Replace('.', '$') + }; + private static LiveIedDataObjectModel Part(string cdc, + params LiveIedDataAttributeModel[] attributes) + => new() + { + Name = "A", Reference = Owner, InferredCdc = cdc, + Attributes = attributes + }; + + [Fact] + public void LnPageAndNavigationShareOneEngineeringDoAcrossMxCfDc() + { + var ln = new LiveIedLogicalNodeModel + { + Name = "MMXU1", LnClass = "MMXU", + DataObjects = + [ + Part("WYE", Attr("MX", "phsA.cVal.mag.f"), + Attr("MX", "phsA.cVal.ang.f")), + Part("WYE", Attr("CF", "phsA.units.SIUnit"), + Attr("CF", "phsA.units.multiplier")), + Part("WYE", Attr("DC", "phsA.d")) + ] + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", LogicalNodes = [ln] + }] + }; + var page = new IedBrowserTreeRow("model/ln/LD/MMXU1", + "MMXU1", "LN", 2, ln); + var doRow = Assert.Single(IedBrowserPresentation.Details( + page, model, connected: true).Where(r => r.IsDataObject)); + Assert.Equal("A", doRow.Name); + Assert.True(doRow.HasChildren); + var phase = Assert.Single(doRow.GetChildren()); + Assert.Equal("phsA", phase.Name); + Assert.True(phase.HasChildren); + Assert.Equal("MX", phase.FunctionalConstraint); + var branchNames = phase.GetChildren().Select(x => x.Name).ToArray(); + Assert.Contains("cVal [MX]", branchNames); + Assert.Contains("units [CF]", branchNames); + Assert.Contains("d [DC]", branchNames); + Assert.DoesNotContain(doRow.GetChildren(), x => x.Name.Contains("[CF]")); + + var treeModel = Assert.Single(IedBrowserPresentation.Roots(model, + Array.Empty()).Where(r => r.Key == "category/model")); + var ld = Assert.Single(treeModel.GetChildren()); + var treeLn = Assert.Single(ld.GetChildren()); + Assert.Single(treeLn.GetChildren()); + Assert.Equal(Owner, ((LiveIedDataObjectModel)treeLn.GetChildren()[0].Payload!).Reference); + } + + [Fact] + public void DifferentExactDoReferencesNeverMergeAndConflictingCdcFailsClosed() + { + var same = IedBrowserEngineeringObjects.GroupExact( + [ + Part("WYE", Attr("MX", "phsA.cVal.mag.f")), + Part("CMV", Attr("CF", "phsA.units.SIUnit")), + new LiveIedDataObjectModel + { + Name = "A", Reference = "LD/MMXU2.A", + Attributes = [new LiveIedDataAttributeModel + { + MmsReference = "LD/MMXU2$MX$A$phsA$cVal$mag$f", + FunctionalConstraint = "MX" + }] + } + ]); + Assert.Equal(2, same.Count); + var first = Assert.Single(same.Where(x => x.Reference == Owner)); + Assert.Equal("", first.InferredCdc); + Assert.Equal(2, first.Attributes.Count); + } + + [Fact] + public void ThreePhaseRootUsesCompactPreview_WithoutLosingNeutralEvidence() + { + var phases = new[] { "phsA", "phsB", "phsC", "neut" }; + var obj = Part("WYE", phases.SelectMany(p => new[] + { + Attr("MX", p + ".cVal.mag.f"), + Attr("MX", p + ".cVal.ang.f") + }).ToArray()); + var row = IedBrowserStructuredDetails.DataObject(obj, "MX"); + Assert.Equal(4, row.VectorComponents.Count); + Assert.Equal(3, row.PhasePlans.Count); + var ledger = new IedBrowserLiveValues(); + var session = ledger.BeginAssociation(); + foreach (var phase in phases) + { + ledger.PublishForAssociation(session, + Owner + "." + phase + ".cVal.mag.f", "MX", + "0", "", "t1", "Report"); + ledger.PublishForAssociation(session, + Owner + "." + phase + ".cVal.ang.f", "MX", + "0", "", phase == "neut" ? "t2" : "t1", "Report"); + } + Assert.True(IedBrowserEngineeringComposite.TryCompose(row, ledger, + new Dictionary(), true, out var preview)); + Assert.Contains("phsA: 0", preview.Text); + Assert.Contains("phsC: 0", preview.Text); + Assert.DoesNotContain("neut:", preview.Text); + Assert.DoesNotContain("independent, not paired", preview.Text); + Assert.True(IedBrowserEngineeringComposite.TryCompose( + row with { IsDataObject = false }, ledger, + new Dictionary(), true, out var expanded)); + Assert.Contains("neut:", expanded.Text); + Assert.Contains("angle 0 (independent, not paired)", expanded.Text); + } + + [Fact] + public void PlainScalarMvIsNeverPromotedToAnAngleByGrouping() + { + var model = Part("MV", Attr("MX", "mag.f"), + Attr("CF", "units.SIUnit")); + var row = IedBrowserStructuredDetails.DataObject(model, "MX"); + Assert.Empty(row.PhasePlans); + Assert.Null(row.PolarPlan); + Assert.Empty(row.VectorComponents); + Assert.DoesNotContain(row.GetChildren(), x => x.PolarPlan is not null); + } +} diff --git a/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs b/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs index a8a363f65..5ee9b1e37 100644 --- a/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs +++ b/tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs @@ -43,11 +43,20 @@ public void NeutralExtension_UsesOneComposerAndRefusesMismatchedAngleTime() Owner + ".neut.cVal.mag.f", "MX", "3", "", "t1", "Report")); Assert.True(IedBrowserEngineeringComposite.TryCompose( row, ledger, new Dictionary(), true, out var unpaired)); - Assert.StartsWith("Independent", unpaired.Source); Assert.Contains("phsA: 10", unpaired.Text); - Assert.Contains("angle 30 (independent, not paired)", unpaired.Text); - Assert.Contains("neut: 3", unpaired.Text); + Assert.DoesNotContain("angle 30", unpaired.Text); + Assert.DoesNotContain("neut: 3", unpaired.Text); Assert.DoesNotContain("∠ 30", unpaired.Text); + Assert.Contains("other model-proven components on expansion", + unpaired.Provenance); + // The complete independently observed vector remains accessible + // through an explicitly expanded inspection row, never thrown away. + var expanded = row with { IsDataObject = false }; + Assert.True(IedBrowserEngineeringComposite.TryCompose( + expanded, ledger, new Dictionary(), true, out var all)); + Assert.StartsWith("Independent", all.Source); + Assert.Contains("angle 30 (independent, not paired)", all.Text); + Assert.Contains("neut: 3", all.Text); Assert.True(ledger.PublishForAssociation(session, Owner + ".phsA.cVal.ang.f", "MX", "30", "", "t1", "Report"));