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
8 changes: 8 additions & 0 deletions Models/NativeReportMonitorModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ public sealed class NativeReportFrameMetadata
public string ReportId { get; init; } = string.Empty;
public string DataSetReference { get; init; } = string.Empty;
public ulong? SequenceNumber { get; init; }
// Wire OptFlds decoded by ARIEC MmsReportFrameMapper; null means provenance
// unavailable, false means the corresponding bit was explicitly *not* set.
// The bounded raw hex represents only the option-bit mask, never process data.
public string OptFldsRawHex { get; init; } = string.Empty;
public bool? OptFldsSequenceNumber { get; init; }
public bool? OptFldsEntryId { get; init; }
public bool? OptFldsBufferOverflow { get; init; }
public bool? OptFldsConfRev { get; init; }
public ulong? SubSequenceNumber { get; init; }
public bool? MoreSegmentsFollow { get; init; }
public bool? BufferOverflow { get; init; }
Expand Down
14 changes: 11 additions & 3 deletions Services/DiagnosticReportBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,11 @@ internal static void AppendReportContinuity(
builder.AppendLine($"Report continuity : {verdict} • frames={snapshot.Frames}, " +
$"sequenced={snapshot.Sequenced}, processUpdates={snapshot.ProcessUpdatesSeen}, " +
$"streams={snapshot.StreamCount}, findings={snapshot.Findings}, " +
$"BufOvfl={snapshot.Overflows}, untracked={snapshot.UntrackedStreamCount}");
builder.AppendLine(" Qualification : association-local decoded metadata only; no alerts " +
"does not prove SOE/event continuity, GI causality or reconnect/replay completeness.");
$"BufOvfl={snapshot.Overflows}, untracked={snapshot.UntrackedStreamCount}, " +
$"OptFldsDecoded={snapshot.OptFldsDecoded}, OptFldsUnknown={snapshot.OptFldsUnknown}");
builder.AppendLine(" Qualification : ARIEC decoder-sourced wire OptFlds evidence; " +
"OptFldsUnknown is not proof of absent wire fields. No alerts does not prove SOE/event continuity, " +
"GI causality or reconnect/replay completeness.");

foreach (var (stream, index) in snapshot.Streams.Select((stream, index) => (stream, index)))
{
Expand All @@ -309,6 +311,12 @@ static string Safe(string value)
$"BufOvfl={stream.Overflow}, ConfRevChanges={stream.ConfRevChanges}, " +
$"EntryIDPresent={stream.EntryIdPresent}, firstSqNum={stream.FirstSqNum?.ToString() ?? "-"}, " +
$"lastSqNum={stream.LastSqNum?.ToString() ?? "-"}");
builder.AppendLine($" OptFlds : decoded={stream.OptFldsDecoded}/{stream.Frames}, " +
$"unknown={stream.OptFldsUnknown}, lastMask={Safe(stream.LastOptFldsHex)}, " +
$"SqNum requested={stream.SqNumAdvertised}, omitted={stream.SqNumOmitted}, " +
$"requestedButUndecoded={stream.SqNumAdvertisedMissing}; " +
$"EntryID requested={stream.EntryIdAdvertised}, omitted={stream.EntryIdOmitted}, " +
$"emptyOrUnprojected={stream.EntryIdEmptyOrUnprojected}");
}
if (snapshot.StreamCount > snapshot.Streams.Count)
builder.AppendLine($" stream list : TRUNCATED • shown={snapshot.Streams.Count}, total={snapshot.StreamCount}");
Expand Down
98 changes: 97 additions & 1 deletion Services/Iec61850ReportContinuityInspector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ internal sealed class Iec61850ReportContinuityState
public long ConfRevChanges { get; set; }
public long EntryIdPresentFrames { get; set; }
public ulong? FirstSequenceNumber { get; set; }
public long OptFldsDecodedFrames { get; set; }
public long OptFldsUnknownFrames { get; set; }
public long SqNumAdvertisedFrames { get; set; }
public long SqNumOmittedFrames { get; set; }
public long SqNumAdvertisedMissingFrames { get; set; }
public long EntryIdAdvertisedFrames { get; set; }
public long EntryIdOmittedFrames { get; set; }
public long EntryIdAdvertisedEmptyFrames { get; set; }
public string LastOptFldsHex { get; set; } = string.Empty;
}

/// <summary>
Expand All @@ -54,6 +63,17 @@ public sealed record Iec61850ReportContinuityStreamSnapshot
public long EntryIdPresent { get; init; }
public ulong? FirstSqNum { get; init; }
public ulong? LastSqNum { get; init; }
public long OptFldsDecoded { get; init; }
public long OptFldsUnknown { get; init; }
public long SqNumAdvertised { get; init; }
public long SqNumOmitted { get; init; }
public long SqNumAdvertisedMissing { get; init; }
public long EntryIdAdvertised { get; init; }
public long EntryIdOmitted { get; init; }
// An empty decoded EntryID could also represent an empty OCTET STRING; this
// counter alone never attributes the cause to wire loss or decoder failure.
public long EntryIdEmptyOrUnprojected { get; init; }
public string LastOptFldsHex { get; init; } = string.Empty;
}

public sealed record Iec61850ReportContinuitySnapshot
Expand All @@ -65,6 +85,8 @@ public sealed record Iec61850ReportContinuitySnapshot
public long Sequenced { get; init; }
public long Findings { get; init; }
public long Overflows { get; init; }
public long OptFldsDecoded { get; init; }
public long OptFldsUnknown { get; init; }
public IReadOnlyList<Iec61850ReportContinuityStreamSnapshot> Streams { get; init; } =
Array.Empty<Iec61850ReportContinuityStreamSnapshot>();
}
Expand Down Expand Up @@ -117,6 +139,55 @@ string EntryContext() => buffered
? $"; BRCB EntryID previousPresent={priorEntryIdPresent}, currentPresent={entryIdPresent}, changed={entryIdChanged}"
: string.Empty;

// Raw OptFlds is authoritative decoder evidence. Unknown stays UNKNOWN;
// a missing value cannot be interpreted as an omitted wire field.
var hasOptFlds = frame.OptFldsSequenceNumber.HasValue &&
frame.OptFldsEntryId.HasValue &&
IsBoundedHex(frame.OptFldsRawHex);
if (hasOptFlds)
{
if (state.OptFldsDecodedFrames < long.MaxValue) state.OptFldsDecodedFrames++;
state.LastOptFldsHex = frame.OptFldsRawHex;
if (frame.OptFldsSequenceNumber == true)
{
if (state.SqNumAdvertisedFrames < long.MaxValue) state.SqNumAdvertisedFrames++;
if (!frame.SequenceNumber.HasValue)
{
if (state.SqNumAdvertisedMissingFrames < long.MaxValue)
state.SqNumAdvertisedMissingFrames++;
Warn("Report OptFlds advertises SqNum, but ARIEC decoded no SqNum; inspect decoder and on-wire field completeness");
}
}
else
{
if (state.SqNumOmittedFrames < long.MaxValue) state.SqNumOmittedFrames++;
if (frame.SequenceNumber.HasValue)
Warn("Report OptFlds omits SqNum, but decoded frame supplied SqNum; metadata provenance is inconsistent");
}

if (frame.OptFldsEntryId == true)
{
if (state.EntryIdAdvertisedFrames < long.MaxValue) state.EntryIdAdvertisedFrames++;
if (!entryIdPresent && state.EntryIdAdvertisedEmptyFrames < long.MaxValue)
state.EntryIdAdvertisedEmptyFrames++;
}
else
{
if (state.EntryIdOmittedFrames < long.MaxValue) state.EntryIdOmittedFrames++;
if (entryIdPresent)
Warn("Report OptFlds omits EntryID, but decoded frame supplied EntryID; metadata provenance is inconsistent");
}

if (frame.OptFldsBufferOverflow == false && frame.BufferOverflow.HasValue)
Warn("Report OptFlds omits BufOvfl, but decoded frame supplied it; metadata provenance is inconsistent");
if (frame.OptFldsConfRev == false && frame.ConfRev.HasValue)
Warn("Report OptFlds omits ConfRev, but decoded frame supplied it; metadata provenance is inconsistent");
}
else if (state.OptFldsUnknownFrames < long.MaxValue)
{
state.OptFldsUnknownFrames++;
}

