Skip to content
Merged
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
67 changes: 64 additions & 3 deletions src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ public static MmsReportSemanticProjectionContext Create(LiveIedModelDiscoveryDoc
accumulated = $"{accumulated}.{part}";
current = current.GetOrAdd(part, accumulated);
}
// Only a type backed by exact model evidence can veto a
// wire leaf. Partial discovery must remain compatible.
current.ObserveExactType(ExactMmsKind(attribute));
}

if (root.Children.Count == 0)
Expand Down Expand Up @@ -194,6 +197,16 @@ private static bool TryFlatten(
return false;
}

if (schema.HasTypeConflict)
{
reason = $"typed schema conflict for {schema.Reference}: exact model sources disagree";
return false;
}
if (schema.ExactKind is MmsDataKind expected && expected != value.Kind)
{
reason = $"typed MMS leaf mismatch at {schema.Reference}: expected {expected}, received {value.Kind}";
return false;
}
leaves.Add(new ExpandedLeaf(schema.Reference, value));
reason = string.Empty;
return true;
Expand Down Expand Up @@ -253,6 +266,30 @@ private static string NormalizeDataSetReference(string value)
return normalized[..(slash + 1)] + normalized[(slash + 1)..].Replace('$', '.');
}

// Strictly known, model-qualified MMS kinds only. SCL bType alone is not
// live MMS type authority; unknown/partial types retain existing fallback.
private static MmsDataKind? ExactMmsKind(LiveIedDataAttributeModel attribute)
{
if (attribute.TypeConfidence != LiveIedDiscoveryConfidenceLevel.Exact ||
string.IsNullOrWhiteSpace(attribute.MmsType))
return null;

return attribute.MmsType.Trim().ToLowerInvariant().Replace('_', '-') switch
{
"floating-point" or "floatingpoint" => MmsDataKind.FloatingPoint,
"bit-string" or "bitstring" => MmsDataKind.BitString,
"utc-time" or "utctime" => MmsDataKind.UtcTime,
"boolean" => MmsDataKind.Boolean,
"integer" or "signed-integer" => MmsDataKind.Integer,
"unsigned" or "unsigned-integer" => MmsDataKind.Unsigned,
"octet-string" or "octetstring" => MmsDataKind.OctetString,
"visible-string" or "visiblestring" => MmsDataKind.VisibleString,
"mms-string" or "mmsstring" => MmsDataKind.MmsString,
"binary-time" or "binarytime" => MmsDataKind.BinaryTime,
_ => null
};
}

private sealed class SchemaNode
{
private readonly Dictionary<string, SchemaNode> _byName = new(StringComparer.OrdinalIgnoreCase);
Expand All @@ -263,6 +300,18 @@ public SchemaNode(string reference)

public string Reference { get; }
public IReadOnlyList<SchemaNode> Children => _children;
public MmsDataKind? ExactKind { get; private set; }
public bool HasTypeConflict { get; private set; }

public void ObserveExactType(MmsDataKind? kind)
{
if (!kind.HasValue)
return;
if (ExactKind.HasValue && ExactKind != kind)
HasTypeConflict = true;
else
ExactKind = kind;
}

public SchemaNode GetOrAdd(string name, string reference)
{
Expand Down Expand Up @@ -302,6 +351,7 @@ public static MmsReportValueProjection Project(

var baseline = MmsReportValueProjector.Project(frame);
var semanticReplacementPositions = new HashSet<int>();
var rejectedTypedPositions = new HashSet<int>();
var semanticUpdates = new List<MmsReportSignalUpdate>();
var semanticWarnings = new List<string>();

Expand All @@ -323,7 +373,17 @@ public static MmsReportValueProjection Project(
out var resolvedMemberReference,
out var expansionReason))
{
if (baselineWasRaw)
// A positively contradictory exact MMS type is not permission
// for the generic positional fallback to invent process values.
// Preserve the original frame and explicit rejection evidence,
// but exclude this member's candidate value/quality/timestamp.
if (expansionReason.StartsWith("typed MMS leaf mismatch", StringComparison.Ordinal) ||
expansionReason.StartsWith("typed schema conflict", StringComparison.Ordinal))
{
rejectedTypedPositions.Add(valuePosition);
semanticWarnings.Add($"REPORT_SEMANTIC_REJECTED: {reportedMemberReference}; {expansionReason}; no scalar projection from this member.");
}
else if (baselineWasRaw)
{
semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {reportedMemberReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible.");
}
Expand Down Expand Up @@ -369,7 +429,7 @@ public static MmsReportValueProjection Project(
semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {resolvedMemberReference} {expansionReason}; exact static DataSet schema overrode generic structured-value heuristics.");
}

if (semanticReplacementPositions.Count == 0)
if (semanticReplacementPositions.Count == 0 && rejectedTypedPositions.Count == 0)
{
return new MmsReportValueProjection
{
Expand All @@ -383,7 +443,8 @@ public static MmsReportValueProjection Project(
ReceivedAt = frame.ReceivedAt,
Header = frame.Header,
Values = frame.Values
.Where((_, index) => !semanticReplacementPositions.Contains(index))
.Where((_, index) => !semanticReplacementPositions.Contains(index) &&
!rejectedTypedPositions.Contains(index))
.ToArray(),
DecoderMode = frame.DecoderMode,
Message = frame.Message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,82 @@ public void C264_Incomplete_Live_Schema_Still_Projects_Strictly_Typed_Mod_FCD()
x => x.Value.StartsWith("Structure(", StringComparison.OrdinalIgnoreCase));
}

[Fact]
public void ExactTypedFcd_SwappedQualityAndTimeIsRejectedWithoutFalseValuePublication()
{
const string root = "C264MEAS/MMXU1.A";
const string dataSet = "C264MASTER/LLN0.Analog";
var model = BuildMeasurementPairModel(root, dataSet);
var when = new DateTimeOffset(2026, 10, 20, 10, 0, 0, TimeSpan.Zero);
var invalid = BuildFrame(root, dataSet,
MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(12f) }),
MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(13f) }),
MmsDataValue.UtcTime(new Iec61850UtcTime(when, 0)),
MmsDataValue.BitString(3, new byte[] { 0 }));
var result = MmsSemanticReportValueProjector.Project(
invalid, MmsReportSemanticProjectionContext.Create(model));

Assert.Empty(result.Updates);
Assert.Contains(result.Warnings,
warning => warning.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal) &&
warning.Contains("expected BitString, received UtcTime", StringComparison.Ordinal));
// The original received frame remains available to diagnosis.
Assert.Equal(4, invalid.Values[0].Value!.Children.Count);
}

