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
14 changes: 11 additions & 3 deletions Models/IedBrowserEngineeringComposite.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IedBrowserPolarPlan> plans = row.PhasePlans.Count > 0
? row.PhasePlans : row.PolarPlan is { } polar
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}
Expand Down
55 changes: 55 additions & 0 deletions Models/IedBrowserEngineeringObjects.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using AR.Iec61850.Discovery;

namespace ArIED61850Tester.Models;

/// <summary>
/// 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.
/// </summary>
public static class IedBrowserEngineeringObjects
{
public static IReadOnlyList<LiveIedDataObjectModel> GroupExact(
IReadOnlyList<LiveIedDataObjectModel> 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<string> candidates)
{
var values = candidates.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.Ordinal).Take(2).ToArray();
return values.Length == 1 ? values[0] : "";
}
}
8 changes: 5 additions & 3 deletions Models/IedBrowserPresentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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:
Expand Down
76 changes: 73 additions & 3 deletions Models/IedBrowserStructuredDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
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)
};
}

Expand Down Expand Up @@ -165,14 +166,83 @@

public static IReadOnlyList<IedBrowserDetailRow> AttributeRows(
IReadOnlyList<LiveIedDataAttributeModel> 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);
}


/// <summary>
/// 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.
/// </summary>
private static IReadOnlyList<IedBrowserDetailRow> BuildCrossFcRoots(
(LiveIedDataAttributeModel attr, string[] parts)[] attrs,
int depth, bool allowVector)
{
var output = new List<IedBrowserDetailRow>();
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<IedBrowserPolarPlan>();
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<IedBrowserDetailRow> BuildAt(
Expand All @@ -193,7 +263,7 @@
var hasDirect = matching.attr is not null;
var attr = hasDirect ? matching.attr : groupArray[0].attr;
var descendants = groupArray.Where(x => x.parts.Length > pathIndex + 1).ToArray();
var fullPrefix = IedBrowserLiveValues.CanonicalReference(attr.ObjectReference) +

Check warning on line 266 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 266 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 266 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 266 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 266 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 266 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.
"." + string.Join(".", groupArray[0].parts.Take(pathIndex + 1));
var polar = allowVector
? IedBrowserEngineeringComposite.BuildPolar(
Expand Down
140 changes: 140 additions & 0 deletions tests/ARSAS.Tests/M214EngineeringHierarchyUxTests.cs
Original file line number Diff line number Diff line change
@@ -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<RcbExportRow>()).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<string, string?>(), 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<string, string?>(), 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);
}
}
15 changes: 12 additions & 3 deletions tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string?>(), 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<string, string?>(), 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"));
Expand Down
Loading