Skip to content
Closed
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
54 changes: 54 additions & 0 deletions Services/Iec61850DataSetSignalInventoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals(
{
if (ApplyEngineDataSetAuthority(current, descriptor, inventoryReference, runtimeBinding))
enriched++;
AddPhaseRows(signals, model, descriptor, inventoryReference, added);
continue;
}

Expand All @@ -88,11 +89,64 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals(
var signal = CreateSignal(descriptor, runtimeBinding, inventoryReference);
signals.Add(signal);
added.Add(signal);
AddPhaseRows(signals, model, descriptor, inventoryReference, added);
}

return new Iec61850DataSetSignalInventoryMergeResult(added, enriched, mandatory.Count);
}

private static void AddPhaseRows(
ICollection<SignalDefinition> signals,
LiveIedModelDiscoveryDocument model,
Iec61850SignalDescriptor descriptor,
string staticMember,
List<SignalDefinition> added)
{
// Only schema-proven phase descendants of an unresolved whole-DO FCD.
// Keep the original mandatory FCD row; never replace its identity.
if (descriptor.ResolutionStatus != Iec61850SignalCatalogResolutionStatus.Unresolved)
return;

var membership = FirstMembership(descriptor);
if (membership is null || string.IsNullOrWhiteSpace(membership.DataSetReference))
return;

foreach (var leaf in Iec61850StaticPhaseLeafProjection.Resolve(
model, staticMember, descriptor.FunctionalConstraint))
{
if (signals.Any(signal =>
ReferenceEquals(signal.ObjectReference, leaf.Reference) &&
ReferenceEquals(signal.DisplayReference, leaf.Reference) &&
ReferenceEquals(signal.DataSetReference, membership.DataSetReference)))
continue;

var row = new SignalDefinition
{
Name = descriptor.DataObject + " " + leaf.Phase,
ObjectReference = leaf.Reference,
DisplayReference = leaf.Reference,
FunctionalConstraint = "MX",
DataType = leaf.DataType,
Category = "Measurement",
Confidence = "High",
DataSetReference = membership.DataSetReference,
ReportControlReference = descriptor.ReportMemberships.FirstOrDefault()?.ReportControlReference ?? "",
QualityReference = leaf.QualityReference,
TimestampReference = leaf.TimestampReference,
Source = "ARIEC61850 signal inventory • mandatory static DataSet member • schema named phase",
IsReportCapable = true,
ReportCoverage = "Static DataSet phase leaf • awaiting configured report",
ReportCoverageReason = "Exact schema-proven phase of the configured static FCD; requires actual report ingress.",
ProbeStatus = "Exact phase schema; report pending",
Value = "-",
Quality = "Unknown",
DeviceTimestamp = "-"
};
signals.Add(row);
added.Add(row);
}
}

private static SignalDefinition? FindExistingMembershipSignal(
IEnumerable<SignalDefinition> signals,
Iec61850SignalDescriptor descriptor,
Expand Down
16 changes: 16 additions & 0 deletions Services/Iec61850StaticDataSetAuthoritySelection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ public static IReadOnlySet<SignalDefinition> Build(Iec61850MonitorDevice device)
selected.Add(control);
}

// A single configured WYE/DEL whole-DO membership can own several
// independent schema-proven phase leaves. Retain the original static
// membership and select every exact leaf with this DataSet owner.
foreach (var leaf in Iec61850StaticPhaseLeafProjection.Resolve(
authorityModel, memberReference, descriptor.FunctionalConstraint))
{
var matches = signals
.Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime)
.Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference))
.Where(signal => LiteralEquals(signal.ObjectReference, leaf.Reference))
.Where(signal => LiteralEquals(signal.DisplayReference, leaf.Reference))
.ToArray();
if (matches.Length == 1)
selected.Add(matches[0]);
}

var candidates = signals
.Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime)
.Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference))
Expand Down
74 changes: 74 additions & 0 deletions Services/Iec61850StaticPhaseLeafProjection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using AR.Iec61850.Discovery;

namespace ArIED61850Tester.Services;

