diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs
new file mode 100644
index 00000000..402538f8
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs
@@ -0,0 +1,153 @@
+namespace AR.Iec61850.Mms;
+
+///
+/// Client-compatibility activation wrapper for persistent reporting.
+///
+/// Mature IEC 61850 clients normally reserve a BRCB when ResvTms is exposed,
+/// enable reporting, install/retain the report receiver, and only then request GI.
+/// Some servers also support implicit BRCB reservation through RptEna=true, so an
+/// explicit ResvTms rejection is non-fatal and the baseline activation is still tried.
+///
+/// This wrapper does not create dynamic DataSets and does not schedule cyclic process
+/// reads. It only hardens the RCB control-plane sequence used by report acquisition.
+///
+public sealed partial class MmsClientSession
+{
+ public async Task StartPersistentReportMonitorClientCompatibleAsync(
+ MmsReportSubscriptionPlan plan,
+ bool triggerGeneralInterrogation = true,
+ bool deleteDynamicDataSetOnStop = true,
+ MmsIedModelDirectory? directory = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+
+ var rcb = plan.ReportControl;
+ MmsReportAttributeWriteStep? reservationStep = null;
+ var compatibilityWarnings = new List();
+
+ if (rcb is { Buffered: true } &&
+ rcb.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase) &&
+ !MmsReportSubscriptionPlanner.IsExplicitlyEnabled(rcb) &&
+ !MmsReportSubscriptionPlanner.IsReservedByOtherClient(rcb))
+ {
+ reservationStep = await WriteReportAttributeAsync(
+ rcb,
+ "ResvTms",
+ MmsDataValue.Unsigned(60),
+ cancellationToken).ConfigureAwait(false);
+
+ if (!reservationStep.IsSuccess)
+ {
+ compatibilityWarnings.Add(
+ $"BRCB ResvTms=60 explicit reservation was not accepted ({reservationStep.Message}). Continuing with standards-compatible implicit reservation through RptEna=true.");
+ }
+ }
+
+ // Deliberately suppress GI inside the baseline start. The baseline method registers
+ // the persistent monitor only after RptEna succeeds. Requesting GI below guarantees
+ // that the report receiver/session is already registered when the server emits the
+ // initial InformationReport, while the receive router still preserves any earlier
+ // unconfirmed traffic that arrived during confirmed writes.
+ var attempt = await StartPersistentReportMonitorWithAttemptEvidenceAsync(
+ plan,
+ triggerGeneralInterrogation: false,
+ deleteDynamicDataSetOnStop,
+ directory,
+ cancellationToken).ConfigureAwait(false);
+
+ var start = attempt.StartResult;
+ var writes = new List();
+ if (reservationStep is not null)
+ writes.Add(reservationStep);
+ writes.AddRange(start.WriteSteps);
+
+ var warnings = start.Warnings
+ .Where(warning => !warning.Contains("ResvTms pre-reserve was skipped", StringComparison.OrdinalIgnoreCase))
+ .Concat(compatibilityWarnings)
+ .ToList();
+
+ if (!attempt.IsSuccess || start.Session is null)
+ {
+ var cleanupSteps = attempt.CleanupSteps.ToList();
+ var cleanupWarnings = attempt.CleanupWarnings.ToList();
+ var cleanupAttempted = attempt.CleanupAttempted;
+ var cleanupSucceeded = attempt.CleanupSucceeded;
+
+ if (reservationStep?.IsSuccess == true && rcb is not null)
+ {
+ var release = await TryWriteReportAttributeForCleanupAsync(
+ rcb,
+ "ResvTms",
+ MmsDataValue.Unsigned(0),
+ CancellationToken.None).ConfigureAwait(false);
+ cleanupSteps.Add(release);
+ cleanupAttempted = true;
+ cleanupSucceeded &= release.IsSuccess;
+ if (!release.IsSuccess)
+ cleanupWarnings.Add($"BRCB ResvTms cleanup after failed activation was not accepted: {release.Message}");
+ }
+
+ return new MmsPersistentReportMonitorAttemptResult
+ {
+ StartResult = CopyStartResult(start, writes, warnings),
+ DynamicAttemptState = attempt.DynamicAttemptState,
+ FailureReason = attempt.FailureReason,
+ CleanupAttempted = cleanupAttempted,
+ CleanupSucceeded = cleanupSucceeded,
+ CleanupSteps = cleanupSteps,
+ CleanupWarnings = cleanupWarnings
+ };
+ }
+
+ if (reservationStep?.IsSuccess == true)
+ start.Session.ReservationTouched = true;
+
+ if (triggerGeneralInterrogation)
+ {
+ var gi = await WriteReportAttributeAsync(
+ start.Session.ReportControl,
+ "GI",
+ MmsDataValue.Boolean(true),
+ cancellationToken).ConfigureAwait(false);
+ writes.Add(gi);
+ if (!gi.IsSuccess)
+ warnings.Add("GI=true write failed or is not supported by this RCB. Waiting for spontaneous/integrity reports only.");
+ }
+
+ var compatibilityMessage = reservationStep?.IsSuccess == true
+ ? "BRCB explicitly reserved with ResvTms=60 before RptEna; GI was requested only after the persistent receiver was registered."
+ : "GI was requested only after the persistent receiver was registered.";
+
+ return new MmsPersistentReportMonitorAttemptResult
+ {
+ StartResult = CopyStartResult(
+ start,
+ writes,
+ warnings,
+ $"{start.Message} {compatibilityMessage}"),
+ DynamicAttemptState = attempt.DynamicAttemptState,
+ FailureReason = attempt.FailureReason,
+ CleanupAttempted = attempt.CleanupAttempted,
+ CleanupSucceeded = attempt.CleanupSucceeded,
+ CleanupSteps = attempt.CleanupSteps,
+ CleanupWarnings = attempt.CleanupWarnings
+ };
+ }
+
+ private static MmsPersistentReportMonitorStartResult CopyStartResult(
+ MmsPersistentReportMonitorStartResult source,
+ IReadOnlyList writes,
+ IReadOnlyList warnings,
+ string? message = null)
+ => new()
+ {
+ IsSuccess = source.IsSuccess,
+ Message = message ?? source.Message,
+ Session = source.Session,
+ WriteSteps = writes.ToArray(),
+ Warnings = warnings.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
+ RcbSnapshots = source.RcbSnapshots,
+ DataSetSnapshots = source.DataSetSnapshots
+ };
+}
diff --git a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs
index e8e40984..07f7aedf 100644
--- a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs
+++ b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs
@@ -85,8 +85,22 @@ internal bool TryExpand(
MmsReportValue reportValue,
out IReadOnlyList expanded,
out string reason)
+ => TryExpand(
+ dataSetReference,
+ reportValue,
+ out expanded,
+ out _,
+ out reason);
+
+ internal bool TryExpand(
+ string dataSetReference,
+ MmsReportValue reportValue,
+ out IReadOnlyList expanded,
+ out string resolvedMemberReference,
+ out string reason)
{
expanded = Array.Empty();
+ resolvedMemberReference = string.Empty;
reason = string.Empty;
if (reportValue.Value is null || reportValue.Value.Kind is not (MmsDataKind.Structure or MmsDataKind.Array))
@@ -127,6 +141,7 @@ internal bool TryExpand(
ReasonForInclusion = reportValue.ReasonForInclusion
})
.ToArray();
+ resolvedMemberReference = schema.MemberReference;
reason = $"expanded {schema.MemberReference} into {expanded.Count} scalar semantic descendant(s)";
return true;
}
@@ -281,8 +296,9 @@ private sealed record ExpandedLeaf(string Reference, MmsDataValue Value);
}
///
-/// Backward-compatible overlay for structures that the established report projector
-/// intentionally leaves raw. Existing known CDC projections remain untouched.
+/// Model-backed overlay for structured report members. Exact static DataSet/SCL schema is
+/// authoritative when it can expand the structure safely; the established generic projector
+/// remains the fail-closed fallback when no unique semantic schema matches.
///
public static class MmsSemanticReportValueProjector
{
@@ -294,23 +310,36 @@ public static MmsReportValueProjection Project(
ArgumentNullException.ThrowIfNull(context);
var baseline = MmsReportValueProjector.Project(frame);
- var replacementParents = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var semanticReplacementPositions = new HashSet();
var semanticUpdates = new List();
var semanticWarnings = new List();
- foreach (var reportValue in frame.Values)
+ for (var valuePosition = 0; valuePosition < frame.Values.Count; valuePosition++)
{
+ var reportValue = frame.Values[valuePosition];
if (reportValue.Value is null || reportValue.Value.Kind is not (MmsDataKind.Structure or MmsDataKind.Array))
continue;
- var parentReference = reportValue.MemberReference;
- var rawPrefix = $"REPORT_RAW_STRUCT: {parentReference} ";
- if (!baseline.Warnings.Any(warning => warning.StartsWith(rawPrefix, StringComparison.OrdinalIgnoreCase)))
- continue;
-
- if (!context.TryExpand(frame.Header.DataSetReference, reportValue, out var expanded, out var expansionReason))
+ var reportedMemberReference = reportValue.MemberReference;
+ var rawPrefix = $"REPORT_RAW_STRUCT: {reportedMemberReference} ";
+ var baselineWasRaw = baseline.Warnings.Any(warning =>
+ warning.StartsWith(rawPrefix, StringComparison.OrdinalIgnoreCase));
+
+ // Static DataSet identity + exact SCL/live schema is stronger evidence than a
+ // generic shape heuristic. Try semantic expansion first for every structured
+ // member, including structures the baseline recognizes as instMag/mag pairs.
+ // If the exact schema cannot prove the mapping, preserve baseline behavior.
+ if (!context.TryExpand(
+ frame.Header.DataSetReference,
+ reportValue,
+ out var expanded,
+ out var resolvedMemberReference,
+ out var expansionReason))
{
- semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible.");
+ if (baselineWasRaw)
+ {
+ semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {reportedMemberReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible.");
+ }
continue;
}
@@ -325,11 +354,20 @@ public static MmsReportValueProjection Project(
var projected = MmsReportValueProjector.Project(synthetic);
if (projected.Updates.Count == 0 || projected.Warnings.Any(warning => warning.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase)))
{
- semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} expansion was not publishable; baseline raw projection was preserved.");
+ if (baselineWasRaw)
+ {
+ semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {reportedMemberReference} expansion was not publishable; baseline raw projection was preserved.");
+ }
continue;
}
- replacementParents.Add(Normalize(parentReference));
+ // Replace the generic projection by report-value position, not by a guessed parent
+ // reference. Some valid InformationReports omit member identity and are resolved
+ // only by the exact static DataSet + member index. In that case the generic
+ // projector can emit unrooted heuristic leaves, so descendant-name filtering is
+ // neither sufficient nor safe. A successful semantic projection owns this report
+ // value completely; generic projection remains available only for other values.
+ semanticReplacementPositions.Add(valuePosition);
semanticUpdates.AddRange(projected.Updates.Select(update => new MmsReportSignalUpdate
{
Reference = update.Reference,
@@ -347,10 +385,10 @@ public static MmsReportValueProjection Project(
IsProjectedChild = true,
ProjectionStatus = "semantic-structured-leaf"
}));
- semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {parentReference} {expansionReason}; static DataSet membership identity was preserved.");
+ semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {resolvedMemberReference} {expansionReason}; exact static DataSet schema overrode generic structured-value heuristics.");
}
- if (replacementParents.Count == 0)
+ if (semanticReplacementPositions.Count == 0)
{
return new MmsReportValueProjection
{
@@ -359,16 +397,34 @@ public static MmsReportValueProjection Project(
};
}
- var updates = baseline.Updates
- .Where(update => !replacementParents.Contains(Normalize(update.Reference)))
+ // Re-project only report values that were not replaced semantically. This preserves
+ // normal generic scalar/companion behavior for unrelated members while guaranteeing
+ // that no heuristic output from a successfully resolved structured member survives,
+ // even when the wire report omitted MemberReference entirely.
+ var retainedFrame = new MmsReportFrame
+ {
+ ReceivedAt = frame.ReceivedAt,
+ Header = frame.Header,
+ Values = frame.Values
+ .Where((_, index) => !semanticReplacementPositions.Contains(index))
+ .ToArray(),
+ DecoderMode = frame.DecoderMode,
+ Message = frame.Message
+ };
+ var retainedBaseline = MmsReportValueProjector.Project(retainedFrame);
+
+ var updates = retainedBaseline.Updates
.Concat(semanticUpdates)
.GroupBy(update => Normalize(update.Reference) + "|" + update.FunctionalConstraint, StringComparer.OrdinalIgnoreCase)
.Select(group => group.Last())
- .OrderBy(update => update.Reference, StringComparer.OrdinalIgnoreCase)
+ // q/t companions are intentionally delivered before scalar values. Consumers can
+ // therefore attach report-native quality/timestamp to semantic value leaves without
+ // inventing defaults or issuing a separate MMS read.
+ .OrderBy(update => CompanionPriority(update.Reference))
+ .ThenBy(update => update.Reference, StringComparer.OrdinalIgnoreCase)
.ToArray();
- var warnings = baseline.Warnings
- .Where(warning => !replacementParents.Any(parent => warning.StartsWith($"REPORT_RAW_STRUCT: {parent} ", StringComparison.OrdinalIgnoreCase)))
+ var warnings = retainedBaseline.Warnings
.Concat(semanticWarnings)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
@@ -380,6 +436,15 @@ public static MmsReportValueProjection Project(
};
}
+ private static int CompanionPriority(string reference)
+ {
+ var normalized = Normalize(reference);
+ return normalized.EndsWith(".q", StringComparison.OrdinalIgnoreCase) ||
+ normalized.EndsWith(".t", StringComparison.OrdinalIgnoreCase)
+ ? 0
+ : 1;
+ }
+
private static string Normalize(string value)
=> string.IsNullOrWhiteSpace(value)
? string.Empty
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs
index 13b93c02..c4e6a34f 100644
--- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs
+++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs
@@ -16,10 +16,6 @@ public void Whole_MultiPhase_Member_Fans_Out_All_Scalar_Leaves_Without_Selecting
Assert.Equal(LiveIedDataSetMemberResolutionStatus.Ambiguous, binding.ResolutionStatus);
Assert.Null(binding.PrimaryValue);
- // Physical bench evidence included phsB.cVal.mag.f = 40.04636.
- // Keep that exact floating-point input here so the report fan-out regression stays tied
- // to the field failure that exposed REPORT_RAW_STRUCT. The public display renderer has
- // an established three-decimal contract, which is asserted below independently of routing.
var frame = BuildFrame(
objectReference,
dataSetReference,
@@ -62,10 +58,6 @@ public void Sparse_Report_Value_Index_Drift_Still_Uses_Exact_Static_Member_Refer
const string dataSetReference = "AA1E1F02R2Application/LLN0.Analog";
var model = BuildThreePhaseModel(objectReference, dataSetReference);
- // The static DataSet member index in the model is 0. A sparse InformationReport may
- // expose a decoder-side value position that differs from that static member index.
- // Exact IEC member identity must remain sufficient and must not be rejected solely
- // because the transient report value index is different.
var frame = BuildFrame(
objectReference,
dataSetReference,
@@ -90,6 +82,92 @@ public void Sparse_Report_Value_Index_Drift_Still_Uses_Exact_Static_Member_Refer
warning.StartsWith("REPORT_SEMANTIC_FALLBACK:", StringComparison.OrdinalIgnoreCase));
}
+ [Fact]
+ public void Exact_Static_Schema_Overrides_Generic_InstMagMag_Heuristic()
+ {
+ const string objectReference = "AA1E1F06R4VI3p1_OperationalValues/PPRE_MMXU1.TotPF";
+ const string dataSetReference = "AA1E1F06R4Application/LLN0.Analog";
+ var timestamp = new DateTimeOffset(2026, 9, 4, 13, 20, 51, TimeSpan.Zero);
+ var model = BuildMeasurementPairModel(objectReference, dataSetReference);
+ var frame = BuildFrame(
+ objectReference,
+ dataSetReference,
+ MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.125) }),
+ MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.25) }),
+ MmsDataValue.BitString(3, new byte[] { 0x00, 0x00 }),
+ MmsDataValue.UtcTime(new Iec61850UtcTime(timestamp, 0)));
+
+ // The generic projector can correctly recognize the wire shape as an MX pair.
+ // Static DataSet semantic authority must still win so exact schema leaf identity,
+ // including the final .f and the report-native q/t, reaches the consumer.
+ var baseline = MmsReportValueProjector.Project(frame);
+ Assert.Contains(baseline.Updates, update =>
+ update.ProjectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase));
+
+ var projection = MmsSemanticReportValueProjector.Project(
+ frame,
+ MmsReportSemanticProjectionContext.Create(model));
+
+ var instant = Assert.Single(projection.Updates, update =>
+ update.Reference.Equals(objectReference + ".instMag.f", StringComparison.OrdinalIgnoreCase));
+ var magnitude = Assert.Single(projection.Updates, update =>
+ update.Reference.Equals(objectReference + ".mag.f", StringComparison.OrdinalIgnoreCase));
+ Assert.Equal("semantic-structured-leaf", instant.ProjectionStatus);
+ Assert.Equal("semantic-structured-leaf", magnitude.ProjectionStatus);
+ Assert.Equal("good", magnitude.Quality);
+ Assert.True(magnitude.HasQuality);
+ Assert.True(magnitude.HasTimestamp);
+ Assert.Contains("2026-09-04", magnitude.Timestamp, StringComparison.Ordinal);
+ Assert.DoesNotContain(projection.Updates, update =>
+ update.Reference.Equals(objectReference + ".instMag", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(projection.Updates, update =>
+ update.Reference.Equals(objectReference + ".mag", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(projection.Warnings, warning =>
+ warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Missing_MemberReference_Uses_Static_DataSet_Index_Without_Leaking_Generic_Heuristic()
+ {
+ const string objectReference = "AA1E1F06R4VI3p1_OperationalValues/PPRE_MMXU1.TotPF";
+ const string dataSetReference = "AA1E1F06R4Application/LLN0.Analog";
+ var timestamp = new DateTimeOffset(2026, 9, 4, 13, 20, 51, TimeSpan.Zero);
+ var model = BuildMeasurementPairModel(objectReference, dataSetReference);
+ var frame = BuildFrame(
+ string.Empty,
+ dataSetReference,
+ reportValueIndex: 0,
+ MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.125) }),
+ MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.25) }),
+ MmsDataValue.BitString(3, new byte[] { 0x00, 0x00 }),
+ MmsDataValue.UtcTime(new Iec61850UtcTime(timestamp, 0)));
+
+ // With no MemberReference, the generic projector has wire-shape evidence but no
+ // authoritative engineering parent. It can therefore emit unrooted MX-pair leaves.
+ var baseline = MmsReportValueProjector.Project(frame);
+ Assert.Contains(baseline.Updates, update =>
+ update.ProjectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase));
+
+ var projection = MmsSemanticReportValueProjector.Project(
+ frame,
+ MmsReportSemanticProjectionContext.Create(model));
+
+ var instant = Assert.Single(projection.Updates, update =>
+ update.Reference.Equals(objectReference + ".instMag.f", StringComparison.OrdinalIgnoreCase));
+ var magnitude = Assert.Single(projection.Updates, update =>
+ update.Reference.Equals(objectReference + ".mag.f", StringComparison.OrdinalIgnoreCase));
+ Assert.Equal("semantic-structured-leaf", instant.ProjectionStatus);
+ Assert.Equal("semantic-structured-leaf", magnitude.ProjectionStatus);
+ Assert.DoesNotContain(projection.Updates, update =>
+ update.ProjectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(projection.Updates, update =>
+ update.Reference.Equals("instMag.f", StringComparison.OrdinalIgnoreCase)
+ || update.Reference.Equals("mag.f", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(projection.Warnings, warning =>
+ warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase)
+ && warning.Contains(objectReference, StringComparison.OrdinalIgnoreCase));
+ }
+
[Fact]
public void Schema_Mismatch_Fails_Closed_And_Preserves_Raw_Projection()
{
@@ -128,6 +206,29 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel(
Attribute(objectReference + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"),
Attribute(objectReference + ".phsC.cVal.mag.f", "phsC.cVal.mag.f")
};
+ return BuildModel(objectReference, dataSetReference, "WYE", attributes);
+ }
+
+ private static LiveIedModelDiscoveryDocument BuildMeasurementPairModel(
+ string objectReference,
+ string dataSetReference)
+ {
+ var attributes = new[]
+ {
+ Attribute(objectReference + ".instMag.f", "instMag.f"),
+ Attribute(objectReference + ".mag.f", "mag.f"),
+ Attribute(objectReference + ".q", "q", "Quality", "bit-string"),
+ Attribute(objectReference + ".t", "t", "Timestamp", "utc-time")
+ };
+ return BuildModel(objectReference, dataSetReference, "MV", attributes);
+ }
+
+ private static LiveIedModelDiscoveryDocument BuildModel(
+ string objectReference,
+ string dataSetReference,
+ string cdc,
+ IReadOnlyList attributes)
+ {
var slash = objectReference.IndexOf('/');
var domain = objectReference[..slash];
var logicalPath = objectReference[(slash + 1)..];
@@ -138,7 +239,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel(
return new LiveIedModelDiscoveryDocument
{
Source = "SclWorkspace",
- IedName = "AA1E1F02R2",
+ IedName = "AA1E1F06R4",
LogicalDevices = new[]
{
new LiveIedLogicalDeviceModel
@@ -149,7 +250,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel(
new LiveIedLogicalNodeModel
{
Name = logicalNode,
- LnClass = "MHAI",
+ LnClass = logicalNode.Contains("MHAI", StringComparison.OrdinalIgnoreCase) ? "MHAI" : "MMXU",
LnInst = "1",
DataObjects = new[]
{
@@ -157,7 +258,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel(
{
Reference = objectReference,
Name = dataObjectName,
- InferredCdc = "WYE",
+ InferredCdc = cdc,
Attributes = attributes
}
}
@@ -170,7 +271,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel(
new LiveIedDataSetModel
{
Reference = dataSetReference,
- Domain = "AA1E1F02R2Application",
+ Domain = dataSetReference.Split('/')[0],
LogicalNode = "LLN0",
Name = "Analog",
MemberCount = 1,
@@ -181,7 +282,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel(
Index = 0,
Reference = objectReference,
FunctionalConstraint = "MX",
- MmsReference = "AA1E1F02R2VI3p1_THDHarmonics/I_MHAI1$MX$ThdA",
+ MmsReference = objectReference.Replace('.', '$'),
Confidence = LiveIedDiscoveryConfidenceLevel.Exact
}
}
@@ -190,15 +291,19 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel(
};
}
- private static LiveIedDataAttributeModel Attribute(string reference, string path)
+ private static LiveIedDataAttributeModel Attribute(
+ string reference,
+ string path,
+ string sclBType = "FLOAT32",
+ string mmsType = "floating-point")
=> new()
{
ObjectReference = reference,
AttributePath = path,
FunctionalConstraint = "MX",
MmsReference = reference.Replace('.', '$'),
- SclBType = "FLOAT32",
- MmsType = "floating-point",
+ SclBType = sclBType,
+ MmsType = mmsType,
Source = "SCL.DataTypeTemplates",
TypeSource = "SCL.DataTypeTemplates",
TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact