Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
89be6b9
feat(parity): add source-neutral static acquisition evidence model (#…
masarray Oct 8, 2026
ac1d478
feat(parity): fingerprint Discovery and Open-SCL static acquisition s…
masarray Oct 8, 2026
e5e9250
feat(parity): retain runtime-only ingress parity evidence per IED (#446)
masarray Oct 8, 2026
ea1db8b
feat(parity): record static acquisition evidence per ingress at engin…
masarray Oct 8, 2026
77d05d8
feat(diagnostics): expose Discovery/Open-SCL static parity evidence (…
masarray Oct 8, 2026
fd0da5e
test(parity): lock Discovery/Open-SCL static semantic fingerprint con…
masarray Oct 8, 2026
d68929b
fix(parity): bind semantic fingerprint to logical IED identity (#446)
masarray Oct 8, 2026
2f51422
fix(test): construct monitor points through stable identity fields (#…
masarray Oct 8, 2026
8b72aa5
refactor(parity): fingerprint final static semantics, not planner-int…
masarray Oct 8, 2026
bd0ceb8
fix(p7.3): represent incomplete static parity evidence explicitly
masarray Oct 8, 2026
c9b449e
fix(p7.3): fail closed when static parity evidence is incomplete
masarray Oct 8, 2026
4eff8af
fix(p7.3): warn on incomplete static parity without affecting reporting
masarray Oct 8, 2026
e95cbd6
test(p7.3): verify slot diagnostic isolation and fail-closed parity l…
masarray Oct 8, 2026
9bfe39a
test(p7.3): compare actual ordered SCL members, not an absent fixture
masarray Oct 8, 2026
f5729f4
ci(p7.3): repair truncated R7 workflow with strict physical/trial pro…
masarray Oct 8, 2026
389a635
ci(p7.3): remove duplicated malformed tail from inherited R7 workflow
masarray Oct 8, 2026
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
292 changes: 38 additions & 254 deletions .github/workflows/scl-interoperability-r7.yml

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Models/MonitorModels.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using AR.Iec61850.Discovery;
using AR.Iec61850.Scl.Workspace;

Expand Down Expand Up @@ -47,6 +48,9 @@ public sealed class Iec61850MonitorDevice : ObservableObject
public BulkObservableCollection<SignalDefinition> CommandSignals { get; } = new();
public Iec61850DeviceDiagnosticSnapshot LastDiagnosticSnapshot { get; set; } = new();

[JsonIgnore]
public StaticAcquisitionParitySnapshot StaticAcquisitionParity { get; set; } = new();

public SclIedWorkspace? SclWorkspace
{
get => _sclWorkspace;
Expand Down
68 changes: 68 additions & 0 deletions Models/StaticAcquisitionParityModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
namespace ArIED61850Tester.Models;

public enum StaticAcquisitionIngressKind
{
Unknown,
LiveDiscovery,
OpenScl
}

public enum StaticAcquisitionParityStatus
{
NotAvailable,
AwaitingOtherIngress,
Equivalent,
Mismatch,
InsufficientEvidence
}

/// <summary>
/// Source-neutral semantic evidence for one Static DataSet planning pass.
///
/// Concrete runtime RCB instance names are retained for diagnostics but are deliberately
/// excluded from SemanticFingerprint. An indexed RCB slot may legitimately change from
/// Rpt01 to Rpt02 across associations while the configured DataSet/report semantics remain
/// equivalent.
/// </summary>
public sealed class StaticAcquisitionIngressEvidence
{
public DateTimeOffset CapturedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public StaticAcquisitionIngressKind Ingress { get; init; }
public string IedName { get; init; } = string.Empty;
public string SemanticFingerprint { get; init; } = string.Empty;
public int RequestedPointCount { get; init; }
public int PlannedReportCount { get; init; }
public int StaticBrcbSignalCount { get; init; }
public int StaticUrcbSignalCount { get; init; }
public int UncoveredSignalCount { get; init; }
public IReadOnlyList<string> SemanticLines { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> RuntimeTargets { get; init; } = Array.Empty<string>();
public bool IsComparable { get; init; }
public string IncomparableReason { get; init; } = string.Empty;

public string Summary =>
$"{Ingress}: fingerprint={SemanticFingerprint}, requested={RequestedPointCount}, " +
$"plans={PlannedReportCount}, staticBRCB={StaticBrcbSignalCount}, " +
$"staticURCB={StaticUrcbSignalCount}, uncovered={UncoveredSignalCount}";
}

public sealed class StaticAcquisitionParitySnapshot
{
public StaticAcquisitionIngressEvidence? Discovery { get; init; }
public StaticAcquisitionIngressEvidence? OpenScl { get; init; }
public StaticAcquisitionParityStatus Status { get; init; } = StaticAcquisitionParityStatus.NotAvailable;
public IReadOnlyList<string> Differences { get; init; } = Array.Empty<string>();

public string Summary => Status switch
{
StaticAcquisitionParityStatus.Equivalent =>
$"MATCH • {Discovery?.SemanticFingerprint ?? OpenScl?.SemanticFingerprint ?? "unavailable"}",
StaticAcquisitionParityStatus.Mismatch =>
$"MISMATCH • discovery={Discovery?.SemanticFingerprint ?? "missing"} • scl={OpenScl?.SemanticFingerprint ?? "missing"}",
StaticAcquisitionParityStatus.InsufficientEvidence =>
$"INCOMPLETE EVIDENCE • {string.Join("; ", Differences.Take(2))}",
StaticAcquisitionParityStatus.AwaitingOtherIngress =>
$"AWAITING PEER INGRESS • {(Discovery is not null ? "Discovery captured" : "Open SCL captured")}",
_ => "not captured"
};
}
27 changes: 26 additions & 1 deletion Services/DiagnosticReportBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public static string BuildEmergency(
foreach (var device in devices)
{
builder.AppendLine(
$"{device.DeviceId} | {device.Name} | endpoint={device.EndpointText} | connected={device.IsConnected} | monitoring={device.IsMonitoring} | status={device.Status}");
$"{device.DeviceId} | {device.Name} | endpoint={device.EndpointText} | connected={device.IsConnected} | monitoring={device.IsMonitoring} | status={device.Status} | staticParity={device.StaticAcquisitionParity.Summary}");
}
if (devices.Count == 0)
builder.AppendLine("No IED card is present.");
Expand Down Expand Up @@ -136,6 +136,7 @@ public static async Task<string> BuildAsync(
builder.AppendLine($"Status : {device.Status}");
builder.AppendLine($"Detail : {device.Detail}");
builder.AppendLine($"Acquisition : {device.AcquisitionMode}");
AppendStaticIngressParity(builder, device.StaticAcquisitionParity);
builder.AppendLine($"Saved model : {device.HasDiscoveryCache} ({device.SignalCount:N0} signal(s))");
builder.AppendLine($"Selected : live={device.SelectedLiveSignalCount:N0}, control={device.SelectedControlSignalCount:N0}");
builder.AppendLine($"Logical Devices : {EmptyAsUnavailable(device.LogicalDeviceSummary)}");
Expand Down Expand Up @@ -219,6 +220,30 @@ public static async Task<string> BuildAsync(
}


private static void AppendStaticIngressParity(
StringBuilder builder,
StaticAcquisitionParitySnapshot parity)
{
builder.AppendLine($"Static parity : {parity.Summary}");

if (parity.Discovery is { } discovery)
{
builder.AppendLine($" Discovery : {discovery.Summary}");
foreach (var target in discovery.RuntimeTargets.Take(8))
builder.AppendLine($" runtime : {target}");
}

if (parity.OpenScl is { } openScl)
{
builder.AppendLine($" Open SCL : {openScl.Summary}");
foreach (var target in openScl.RuntimeTargets.Take(8))
builder.AppendLine($" runtime : {target}");
}

foreach (var difference in parity.Differences.Take(12))
builder.AppendLine($" difference : {difference}");
}

private static RouteAnalysis AnalyzeRoute(string targetText)
{
if (!IPAddress.TryParse(targetText, out var target) || target.AddressFamily != AddressFamily.InterNetwork)
Expand Down
9 changes: 9 additions & 0 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,15 @@ private async Task<IReadOnlyList<ReportControlPlan>> BuildReportPlansForCurrentA
session.HybridValidation.Reset(hybrid);
if (session.StaticDataSetReportOnly)
{
var parity = StaticAcquisitionParityTracker.Record(session.Device, hybrid);
Log(
parity.Status is StaticAcquisitionParityStatus.Mismatch or StaticAcquisitionParityStatus.InsufficientEvidence ? "WARN" : "INFO",
session.Device.Name,
$"Static ingress parity evidence: {parity.Summary}. Concrete live RCB slots are diagnostic-only and excluded from the semantic fingerprint.");

foreach (var difference in parity.Differences.Take(6))
Log("WARN", session.Device.Name, $"Static ingress parity difference: {difference}");

Log("INFO", session.Device.Name,
$"Static DataSet ARIEC authority={hybrid.Authority}; status={hybrid.Status}; requested={hybrid.RequestedPointCount}, catalog={hybrid.CatalogMappedPointCount}, staticBRCB={hybrid.StaticBrcbSignalCount}, staticURCB={hybrid.StaticUrcbSignalCount}, engineFallbackCandidates={hybrid.PollingFallbackSignalCount}, uncovered={hybrid.UncoveredSignalCount}. Engine fallback candidates are diagnostic only and are not scheduled as MMS process polling.");
}
Expand Down
238 changes: 238 additions & 0 deletions Services/StaticAcquisitionParityTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
using System.Security.Cryptography;
using System.Text;
using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;

namespace ArIED61850Tester.Services;

/// <summary>
/// Builds a stable semantic fingerprint for Static DataSet acquisition and compares
/// Discovery vs Open-SCL ingress without confusing concrete live RCB slot ownership with
/// configuration equivalence.
/// </summary>
internal static class StaticAcquisitionParityTracker
{
public static StaticAcquisitionParitySnapshot Record(
Iec61850MonitorDevice device,
NativeHybridReportPlanningResult planning)
{
ArgumentNullException.ThrowIfNull(device);
ArgumentNullException.ThrowIfNull(planning);

var ingress = ResolveIngress(device);
if (ingress == StaticAcquisitionIngressKind.Unknown)
return device.StaticAcquisitionParity;

var evidence = BuildEvidence(device, planning, ingress);
var previous = device.StaticAcquisitionParity;

var discovery = ingress == StaticAcquisitionIngressKind.LiveDiscovery
? evidence
: previous.Discovery;
var openScl = ingress == StaticAcquisitionIngressKind.OpenScl
? evidence
: previous.OpenScl;

var updated = Compare(discovery, openScl);
device.StaticAcquisitionParity = updated;
return updated;
}

internal static StaticAcquisitionIngressEvidence BuildEvidence(
Iec61850MonitorDevice device,
NativeHybridReportPlanningResult planning,
StaticAcquisitionIngressKind ingress)
{
var model = ingress == StaticAcquisitionIngressKind.OpenScl
? device.SclWorkspace?.DesignModel
: device.LiveDiscoveryModel;

var staticPlans = planning.ReportPlans
.Where(plan =>
plan.IsEngineAuthoritative &&
plan.EngineAcquisitionKind.StartsWith("Static", StringComparison.OrdinalIgnoreCase))
.OrderBy(plan => NormalizeReference(plan.DataSetReference), StringComparer.Ordinal)
.ThenBy(plan => NormalizeKind(plan.EngineAcquisitionKind), StringComparer.Ordinal)
.ToArray();

// A matching hash must never establish parity when the engine did not provide
// a complete, authoritative Static DataSet plan and ordered membership model.
var evidenceIssues = new List<string>();
if (!planning.IsAuthoritative)
evidenceIssues.Add("planner was not authoritative");
if (planning.RequestedPointCount <= 0)
evidenceIssues.Add("no selected runtime points");
if (planning.UncoveredSignalCount > 0)
evidenceIssues.Add($"{planning.UncoveredSignalCount} uncovered runtime point(s)");
if (staticPlans.Length == 0)
evidenceIssues.Add("no engine-authoritative static report plans");
if (model is null)
evidenceIssues.Add("ingress model missing");

var semanticLines = new List<string>
{
$"ied={NormalizeReference(device.Name)}|requested={planning.RequestedPointCount}|" +
$"staticBrcbSignals={planning.StaticBrcbSignalCount}|staticUrcbSignals={planning.StaticUrcbSignalCount}|" +
$"uncovered={planning.UncoveredSignalCount}|staticPlans={staticPlans.Length}"
};

var runtimeTargets = new List<string>();

foreach (var plan in staticPlans)
{
var dataSetReference = NormalizeReference(plan.DataSetReference);
var kind = NormalizeKind(plan.EngineAcquisitionKind);
var dataSet = model?.DataSets.FirstOrDefault(candidate =>
NormalizeReference(candidate.Reference).Equals(dataSetReference, StringComparison.Ordinal));

var members = dataSet?.Members
.OrderBy(member => member.Index)
.Select(member =>
$"{member.Index}:{NormalizeReference(member.Reference)}@{NormalizeFc(member.FunctionalConstraint)}")
.ToArray()
?? Array.Empty<string>();

if (dataSet is null)
evidenceIssues.Add($"DataSet {dataSetReference} missing from {ingress} model");
else if (members.Length == 0)
evidenceIssues.Add($"DataSet {dataSetReference} has no ordered member evidence");
else if (dataSet.MemberCount > 0 && dataSet.MemberCount != members.Length)
evidenceIssues.Add($"DataSet {dataSetReference} has {members.Length}/{dataSet.MemberCount} member descriptors");

var selected = plan.Bindings
.Where(point => !string.IsNullOrWhiteSpace(point.IecReference))
.Select(point =>
$"{NormalizeReference(point.IecReference)}@{NormalizeFc(point.FunctionalConstraint)}")
.Distinct(StringComparer.Ordinal)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();

if (selected.Length == 0)
evidenceIssues.Add($"DataSet {dataSetReference} has no selected point bindings");

semanticLines.Add(
$"{kind}|dataset={dataSetReference}|members={members.Length}[{string.Join(",", members)}]|" +
$"selected={selected.Length}[{string.Join(",", selected)}]");

runtimeTargets.Add(
$"{kind}|dataset={dataSetReference}|rcb={NormalizeReference(plan.ReportControlReference)}|" +
$"bindings={plan.Bindings.Count}");
}

var canonicalText = string.Join("\n", semanticLines);
var fingerprint = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(canonicalText)))
.ToLowerInvariant();

return new StaticAcquisitionIngressEvidence
{
CapturedAtUtc = DateTimeOffset.UtcNow,
Ingress = ingress,
IedName = device.Name,
SemanticFingerprint = fingerprint,
RequestedPointCount = planning.RequestedPointCount,
PlannedReportCount = staticPlans.Length,
StaticBrcbSignalCount = planning.StaticBrcbSignalCount,
StaticUrcbSignalCount = planning.StaticUrcbSignalCount,
UncoveredSignalCount = planning.UncoveredSignalCount,
SemanticLines = semanticLines,
RuntimeTargets = runtimeTargets,
IsComparable = evidenceIssues.Count == 0,
IncomparableReason = string.Join("; ", evidenceIssues)
};
}

internal static StaticAcquisitionParitySnapshot Compare(
StaticAcquisitionIngressEvidence? discovery,
StaticAcquisitionIngressEvidence? openScl)
{
if (discovery is null && openScl is null)
return new StaticAcquisitionParitySnapshot();

if (discovery is null || openScl is null)
{
return new StaticAcquisitionParitySnapshot
{
Discovery = discovery,
OpenScl = openScl,
Status = StaticAcquisitionParityStatus.AwaitingOtherIngress
};
}

if (!discovery.IsComparable || !openScl.IsComparable)
{
var issues = new List<string>();
if (!discovery.IsComparable)
issues.Add($"Discovery: {discovery.IncomparableReason}");
if (!openScl.IsComparable)
issues.Add($"Open SCL: {openScl.IncomparableReason}");
return new StaticAcquisitionParitySnapshot
{
Discovery = discovery,
OpenScl = openScl,
Status = StaticAcquisitionParityStatus.InsufficientEvidence,
Differences = issues
};
}

if (discovery.SemanticFingerprint.Equals(
openScl.SemanticFingerprint,
StringComparison.OrdinalIgnoreCase))
{
return new StaticAcquisitionParitySnapshot
{
Discovery = discovery,
OpenScl = openScl,
Status = StaticAcquisitionParityStatus.Equivalent
};
}

var discoveryLines = discovery.SemanticLines.ToHashSet(StringComparer.Ordinal);
var sclLines = openScl.SemanticLines.ToHashSet(StringComparer.Ordinal);
var differences = discoveryLines
.Except(sclLines, StringComparer.Ordinal)
.Select(line => $"Discovery only: {line}")
.Concat(sclLines
.Except(discoveryLines, StringComparer.Ordinal)
.Select(line => $"Open SCL only: {line}"))
.Take(12)
.ToArray();

return new StaticAcquisitionParitySnapshot
{
Discovery = discovery,
OpenScl = openScl,
Status = StaticAcquisitionParityStatus.Mismatch,
Differences = differences
};
}

private static StaticAcquisitionIngressKind ResolveIngress(Iec61850MonitorDevice device)
{
if (device.SclWorkspace?.DesignModel is not null)
return StaticAcquisitionIngressKind.OpenScl;
if (device.LiveDiscoveryModel is not null)
return StaticAcquisitionIngressKind.LiveDiscovery;
return StaticAcquisitionIngressKind.Unknown;
}

private static string NormalizeReference(string? value)
=> (value ?? string.Empty)
.Trim()
.Replace('$', '.')
.Replace("..", ".")
.ToLowerInvariant();

private static string NormalizeKind(string? value)
{
var kind = (value ?? string.Empty).Trim();
if (kind.Contains("brcb", StringComparison.OrdinalIgnoreCase))
return "static-brcb";
if (kind.Contains("urcb", StringComparison.OrdinalIgnoreCase))
return "static-urcb";
return "static-report";
}

private static string NormalizeFc(string? value)
=> (value ?? string.Empty).Trim().ToUpperInvariant();
}
Loading