/// <summary>
/// Maps one typed WYE/DEL whole-DO static FCDA into named phase magnitudes.
/// No MMS reads, numeric-child selection, or additional DataSet/RCB are created.
/// </summary>
public static class Iec61850StaticPhaseLeafProjection
{
public sealed record Leaf(
string Reference, string Phase, string DataType,
string QualityReference, string TimestampReference);

public static IReadOnlyList<Leaf> Resolve(
LiveIedModelDiscoveryDocument? model, string? memberReference, string? fc)
{
if (model is null || string.IsNullOrWhiteSpace(memberReference) ||
!string.Equals(fc, "MX", StringComparison.OrdinalIgnoreCase))
return Array.Empty<Leaf>();

var normalized = Normalize(memberReference);
var owners = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes)
.SelectMany(ln => ln.DataObjects)
.Where(obj => string.Equals(Normalize(obj.Reference), normalized, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (owners.Length != 1)
return Array.Empty<Leaf>();

var owner = owners[0];
var phases = (owner.InferredCdc ?? string.Empty).Trim().ToUpperInvariant() switch
{
"WYE" => new[] { "phsA", "phsB", "phsC", "neut", "net", "res" },
"DEL" => new[] { "phsAB", "phsBC", "phsCA" },
_ => Array.Empty<string>()
};
var result = new List<Leaf>(phases.Length);
foreach (var phase in phases)
{
var prefix = normalized + "." + phase;
if (!SchemaSafeAggregateProjectionService.TryResolveStaticDataSetPrimaryLeaf(
model, prefix, "MX", out var leaf, out _) ||
!IsNumeric(leaf.DataType) ||
!Normalize(leaf.Reference).StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase))
continue;

result.Add(new Leaf(
leaf.Reference, phase, leaf.DataType,
HasAttribute(owner, prefix + ".q") ? prefix + ".q" : "",
HasAttribute(owner, prefix + ".t") ? prefix + ".t" : ""));
}
return result;
}

private static bool HasAttribute(LiveIedDataObjectModel owner, string reference)
=> owner.Attributes.Any(attribute =>
string.Equals(attribute.FunctionalConstraint, "MX", StringComparison.OrdinalIgnoreCase) &&
string.Equals(Normalize(string.IsNullOrWhiteSpace(attribute.ObjectReference)
? owner.Reference + "." + attribute.AttributePath : attribute.ObjectReference),
reference, StringComparison.OrdinalIgnoreCase));

private static bool IsNumeric(string? type)
{
var value = (type ?? string.Empty).Trim();
return value.Equals("FLOAT32", StringComparison.OrdinalIgnoreCase) ||
value.Equals("FLOAT64", StringComparison.OrdinalIgnoreCase) ||
value.Equals("floating-point", StringComparison.OrdinalIgnoreCase) ||
value.Equals("INT32", StringComparison.OrdinalIgnoreCase) ||
value.Equals("INT64", StringComparison.OrdinalIgnoreCase);
}

private static string Normalize(string? reference)
=> (reference ?? string.Empty).Trim().Replace('$', '.');
}
12 changes: 9 additions & 3 deletions Services/SchemaSafeAggregateProjectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,16 @@ public static bool TryResolveStaticDataSetPrimaryLeaf(

var requested = NormalizeReference(requestedReference).TrimEnd('.');
var dataObjectReference = NormalizeReference(dataObject.Reference).TrimEnd('.');
if (requested.Length <= dataObjectReference.Length ||
!requested.StartsWith(dataObjectReference + ".", StringComparison.OrdinalIgnoreCase))
// A top-level WYE/DEL has multiple phases and must never be collapsed.
// Top-level scalar CMV/MV FCDs (such as AuxV) can resolve their unique
// typed cVal.mag.f or mag.f using the same schema-safe leaf tiers.
var topLevel = requested.Equals(dataObjectReference, StringComparison.OrdinalIgnoreCase);
var scalarCdc = string.Equals(dataObject.InferredCdc, "CMV", StringComparison.OrdinalIgnoreCase) ||
string.Equals(dataObject.InferredCdc, "MV", StringComparison.OrdinalIgnoreCase);
if ((topLevel && !scalarCdc) ||
(!topLevel && !requested.StartsWith(dataObjectReference + ".", StringComparison.OrdinalIgnoreCase)))
{
status = $"Static DataSet primary-leaf resolution does not own top-level DataObject {requestedReference}.";
status = $"Static DataSet primary-leaf resolution cannot collapse {requestedReference} to one scalar.";
return false;
}

Expand Down
147 changes: 147 additions & 0 deletions tests/ARSAS.Tests/StaticWholeFcdPhaseMeasurementTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;

namespace ARSAS.Tests;

public sealed class StaticWholeFcdPhaseMeasurementTests
{
[Fact]
public void WholeFcd_ThirteenExactPhaseMagnitudes_AndScalarAuxV_AreReportEligible()
{
var model = Model();
var device = new Iec61850MonitorDevice { Name = "IED", LiveDiscoveryModel = model };
var inventory = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device);
Assert.Equal(4, inventory.MandatoryCatalogCount);

var expected = new[]
{
"A.phsA", "A.phsB", "A.phsC", "A.neut", "A.net", "A.res",
"PhV.phsA", "PhV.phsB", "PhV.phsC", "PhV.neut",
"PPV.phsAB", "PPV.phsBC", "PPV.phsCA"
}.Select(p => "IEDLD0/MMXU1." + p + ".cVal.mag.f").ToArray();

var selected = Iec61850StaticDataSetAuthoritySelection.Build(device);
foreach (var reference in expected)
{
var signal = Assert.Single(device.Signals, signal =>
signal.ObjectReference.Equals(reference, StringComparison.OrdinalIgnoreCase) &&
signal.DataSetReference.Equals("IEDLD0/LLN0.Analog", StringComparison.OrdinalIgnoreCase));
Assert.True(signal.CanPublishToRuntime, signal.ObjectReference);
Assert.Equal("FLOAT32", signal.DataType);
Assert.EndsWith(".q", signal.QualityReference, StringComparison.OrdinalIgnoreCase);
Assert.EndsWith(".t", signal.TimestampReference, StringComparison.OrdinalIgnoreCase);
Assert.Contains(selected, chosen => ReferenceEquals(signal, chosen));
}
Assert.Equal(13, selected.Count(signal => expected.Contains(signal.ObjectReference, StringComparer.OrdinalIgnoreCase)));
Assert.Contains(selected, signal =>
signal.ObjectReference.Equals("IEDLD0/MMXU1.AuxV.cVal.mag.f", StringComparison.OrdinalIgnoreCase));

Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device);
foreach (var reference in expected)
Assert.Single(device.Signals, signal =>
signal.ObjectReference.Equals(reference, StringComparison.OrdinalIgnoreCase) &&
signal.DataSetReference.Equals("IEDLD0/LLN0.Analog", StringComparison.OrdinalIgnoreCase));
}