[Fact]
public void ExactTypedFcd_ValidOrderedQualityAndTimestampRetainProcessProjection()
{
const string root = "C264MEAS/MMXU1.A";
const string dataSet = "C264MASTER/LLN0.Analog";
var when = new DateTimeOffset(2026, 10, 20, 10, 0, 0, TimeSpan.Zero);
var valid = BuildFrame(root, dataSet,
MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(12f) }),
MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(13f) }),
MmsDataValue.BitString(3, new byte[] { 0 }),
MmsDataValue.UtcTime(new Iec61850UtcTime(when, 0)));
var result = MmsSemanticReportValueProjector.Project(
valid, MmsReportSemanticProjectionContext.Create(BuildMeasurementPairModel(root, dataSet)));

Assert.Contains(result.Updates, x => x.Reference == root + ".instMag.f" && x.HasValue);
Assert.Contains(result.Updates, x => x.Reference == root + ".mag.f" && x.HasValue);
Assert.DoesNotContain(result.Warnings, x => x.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal));
}

[Fact]
public void ConflictingExactSchemaAndPartialUnknownTypesStayDeterministic()
{
const string root = "C264MEAS/MMXU1.A";
const string dataSet = "C264MASTER/LLN0.Analog";
var contradiction = BuildModel(root, dataSet, "MV", new[]
{
Attribute(root + ".instMag.f", "instMag.f", "FLOAT32", "floating-point"),
Attribute(root + ".instMag.f", "instMag.f", "BOOLEAN", "boolean")
});
var frame = BuildFrame(root, dataSet,
MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(12f) }));
var rejected = MmsSemanticReportValueProjector.Project(
frame, MmsReportSemanticProjectionContext.Create(contradiction));
Assert.Empty(rejected.Updates);
Assert.Contains(rejected.Warnings, x => x.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal));

var unknown = BuildModel(root, dataSet, "MV", new[]
{
new LiveIedDataAttributeModel
{
ObjectReference = root + ".instMag.f",
AttributePath = "instMag.f",
FunctionalConstraint = "MX",
MmsType = "",
TypeConfidence = LiveIedDiscoveryConfidenceLevel.Unknown
}
});
var fallback = MmsSemanticReportValueProjector.Project(
frame, MmsReportSemanticProjectionContext.Create(unknown));
Assert.Contains(fallback.Updates, x => x.Reference == root + ".instMag.f");
Assert.DoesNotContain(fallback.Warnings, x => x.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal));
}

private static LiveIedModelDiscoveryDocument BuildThreePhaseModel(
string objectReference,
string dataSetReference)
Expand Down
Loading