if (frame.BufferOverflow == true)
{
if (state.BufferOverflows < long.MaxValue) state.BufferOverflows++;
Expand Down Expand Up @@ -238,7 +309,16 @@ internal static Iec61850ReportContinuitySnapshot Snapshot(
ConfRevChanges = state.ConfRevChanges,
EntryIdPresent = state.EntryIdPresentFrames,
FirstSqNum = state.FirstSequenceNumber,
LastSqNum = state.LastSequenceNumber
LastSqNum = state.LastSequenceNumber,
OptFldsDecoded = state.OptFldsDecodedFrames,
OptFldsUnknown = state.OptFldsUnknownFrames,
SqNumAdvertised = state.SqNumAdvertisedFrames,
SqNumOmitted = state.SqNumOmittedFrames,
SqNumAdvertisedMissing = state.SqNumAdvertisedMissingFrames,
EntryIdAdvertised = state.EntryIdAdvertisedFrames,
EntryIdOmitted = state.EntryIdOmittedFrames,
EntryIdEmptyOrUnprojected = state.EntryIdAdvertisedEmptyFrames,
LastOptFldsHex = state.LastOptFldsHex
}).ToArray();
return new Iec61850ReportContinuitySnapshot
{
Expand All @@ -249,10 +329,26 @@ internal static Iec61850ReportContinuitySnapshot Snapshot(
Sequenced = ordered.Sum(state => state.SequencedFrames),
Findings = ordered.Sum(state => state.AnomalyFindings),
Overflows = ordered.Sum(state => state.BufferOverflows),
OptFldsDecoded = ordered.Sum(state => state.OptFldsDecodedFrames),
OptFldsUnknown = ordered.Sum(state => state.OptFldsUnknownFrames),
Streams = shown
};
}

private static bool IsBoundedHex(string? value)
{
if (string.IsNullOrEmpty(value) || value.Length > 16 || (value.Length & 1) != 0)
return false;
foreach (var character in value)
{
if (!((character >= '0' && character <= '9') ||
(character >= 'a' && character <= 'f') ||
(character >= 'A' && character <= 'F')))
return false;
}
return true;
}