[Fact]
public void WholeFcd_ReorderedTypedAttributes_MissingPhase_NotInvented()
{
var original = Model();
var objectA = original.LogicalDevices[0].LogicalNodes[0].DataObjects[0];
var sparse = new LiveIedDataObjectModel
{
Reference = objectA.Reference, Name = objectA.Name, InferredCdc = objectA.InferredCdc,
Attributes = objectA.Attributes
.Where(attribute => !attribute.AttributePath.StartsWith("phsB.", StringComparison.OrdinalIgnoreCase))
.Reverse()
.ToArray()
};
var rebuilt = Model(sparse);
var resolved = Iec61850StaticPhaseLeafProjection.Resolve(rebuilt, sparse.Reference, "MX");
Assert.Equal(5, resolved.Count);
Assert.DoesNotContain(resolved, leaf => leaf.Phase == "phsB");
Assert.Empty(Iec61850StaticPhaseLeafProjection.Resolve(rebuilt, sparse.Reference, "CO"));
Assert.Empty(Iec61850StaticPhaseLeafProjection.Resolve(rebuilt, sparse.Reference + ".phsA", "MX"));
}

private static LiveIedModelDiscoveryDocument Model(LiveIedDataObjectModel? overrideA = null)
{
const string prefix = "IEDLD0/MMXU1.";
LiveIedDataObjectModel Object(string name, string cdc, params string[] phases) => new()
{
Reference = prefix + name, Name = name, InferredCdc = cdc,
Attributes = phases.SelectMany(phase => new[]
{
Attribute(prefix + name + "." + phase + ".instCVal.mag.f", phase + ".instCVal.mag.f"),
Attribute(prefix + name + "." + phase + ".q", phase + ".q", "Quality"),
Attribute(prefix + name + "." + phase + ".cVal.mag.f", phase + ".cVal.mag.f"),
Attribute(prefix + name + "." + phase + ".t", phase + ".t", "Timestamp")
}).ToArray()
};
var objects = new[]
{
overrideA ?? Object("A", "WYE", "phsA", "phsB", "phsC", "neut", "net", "res"),
Object("PhV", "WYE", "phsA", "phsB", "phsC", "neut"),
Object("PPV", "DEL", "phsAB", "phsBC", "phsCA"),
new LiveIedDataObjectModel
{
Reference = prefix + "AuxV", Name = "AuxV", InferredCdc = "CMV",
Attributes = new[]
{
Attribute(prefix + "AuxV.instCVal.mag.f", "instCVal.mag.f"),
Attribute(prefix + "AuxV.cVal.mag.f", "cVal.mag.f")
}
}
};
return new LiveIedModelDiscoveryDocument
{
Source = "Synthetic SCL", IedName = "IED",
LogicalDevices = new[]
{
new LiveIedLogicalDeviceModel
{
MmsDomain = "IEDLD0", Inst = "LD0",
LogicalNodes = new[]
{
new LiveIedLogicalNodeModel
{
Name = "MMXU1", LnClass = "MMXU", LnInst = "1", DataObjects = objects
}
}
}
},
DataSets = new[]
{
new LiveIedDataSetModel
{
Reference = "IEDLD0/LLN0.Analog", Domain = "IEDLD0",
LogicalNode = "LLN0", Name = "Analog", MemberCount = 4,
Members = objects.Select((obj, index) => new LiveIedDataSetMemberModel
{
Index = index, Reference = obj.Reference, FunctionalConstraint = "MX",
MmsReference = "IEDLD0/MMXU1$MX$" + obj.Name,
Confidence = LiveIedDiscoveryConfidenceLevel.Exact
}).ToArray()
}
},
ReportControls = new[]
{
new LiveIedReportControlModel
{
Reference = "IEDLD0/LLN0.RP.Unbuffer01",
DataSetReference = "IEDLD0/LLN0.Analog", Buffered = false
}
}
};
}

private static LiveIedDataAttributeModel Attribute(string reference, string path, string type = "FLOAT32")
=> new()
{
ObjectReference = reference, AttributePath = path, FunctionalConstraint = "MX",
SclBType = type, MmsType = type == "FLOAT32" ? "floating-point" : "",
TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact,
TypeSource = "SCL.DataTypeTemplates", Source = "SCL.DataTypeTemplates"
};
}