diff --git a/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs b/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs
index f580a835..f84e3b05 100644
--- a/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs
+++ b/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs
@@ -25,6 +25,62 @@ public static class CdcInferenceEngine
public static bool IsKnownCdc(string cdc)
=> !string.IsNullOrWhiteSpace(cdc) && KnownCdcValues.Contains(cdc.Trim());
+ ///
+ /// Refines only low/medium-confidence generic status inference when the MMS
+ /// TypeSpecification provides an exact primitive stVal type. Exact wire type is
+ /// stronger evidence than the generic ST/stVal/q/t shape; object names are never
+ /// used as a substitute for missing type evidence.
+ ///
+ public static CdcInferenceResult RefineWithExactPrimaryType(
+ CdcInferenceResult inferred,
+ IReadOnlyCollection attributes)
+ {
+ ArgumentNullException.ThrowIfNull(inferred);
+ ArgumentNullException.ThrowIfNull(attributes);
+
+ if (!string.Equals(inferred.Cdc, "SPS", StringComparison.OrdinalIgnoreCase) ||
+ inferred.Confidence >= 0.85)
+ {
+ return inferred;
+ }
+
+ var exactStatusValues = attributes
+ .Where(attribute =>
+ attribute.TypeConfidence == LiveIedDiscoveryConfidenceLevel.Exact &&
+ string.Equals(
+ (attribute.AttributePath ?? string.Empty).Trim().Split('.').LastOrDefault(),
+ "stVal",
+ StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+
+ if (exactStatusValues.Length != 1)
+ return inferred;
+
+ var primary = exactStatusValues[0];
+ var bType = (primary.SclBType ?? string.Empty).Trim().ToUpperInvariant();
+ var mmsType = (primary.MmsType ?? string.Empty).Trim().ToUpperInvariant();
+
+ if (IsExactSignedIntegerType(bType, mmsType))
+ {
+ return Result(
+ "INS",
+ 0.96,
+ inferred.Evidence.Append(
+ $"exact MMS TypeSpecification stVal={FirstNonEmpty(primary.SclBType, primary.MmsType)} overrides generic SPS shape"));
+ }
+
+ if (IsExactBooleanType(bType, mmsType))
+ {
+ return Result(
+ "SPS",
+ Math.Max(inferred.Confidence, 0.96),
+ inferred.Evidence.Append(
+ $"exact MMS TypeSpecification stVal={FirstNonEmpty(primary.SclBType, primary.MmsType)} confirms SPS"));
+ }
+
+ return inferred;
+ }
+
public static CdcInferenceResult Infer(
string logicalNodeClass,
string dataObjectName,
@@ -133,6 +189,18 @@ public static CdcInferenceResult Infer(
return Result(string.Empty, 0.0, evidence.Append("CDC cannot be inferred from current online discovery only"));
}
+ private static bool IsExactSignedIntegerType(string bType, string mmsType)
+ => bType is "INT8" or "INT16" or "INT24" or "INT32" or "INT64" or "INTEGER" ||
+ mmsType.Contains("INTEGER", StringComparison.Ordinal) ||
+ mmsType.Contains("SIGNED", StringComparison.Ordinal);
+
+ private static bool IsExactBooleanType(string bType, string mmsType)
+ => bType == "BOOLEAN" ||
+ mmsType.Contains("BOOLEAN", StringComparison.Ordinal);
+
+ private static string FirstNonEmpty(params string?[] values)
+ => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
+
private static bool IsProtectionOperation(string lnClass, string dataObjectName)
=> string.Equals(dataObjectName, "Op", StringComparison.OrdinalIgnoreCase) &&
((lnClass.Length > 0 && lnClass[0] == 'P') || string.Equals(lnClass, "RREC", StringComparison.OrdinalIgnoreCase));
diff --git a/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs b/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs
index 904cd251..4e92e272 100644
--- a/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs
+++ b/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs
@@ -51,13 +51,13 @@ public static class Iec61850StandardModelRegistry
[Key("MMXU", "PF")] = Def("MMXU", "PF", "WYE", 0.84, "power factor"),
// Harmonic measurements use phase-group CDCs. Physical AA1E1F06R4
- // plus IEDScout SCL prove that MHAI ThdA / ThdPhV are WYE objects whose
+ // plus field-reference SCL evidence prove that MHAI ThdA / ThdPhV are WYE objects whose
// phase children are CMV SDOs, not ACD indication structures.
[Key("MHAI", "ThdA")] = Def("MHAI", "ThdA", "WYE", 0.98, "three-phase current THD"),
[Key("MHAI", "ThdPhV")] = Def("MHAI", "ThdPhV", "WYE", 0.98, "three-phase voltage THD"),
// Instrument-transformer semantics are standardized and were also observed in
- // the AA1E1F06R4 IEDScout engineering model. Exact registry entries prevent
+ // the field-reference engineering model. Exact registry entries prevent
// live discovery from dropping these configuration DOs when attribute-name
// heuristics alone cannot infer their CDC safely.
[Key("TCTR", "Mod")] = Def("TCTR", "Mod", "ENC", 0.98, "mode control"),
diff --git a/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs b/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs
index 21025d14..744cbe56 100644
--- a/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs
+++ b/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs
@@ -215,7 +215,8 @@ private static IEnumerable BuildDataObjects(
.ToArray();
var attrPaths = attributes.Select(x => x.AttributePath).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray();
var fcs = group.Select(x => x.FunctionalConstraint).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray();
- var cdc = CdcInferenceEngine.Infer(parsedLn.SclLnClass, group.Key, attrPaths, fcs);
+ var inferredCdc = CdcInferenceEngine.Infer(parsedLn.SclLnClass, group.Key, attrPaths, fcs);
+ var cdc = CdcInferenceEngine.RefineWithExactPrimaryType(inferredCdc, attributes);
var reference = $"{ln.Domain}/{ln.Name}.{group.Key}";
yield return new LiveIedDataObjectModel
{
diff --git a/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs
index ae583318..0be1e0b3 100644
--- a/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs
+++ b/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs
@@ -877,7 +877,7 @@ private static XElement BuildDaElement(TypeTreeNode node, string ownerTypeId, Li
new XAttribute("type", enumTypeId));
}
- var bType = NormalizeBType(node.BType, node.Name, node.Path, cdc);
+ var bType = NormalizeBType(node.BType, node.Name, node.Path, cdc, node.TypeConfidence);
return new XElement(
Scl + (isRootDa ? "DA" : "BDA"),
new XAttribute("name", SafeXmlName(node.Name)),
@@ -1198,7 +1198,12 @@ private static bool IsControlBlockDataObject(LiveIedDataObjectModel dataObject)
string.Equals(attribute.FunctionalConstraint, "US", StringComparison.OrdinalIgnoreCase) ||
string.Equals(attribute.FunctionalConstraint, "LG", StringComparison.OrdinalIgnoreCase));
- private static string NormalizeBType(string bType, string name, string path, string cdc)
+ private static string NormalizeBType(
+ string bType,
+ string name,
+ string path,
+ string cdc,
+ LiveIedDiscoveryConfidenceLevel typeConfidence)
{
if (string.Equals(name, "q", StringComparison.OrdinalIgnoreCase))
return "Quality";
@@ -1207,15 +1212,32 @@ private static string NormalizeBType(string bType, string name, string path, str
if (string.Equals(name, "T", StringComparison.Ordinal))
return "Timestamp";
+ var normalizedBType = (bType ?? string.Empty).Trim();
+ var exactPrimitive =
+ typeConfidence == LiveIedDiscoveryConfidenceLevel.Exact &&
+ !string.IsNullOrWhiteSpace(normalizedBType) &&
+ !normalizedBType.Equals("Unknown", StringComparison.OrdinalIgnoreCase) &&
+ !normalizedBType.Equals("Struct", StringComparison.OrdinalIgnoreCase);
+
+ // Exact MMS TypeSpecification is protocol evidence. Never overwrite it merely
+ // because a generic CDC shape inferred a different primitive type.
+ if (exactPrimitive)
+ return normalizedBType;
+
var normalizedCdc = cdc.Trim().ToUpperInvariant();
var normalizedPath = path.Trim();
var inferred = InferBTypeFromCdc(normalizedCdc, name, normalizedPath);
if (!string.IsNullOrWhiteSpace(inferred))
return inferred;
- if (string.IsNullOrWhiteSpace(bType) || string.Equals(bType, "Unknown", StringComparison.OrdinalIgnoreCase) || string.Equals(bType, "Struct", StringComparison.OrdinalIgnoreCase))
+ if (string.IsNullOrWhiteSpace(normalizedBType) ||
+ normalizedBType.Equals("Unknown", StringComparison.OrdinalIgnoreCase) ||
+ normalizedBType.Equals("Struct", StringComparison.OrdinalIgnoreCase))
+ {
return "VisString255";
- return bType.Trim();
+ }
+
+ return normalizedBType;
}
private static string InferBTypeFromCdc(string cdc, string name, string path)
@@ -1428,6 +1450,8 @@ private TypeTreeNode(string name, string path)
public string Path { get; }
public string Fc { get; private set; } = string.Empty;
public string BType { get; private set; } = string.Empty;
+ public LiveIedDiscoveryConfidenceLevel TypeConfidence { get; private set; } =
+ LiveIedDiscoveryConfidenceLevel.Unknown;
public IReadOnlyList Children => _orderedChildren;
public string EffectiveFunctionalConstraint
@@ -1491,6 +1515,7 @@ private void Add(LiveIedDataAttributeModel attribute)
current.Fc = attribute.FunctionalConstraint;
current.BType = attribute.SclBType;
+ current.TypeConfidence = attribute.TypeConfidence;
}
}
}
diff --git a/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs
index 2cb0de25..25bdf4b8 100644
--- a/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs
+++ b/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs
@@ -102,6 +102,109 @@ public void Exporter_Preserves_Mms_TypeSpecification_Declaration_Order_For_Statu
.ToArray());
}
+ [Fact]
+ public void Exact_Integer_StVal_Refines_Generic_Status_To_Ins_And_Preserves_Int32()
+ {
+ var discovery = new MmsDiscoveryResult
+ {
+ IedDirectory = new MmsIedModelDirectory(
+ [
+ new MmsFcResolvedPoint
+ {
+ Domain = "IEDADD",
+ LogicalNode = "GGIO1",
+ FunctionalConstraint = "ST",
+ DataObjectPath = "UserStatus.stVal",
+ MmsItemName = "GGIO1$ST$UserStatus$stVal"
+ },
+ new MmsFcResolvedPoint
+ {
+ Domain = "IEDADD",
+ LogicalNode = "GGIO1",
+ FunctionalConstraint = "ST",
+ DataObjectPath = "UserStatus.q",
+ MmsItemName = "GGIO1$ST$UserStatus$q"
+ },
+ new MmsFcResolvedPoint
+ {
+ Domain = "IEDADD",
+ LogicalNode = "GGIO1",
+ FunctionalConstraint = "ST",
+ DataObjectPath = "UserStatus.t",
+ MmsItemName = "GGIO1$ST$UserStatus$t"
+ }
+ ])
+ };
+ var typeResult = new MmsVariableAccessAttributesResult
+ {
+ IsSuccess = true,
+ Reference = new MmsObjectReference("IEDADD", "GGIO1", string.Empty),
+ TypeSpecification = new MmsTypeSpecificationNode
+ {
+ MmsType = "structure",
+ SclBType = "Struct",
+ Children =
+ [
+ new MmsTypeSpecificationNode
+ {
+ Name = "ST",
+ MmsType = "structure",
+ SclBType = "Struct",
+ Children =
+ [
+ new MmsTypeSpecificationNode
+ {
+ Name = "UserStatus",
+ MmsType = "structure",
+ SclBType = "Struct",
+ Children =
+ [
+ new MmsTypeSpecificationNode { Name = "stVal", MmsType = "integer", SclBType = "INT32" },
+ new MmsTypeSpecificationNode { Name = "q", MmsType = "bit-string", SclBType = "Quality" },
+ new MmsTypeSpecificationNode { Name = "t", MmsType = "utc-time", SclBType = "Timestamp" }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ };
+
+ var model = LiveIedModelDiscoveryBuilder.Build(
+ discovery,
+ new LiveIedModelDiscoveryBuildOptions { IedName = "IED" },
+ variableTypeAttributes: [typeResult]);
+
+ var dataObject = Assert.Single(
+ model.LogicalDevices
+ .SelectMany(device => device.LogicalNodes)
+ .SelectMany(node => node.DataObjects),
+ item => item.Name == "UserStatus");
+ Assert.Equal("INS", dataObject.InferredCdc);
+ Assert.Equal(LiveIedDiscoveryConfidenceLevel.Exact,
+ Assert.Single(dataObject.Attributes, attribute => attribute.AttributePath == "stVal").TypeConfidence);
+
+ var document = LiveIedSclExporter.BuildDocument(
+ model,
+ new LiveIedSclExportOptions { Profile = "full-model" });
+ var ns = document.Root!.Name.Namespace;
+ var lNodeType = Assert.Single(
+ document.Descendants(ns + "LNodeType"),
+ element => (string?)element.Attribute("lnClass") == "GGIO");
+ var doRef = Assert.Single(
+ lNodeType.Elements(ns + "DO"),
+ element => (string?)element.Attribute("name") == "UserStatus");
+ var doType = Assert.Single(
+ document.Descendants(ns + "DOType"),
+ element => (string?)element.Attribute("id") == (string?)doRef.Attribute("type"));
+
+ Assert.Equal("INS", (string?)doType.Attribute("cdc"));
+ var stVal = Assert.Single(
+ doType.Elements(ns + "DA"),
+ element => (string?)element.Attribute("name") == "stVal");
+ Assert.Equal("INT32", (string?)stVal.Attribute("bType"));
+ }
+
[Fact]
public void Exporter_Preserves_CaseDistinct_Ltrk_Cts_Members_From_Root_TypeSpecification()
{