private static string? DescribeSequenceAnomaly(ulong? previous, ulong current)
{
if (!previous.HasValue)
Expand Down
11 changes: 11 additions & 0 deletions Services/NativeIec61850Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -484,12 +484,23 @@ public async Task<NativeReportMonitorSliceResult> ReceiveReportMonitorSliceAsync
foreach (var report in slice.Reports)
{
var header = report.Header;
// ARIEC already decodes the actual InformationReport OptFlds BIT STRING.
// Preserve its evidence rather than guessing option presence from a
// nullable SqNum/EntryID or re-parsing MMS in the Studio consumer.
var optFlds = header.OptionalFields;
var hasDecodedOptFlds = !string.IsNullOrWhiteSpace(optFlds.RawHex);
frames.Add(new NativeReportFrameMetadata
{
ReportControlReference = session.ReportControl.Reference,
ReportId = header.ReportId,
DataSetReference = header.DataSetReference,
SequenceNumber = header.SequenceNumber,
OptFldsRawHex = hasDecodedOptFlds
? optFlds.RawHex[..Math.Min(optFlds.RawHex.Length, 16)] : string.Empty,
OptFldsSequenceNumber = hasDecodedOptFlds ? optFlds.HasSequenceNumber : null,
OptFldsEntryId = hasDecodedOptFlds ? optFlds.HasEntryId : null,
OptFldsBufferOverflow = hasDecodedOptFlds ? optFlds.HasBufferOverflow : null,
OptFldsConfRev = hasDecodedOptFlds ? optFlds.HasConfRevision : null,
SubSequenceNumber = header.SubSequenceNumber,
MoreSegmentsFollow = header.MoreSegmentsFollow,
BufferOverflow = header.BufferOverflow,
Expand Down
152 changes: 152 additions & 0 deletions tests/ARSAS.Tests/ReportOptFldsProvenanceP76DTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using System.Text;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;

namespace ARSAS.Tests;

/// <summary>Wire OptFlds were already decoded in ARIEC; tests ensure Studio
/// preserves missing-vs-omitted evidence without inventing SOE proof.</summary>
public sealed class ReportOptFldsProvenanceP76DTests
{
[Fact]
public void SqNumOmittedOnWire_IsDifferentFromDecoderMissingField()
{
var state = new Iec61850ReportContinuityState();
Assert.Empty(Iec61850ReportContinuityInspector.Observe(state,
Frame("0000", sqOpt: false, entryOpt: false), false));
Assert.Equal(1, state.OptFldsDecodedFrames);
Assert.Equal(1, state.SqNumOmittedFrames);
Assert.Equal(0, state.SqNumAdvertisedMissingFrames);
Assert.Equal(1, state.EntryIdOmittedFrames);

var snapshot = Snapshot(state);
var diagnostic = new StringBuilder();
DiagnosticReportBuilder.AppendReportContinuity(diagnostic, snapshot);
Assert.Contains("OptFldsDecoded=1", diagnostic.ToString(), StringComparison.Ordinal);
Assert.Contains("SqNum requested=0, omitted=1, requestedButUndecoded=0",
diagnostic.ToString(), StringComparison.Ordinal);
Assert.Contains("EntryID requested=0, omitted=1", diagnostic.ToString(), StringComparison.Ordinal);
}

[Fact]
public void SqNumAdvertisedButMissing_IsFailVisibleNotSilent()
{
var state = new Iec61850ReportContinuityState();
var warnings = Iec61850ReportContinuityInspector.Observe(state,
Frame("4000", sqOpt: true, entryOpt: false), true);
Assert.Contains(warnings, w => w.Contains("advertises SqNum", StringComparison.Ordinal));
Assert.Equal(1, state.SqNumAdvertisedFrames);
Assert.Equal(1, state.SqNumAdvertisedMissingFrames);
Assert.Equal(1, state.AnomalyFindings);
Assert.Null(state.LastSequenceNumber);
}

[Fact]
public void UnknownProvenance_IsNotMisclassifiedAsOmitted()
{
var state = new Iec61850ReportContinuityState();
var frame = new NativeReportFrameMetadata { SequenceNumber = 0 };
Assert.Empty(Iec61850ReportContinuityInspector.Observe(state, frame, true));
Assert.Equal(1, state.OptFldsUnknownFrames);
Assert.Equal(0, state.OptFldsDecodedFrames);
Assert.Equal(0, state.SqNumOmittedFrames);
Assert.Equal(0, state.SqNumAdvertisedFrames);
}

[Fact]
public void PhysicalZeroZeroSequence_RemainsUnverifiedEvenWithOptFldsKnown()
{
var state = new Iec61850ReportContinuityState();
Assert.Empty(Iec61850ReportContinuityInspector.Observe(state,
Frame("4000", sqOpt: true, entryOpt: false, seq: 0), true));
var warnings = Iec61850ReportContinuityInspector.Observe(state,
Frame("4000", sqOpt: true, entryOpt: false, seq: 0), true);
Assert.Contains(warnings, w => w.Contains("duplicate/replay", StringComparison.Ordinal));
Assert.Equal(2, state.SqNumAdvertisedFrames);
Assert.Equal(1, state.AnomalyFindings);
Assert.Equal(0, state.EntryIdAdvertisedFrames);
Assert.Equal(2, state.EntryIdOmittedFrames);
}

[Fact]
public void AdvertisedEntryIdWithEmptyValue_DoesNotClaimTransportLoss()
{
var state = new Iec61850ReportContinuityState();
Assert.Empty(Iec61850ReportContinuityInspector.Observe(state,
Frame("0100", sqOpt: false, entryOpt: true), true));
Assert.Equal(1, state.EntryIdAdvertisedFrames);
Assert.Equal(1, state.EntryIdAdvertisedEmptyFrames);
Assert.Equal(0, state.AnomalyFindings);
var diagnostic = new StringBuilder();
DiagnosticReportBuilder.AppendReportContinuity(diagnostic, Snapshot(state));
Assert.Contains("emptyOrUnprojected=1", diagnostic.ToString(), StringComparison.Ordinal);
}

[Fact]
public void ContradictoryOptFldsMetadata_IsWarnedWithoutDroppingValues()
{
var state = new Iec61850ReportContinuityState();
var warnings = Iec61850ReportContinuityInspector.Observe(state,
Frame("0000", sqOpt: false, entryOpt: false, seq: 9, entry: "AA"), true);
Assert.Contains(warnings, w => w.Contains("omits SqNum", StringComparison.Ordinal));
Assert.Contains(warnings, w => w.Contains("omits EntryID", StringComparison.Ordinal));
Assert.Equal(9UL, state.LastSequenceNumber);
}

[Fact]
public void MalformedOptFldsRawMask_IsUnknownAndNeverPrintedUnescaped()
{
var state = new Iec61850ReportContinuityState();
var bad = new NativeReportFrameMetadata
{
OptFldsRawHex = "00\r\nStatic parity : SPOOF",
OptFldsSequenceNumber = false,
OptFldsEntryId = false
};
Assert.Empty(Iec61850ReportContinuityInspector.Observe(state, bad, true));
Assert.Equal(1, state.OptFldsUnknownFrames);
Assert.Equal(string.Empty, state.LastOptFldsHex);
var sb = new StringBuilder();
DiagnosticReportBuilder.AppendReportContinuity(sb, Snapshot(state));
Assert.DoesNotContain("SPOOF", sb.ToString(), StringComparison.Ordinal);
}

[Fact]
public void Adapter_UsesAuthoritativeEngineDecodedOptionalFields_NotLocalMmsParsing()
{
var adapter = Read("Services/NativeIec61850Client.cs");
Assert.Contains("var optFlds = header.OptionalFields;", adapter, StringComparison.Ordinal);
Assert.Contains("optFlds.HasSequenceNumber", adapter, StringComparison.Ordinal);
Assert.Contains("optFlds.HasEntryId", adapter, StringComparison.Ordinal);
Assert.Contains("optFlds.RawHex", adapter, StringComparison.Ordinal);
var inspector = Read("Services/Iec61850ReportContinuityInspector.cs");
Assert.DoesNotContain("DecodeMms", inspector, StringComparison.Ordinal);
Assert.DoesNotContain("ReadReportAttributeAsync", inspector, StringComparison.Ordinal);
}

private static NativeReportFrameMetadata Frame(string hex, bool sqOpt, bool entryOpt,
ulong? seq = null, string entry = "") => new()
{
OptFldsRawHex = hex,
OptFldsSequenceNumber = sqOpt,
OptFldsEntryId = entryOpt,
SequenceNumber = seq,
EntryIdHex = entry
};

private static Iec61850ReportContinuitySnapshot Snapshot(Iec61850ReportContinuityState state) =>
Iec61850ReportContinuityInspector.Snapshot(
new Dictionary<string, Iec61850ReportContinuityState> { ["rcb"] = state }, 0, 0);

private static string Read(string path)
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
var full = Path.Combine(directory.FullName, path);
if (File.Exists(full)) return File.ReadAllText(full);
directory = directory.Parent;
}
throw new FileNotFoundException(path);
}
}