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
25 changes: 8 additions & 17 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -438,18 +438,18 @@ private void RefreshIedBrowserVisibleValues()
if (string.IsNullOrWhiteSpace(row.ValueReference)) continue;
if (cfEvidence.TryFormatUnitsParent(
row.ValueReference, row.FunctionalConstraint,
out var verifiedUnit, out var verifiedProvenance))
out var verifiedUnit, out var verifiedProvenance,
out var verifiedAuthority))
{
row.Value = verifiedUnit;
row.ObservationSource = "Verified IEC CF configuration";
row.Value = verifiedAuthority.StartsWith("Stale ", StringComparison.Ordinal)
? "Offline · " + verifiedUnit : verifiedUnit;
row.ObservationSource = verifiedAuthority;
row.ObservationTooltip = verifiedProvenance;
continue;
}
// Prefer the schema-authorized, timestamp-compatible phasor
// composer from M2.14. Extra neutral/net/res components retain
// the bounded, independently observed presentation below.
if (row.VectorComponents.Count <= 3 &&
IedBrowserEngineeringComposite.TryCompose(
// Single IEC composition entry point, with strict compatible
// phasors and independently labelled extra vector components.
if (IedBrowserEngineeringComposite.TryCompose(
row, device.LiveValues, engineeringUnits,
device.IsMonitoring, out var composite))
{
Expand All @@ -460,15 +460,6 @@ private void RefreshIedBrowserVisibleValues()
row.ObservationTooltip = composite.Provenance;
continue;
}
if (IedBrowserObservedVectorPresentation.TryFormat(
device.LiveValues, row.VectorComponents, engineeringUnits,
device.IsMonitoring, out var vector))
{
row.Value = vector.IsStale ? "Offline · " + vector.Value : vector.Value;
row.ObservationSource = vector.Source;
row.ObservationTooltip = vector.Provenance;
continue;
}
var found = IedBrowserDetailEvidenceResolver.TryResolve(
device.LiveValues, row, _iedBrowserValueSchemaIndex,
device.IsMonitoring, out var live, out var observedReference);
Expand Down
17 changes: 15 additions & 2 deletions Models/IedBrowserCfEvidencePresentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,27 @@ public bool TryGet(string reference, string fc, out string value,
/// </summary>
public bool TryFormatUnitsParent(string reference, string fc,
out string unit, out string provenance)
=> TryFormatUnitsParent(reference, fc, out unit, out provenance, out _);

/// <summary>
/// A complete configuration pair is not necessarily current. Keep
/// historical and stale evidence visibly distinct from an MMS Read.
/// </summary>
public bool TryFormatUnitsParent(string reference, string fc,
out string unit, out string provenance, out string authority)
{
unit = "";
provenance = "";
authority = "";
var owner = IedBrowserLiveValues.CanonicalReference(reference);
if (!string.Equals(fc, "CF", StringComparison.OrdinalIgnoreCase) ||
!owner.EndsWith(".units", StringComparison.Ordinal))
!owner.EndsWith(".units", StringComparison.Ordinal) ||
!TryVerifyUnitPair(owner, out unit, out provenance, out var fresh))
return false;
return TryVerifyUnitPair(owner, out unit, out provenance, out _);
authority = provenance.Contains("Discovery snapshot", StringComparison.Ordinal)
? "Historical discovery CF"
: fresh ? "Verified IEC CF configuration" : "Stale IEC CF configuration";
return true;
}

private bool TryVerifyUnitPair(string owner, out string unit,
Expand Down
24 changes: 23 additions & 1 deletion Models/IedBrowserEngineeringComposite.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,17 @@ public static bool TryCompose(
ArgumentNullException.ThrowIfNull(row);
ArgumentNullException.ThrowIfNull(ledger);
observed = null!;
// One entry point for every schema-qualified vector, including
// 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)
return TryIndependent(row, ledger, unitIndex, monitoring, out observed);
IReadOnlyList<IedBrowserPolarPlan> plans = row.PhasePlans.Count > 0
? row.PhasePlans : row.PolarPlan is { } polar
? new[] { polar } : Array.Empty<IedBrowserPolarPlan>();
if (plans.Count == 0) return false;
if (plans.Count == 0)
return TryIndependent(row, ledger, unitIndex, monitoring, out observed);

var components = new List<string>(plans.Count);
var authorities = new HashSet<string>(StringComparer.Ordinal);
Expand Down Expand Up @@ -171,6 +178,21 @@ public static bool TryCompose(
return true;
}

private static bool TryIndependent(IedBrowserDetailRow row,
IedBrowserLiveValues ledger, IReadOnlyDictionary<string, string?> unitIndex,
bool monitoring, out IedBrowserCompositeObservation result)
{
result = null!;
if (!IedBrowserObservedVectorPresentation.TryFormat(
ledger, row.VectorComponents, unitIndex, monitoring,
out var independent))
return false;
result = new IedBrowserCompositeObservation(
independent.Value, "Independent " + independent.Source,
independent.Provenance, independent.IsStale);
return true;
}

/// <summary>
/// Shared compatibility gate for both the standard three-phase composer and
/// additional schema-declared neutral/residual vector rows. An independent
Expand Down
5 changes: 3 additions & 2 deletions tests/ARSAS.Tests/IedBrowserObservedVectorM213HTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ public void BrowserValueRefreshWiresModelProvenVectorProjection()
dir = dir.Parent;
Assert.NotNull(dir);
var source = File.ReadAllText(Path.Combine(dir!.FullName, "MainWindow.IedBrowser.cs"));
Assert.Contains("IedBrowserObservedVectorPresentation.TryFormat(", source);
Assert.Contains("device.LiveValues, row.VectorComponents, engineeringUnits", source);
Assert.Contains("IedBrowserEngineeringComposite.TryCompose(", source);
Assert.Contains("row, device.LiveValues, engineeringUnits", source);
Assert.DoesNotContain("if (IedBrowserObservedVectorPresentation.TryFormat(", source);
}
}
160 changes: 160 additions & 0 deletions tests/ARSAS.Tests/M214MergedCfAndVectorAuthorityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using AR.Iec61850.Discovery;
using AR.Iec61850.Mms;
using ArIED61850Tester.Models;

namespace ARSAS.Tests;

/// <summary>
/// Integrates Thread A's CF authority with Thread B's normalized scalar/vector
/// projections on the current, already-validated Browser stack.
/// </summary>
public sealed class M214MergedCfAndVectorAuthorityTests
{
private const string Owner = "LD/MMXU1.PhV";

private static LiveIedDataAttributeModel Leaf(string phase, string suffix) => new()
{
ObjectReference = Owner, AttributePath = "PhV." + phase + ".cVal." + suffix,
FunctionalConstraint = "MX",
MmsReference = "LD/MMXU1$MX$PhV$" + phase + "$cVal$" +
suffix.Replace('.', '$')
};

[Fact]
public void NeutralExtension_UsesOneComposerAndRefusesMismatchedAngleTime()
{
var model = new LiveIedDataObjectModel
{
Name = "PhV", Reference = Owner, InferredCdc = "WYE",
Attributes = new[] { "phsA", "phsB", "phsC", "neut" }
.SelectMany(p => new[] { Leaf(p, "mag.f"), Leaf(p, "ang.f") })
.ToArray()
};
var row = IedBrowserStructuredDetails.DataObject(model, "MX");
Assert.Equal(4, row.VectorComponents.Count);
Assert.Equal(3, row.PhasePlans.Count);
var ledger = new IedBrowserLiveValues();
var session = ledger.BeginAssociation();
Assert.True(ledger.PublishForAssociation(session,
Owner + ".phsA.cVal.mag.f", "MX", "10", "", "t1", "Report"));
Assert.True(ledger.PublishForAssociation(session,
Owner + ".phsA.cVal.ang.f", "MX", "30", "", "t2", "Report"));
Assert.True(ledger.PublishForAssociation(session,
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("∠ 30", unpaired.Text);

Assert.True(ledger.PublishForAssociation(session,
Owner + ".phsA.cVal.ang.f", "MX", "30", "", "t1", "Report"));
Assert.True(IedBrowserEngineeringComposite.TryCompose(
row, ledger, new Dictionary<string, string?>(), true, out var paired));
Assert.Contains("phsA: 10 ∠ 30", paired.Text);
}

[Fact]
public void CfUnitHistoryStalenessAndReconnect_AreNeverPresentedAsFresh()
{
var owner = Owner + ".phsA";
var model = new LiveIedModelDiscoveryDocument
{
LogicalDevices = [new LiveIedLogicalDeviceModel
{
LogicalNodes = [new LiveIedLogicalNodeModel
{
DataObjects = [new LiveIedDataObjectModel
{
Name = "PhV", Reference = Owner,
Attributes =
[
new LiveIedDataAttributeModel
{
FunctionalConstraint = "CF",
MmsReference = "LD/MMXU1$CF$PhV$phsA$units$SIUnit"
},
new LiveIedDataAttributeModel
{
FunctionalConstraint = "CF",
MmsReference = "LD/MMXU1$CF$PhV$phsA$units$multiplier"
}
]
}]
}]
}]
};
var canonical = new LiveIedCanonicalModel
{
Discovery = model,
InstanceValues =
[
new LiveIedInstanceValueEvidence
{
Domain = "LD", LogicalNode = "MMXU1",
DataObject = "PhV", AttributePath = "phsA.units.SIUnit",
FunctionalConstraint = "CF", Value = MmsDataValue.Integer(29)
},
new LiveIedInstanceValueEvidence
{
Domain = "LD", LogicalNode = "MMXU1",
DataObject = "PhV", AttributePath = "phsA.units.multiplier",
FunctionalConstraint = "CF", Value = MmsDataValue.Integer(3)
}
]
};
var values = new IedBrowserLiveValues();
var projection = new IedBrowserCfEvidencePresentation(values, model, canonical);
Assert.True(projection.TryFormatUnitsParent(owner + ".units", "CF",
out var historical, out _, out var historicalAuthority));
Assert.Equal("kV", historical);
Assert.Equal("Historical discovery CF", historicalAuthority);
var session = values.BeginAssociation();
Assert.True(values.PublishForAssociation(session, owner + ".units.SIUnit",
"CF", "29", "", "", "MMS Read"));
Assert.True(values.PublishForAssociation(session, owner + ".units.multiplier",
"CF", "3", "", "", "MMS Read"));
Assert.True(projection.TryFormatUnitsParent(owner + ".units", "CF",
out var verified, out _, out var verifiedAuthority));
Assert.Equal("kV", verified);
Assert.Equal("Verified IEC CF configuration", verifiedAuthority);
values.MarkOffline();
Assert.True(projection.TryFormatUnitsParent(owner + ".units", "CF",
out _, out _, out var staleAuthority));
Assert.Equal("Stale IEC CF configuration", staleAuthority);
values.BeginAssociation();
Assert.True(projection.TryFormatUnitsParent(owner + ".units", "CF",
out _, out _, out var postReconnectAuthority));
Assert.Equal("Historical discovery CF", postReconnectAuthority);
}

[Fact]
public void ScalarMvStillCannotEnterVectorFallback_DespiteSharedEntryPoint()
{
const string owner = "LD/MMXU1.Hz";
var scalar = new LiveIedDataObjectModel
{
Reference = owner, Name = "Hz", InferredCdc = "MV",
Attributes = [new LiveIedDataAttributeModel
{
ObjectReference = owner, AttributePath = "Hz.mag.f",
FunctionalConstraint = "MX",
MmsReference = "LD/MMXU1$MX$Hz$mag$f"
}]
};
var row = IedBrowserStructuredDetails.DataObject(scalar, "MX");
Assert.Empty(row.VectorComponents);
Assert.Empty(row.PhasePlans);
Assert.Null(row.PolarPlan);
var ledger = new IedBrowserLiveValues();
var session = ledger.BeginAssociation();
ledger.PublishForAssociation(session, row.ValueReference,
"MX", "50", "", "", "MMS Read");
Assert.False(IedBrowserEngineeringComposite.TryCompose(
row, ledger, new Dictionary<string, string?>(), false, out _));
Assert.True(ledger.TryGet(row.ValueReference, "MX", out var trueScalar));
Assert.Equal("50", trueScalar.Value);
}
}
Loading