Skip to content
Draft
20 changes: 19 additions & 1 deletion Models/StaticAcquisitionParityModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ public enum StaticAcquisitionParityStatus
/// Rpt01 to Rpt02 across associations while the configured DataSet/report semantics remain
/// equivalent.
/// </summary>
public sealed class StaticAcquisitionIngressEvidence
public sealed record class StaticAcquisitionIngressEvidence
{
public DateTimeOffset CapturedAtUtc { get; init; } = DateTimeOffset.UtcNow;
// Runtime-only evidence token. Never part of the semantic fingerprint.
public Guid PlanningAttemptId { get; init; } = Guid.NewGuid();
public StaticAcquisitionIngressKind Ingress { get; init; }
public string IedName { get; init; } = string.Empty;
public string SemanticFingerprint { get; init; } = string.Empty;
Expand All @@ -37,6 +39,13 @@ public sealed class StaticAcquisitionIngressEvidence
public int UncoveredSignalCount { get; init; }
public IReadOnlyList<string> SemanticLines { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> RuntimeTargets { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> RoutedReportTargets { get; init; } = Array.Empty<string>();
public DateTimeOffset? FirstRoutedReportAtUtc { get; init; }

public int RoutedTargetCount => RoutedReportTargets.Count;
public bool AllPlannedTargetsRouted => IsComparable && RuntimeTargets.Count > 0 &&
RuntimeTargets.All(target => RoutedReportTargets.Contains(target, StringComparer.Ordinal));
public string RoutedTrafficSummary => $"{RoutedTargetCount}/{RuntimeTargets.Count} exact static RCB target(s) routed";
public bool IsComparable { get; init; }
public string IncomparableReason { get; init; } = string.Empty;

Expand All @@ -53,6 +62,15 @@ public sealed class StaticAcquisitionParitySnapshot
public StaticAcquisitionParityStatus Status { get; init; } = StaticAcquisitionParityStatus.NotAvailable;
public IReadOnlyList<string> Differences { get; init; } = Array.Empty<string>();

// Distinct from configuration equality: both ingress associations must actually route
// process reports from each planned concrete RCB (BRCB and URCB independently).
public bool DualIngressTrafficProven => Status == StaticAcquisitionParityStatus.Equivalent &&
Discovery?.AllPlannedTargetsRouted == true && OpenScl?.AllPlannedTargetsRouted == true;

public string TrafficQualificationSummary => DualIngressTrafficProven
? "DUAL INGRESS TRAFFIC PROVEN (routed static RCB data from both paths)"
: $"TRAFFIC PENDING • Discovery {Discovery?.RoutedTrafficSummary ?? "not captured"} • Open SCL {OpenScl?.RoutedTrafficSummary ?? "not captured"}";

public string Summary => Status switch
{
StaticAcquisitionParityStatus.Equivalent =>
Expand Down
27 changes: 24 additions & 3 deletions 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} | staticParity={device.StaticAcquisitionParity.Summary}");
$"{device.DeviceId} | {device.Name} | endpoint={device.EndpointText} | connected={device.IsConnected} | monitoring={device.IsMonitoring} | status={device.Status} | staticParity={device.StaticAcquisitionParity.Summary} | routedTraffic={device.StaticAcquisitionParity.TrafficQualificationSummary}");
}
if (devices.Count == 0)
builder.AppendLine("No IED card is present.");
Expand Down Expand Up @@ -137,6 +137,8 @@ public static async Task<string> BuildAsync(
builder.AppendLine($"Detail : {device.Detail}");
builder.AppendLine($"Acquisition : {device.AcquisitionMode}");
AppendStaticIngressParity(builder, device.StaticAcquisitionParity);
if (device.IsMonitoring && Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device))
AppendStaticInitialImage(builder, device.Points);
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 @@ -220,24 +222,43 @@ public static async Task<string> BuildAsync(
}


private static void AppendStaticInitialImage(
StringBuilder builder,
IEnumerable<Iec61850MonitorPoint> points)
{
var image = StaticDataSetInitialImageDiagnostic.Evaluate(points);
builder.AppendLine($"Static initial image: {image.Summary}");
builder.AppendLine(" Interpretation : display completeness only; Unknown q does not mean missing value, and RCB setup or first report does not prove every selected member is live.");
foreach (var group in image.Groups.Take(12))
builder.AppendLine($" DataSet/RCB : {group.DataSetReference} | {group.RcbReference} | visible={group.ValueVisible}/{group.Selected}, pending={group.ValuePending}, qNotSupplied={group.QualityNotSupplied}");
foreach (var point in image.PendingPoints.Take(12))
builder.AppendLine($" Awaiting value : {point.Reference} | RCB={point.RcbReference} | status={point.Status} | reason={point.Reason}");
if (image.PendingPoints.Count > 12)
builder.AppendLine($" Additional pending: {image.PendingPoints.Count - 12} (showing first 12; never inferred unavailable)");
}

private static void AppendStaticIngressParity(
StringBuilder builder,
StaticAcquisitionParitySnapshot parity)
{
builder.AppendLine($"Static parity : {parity.Summary}");
builder.AppendLine($"Routed traffic : {parity.TrafficQualificationSummary}");
builder.AppendLine("Physical qualifier: semantic MATCH is not traffic proof; each planned static RCB needs a schema-safe routed process value on its own ingress.");

if (parity.Discovery is { } discovery)
{
builder.AppendLine($" Discovery : {discovery.Summary}");
builder.AppendLine($" traffic : {discovery.RoutedTrafficSummary}; first={discovery.FirstRoutedReportAtUtc?.ToString("O") ?? "pending"}");
foreach (var target in discovery.RuntimeTargets.Take(8))
builder.AppendLine($" runtime : {target}");
builder.AppendLine($" runtime : {target} [{(discovery.RoutedReportTargets.Contains(target, StringComparer.Ordinal) ? "ROUTED" : "PENDING")}]");
}

if (parity.OpenScl is { } openScl)
{
builder.AppendLine($" Open SCL : {openScl.Summary}");
builder.AppendLine($" traffic : {openScl.RoutedTrafficSummary}; first={openScl.FirstRoutedReportAtUtc?.ToString("O") ?? "pending"}");
foreach (var target in openScl.RuntimeTargets.Take(8))
builder.AppendLine($" runtime : {target}");
builder.AppendLine($" runtime : {target} [{(openScl.RoutedReportTargets.Contains(target, StringComparer.Ordinal) ? "ROUTED" : "PENDING")}]");
}

foreach (var difference in parity.Differences.Take(12))
Expand Down
23 changes: 23 additions & 0 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ private sealed class DeviceSession
public HybridReportPhysicalValidationTracker HybridValidation { get; } = new();
public StaticDataSetReportProjectionAccumulator StaticReportProjection { get; } = new();
public bool StaticDataSetReportOnly { get; set; }
public Guid CurrentStaticParityAttemptId { get; set; }
}

private readonly ConcurrentDictionary<string, DeviceSession> _sessions = new(StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -1017,6 +1018,7 @@ private async Task<IReadOnlyList<ReportControlPlan>> BuildReportPlansForCurrentA
IReadOnlyList<ReportControlPlan> legacyPlans,
CancellationToken cancellationToken)
{
session.CurrentStaticParityAttemptId = Guid.Empty;
if (session.Client.CanUseHybridReportPlanner(session.Device))
{
NativeHybridReportPlanningResult hybrid;
Expand Down Expand Up @@ -1051,6 +1053,9 @@ private async Task<IReadOnlyList<ReportControlPlan>> BuildReportPlansForCurrentA
if (session.StaticDataSetReportOnly)
{
var parity = StaticAcquisitionParityTracker.Record(session.Device, hybrid);
var ingressEvidence = session.Device.SclWorkspace?.DesignModel is not null
? parity.OpenScl : parity.Discovery;
session.CurrentStaticParityAttemptId = ingressEvidence?.PlanningAttemptId ?? Guid.Empty;
Log(
parity.Status is StaticAcquisitionParityStatus.Mismatch or StaticAcquisitionParityStatus.InsufficientEvidence ? "WARN" : "INFO",
session.Device.Name,
Expand Down Expand Up @@ -1272,6 +1277,20 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT
: string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report traffic + MMS verification" : $"Live / report traffic + MMS verification ({update.ProjectionStatus})",
trustReportEdge: true,
hasProcessValue: update.HasValue);

// A routed, schema-safe process value is the data-plane proof. RptEna,
// GI, raw/unrouted frames, rejected values, or stale associations
// cannot satisfy exact per-RCB ingress qualification.
if (session.StaticDataSetReportOnly && update.HasValue &&
StaticAcquisitionParityTracker.TryRecordRoutedReport(
session.Device, plan, session.CurrentStaticParityAttemptId,
out var updatedParity))
{
Log("INFO", session.Device.Name,
$"Static RCB routed traffic proven for {plan.ReportControlReference} / {plan.DataSetReference}. " +
$"{updatedParity.TrafficQualificationSummary}. Semantic parity: {updatedParity.Summary}. " +
"No cyclic MMS process polling was introduced.");
}
}
}

Expand Down Expand Up @@ -1942,6 +1961,7 @@ private async Task DisposeClientForReconnectAsync(

private static void ResetAssociationReportEvidence(DeviceSession session)
{
session.CurrentStaticParityAttemptId = Guid.Empty;
// InformationReport proof is association-scoped. A report observed on the old
// socket must never make the replacement association look TrafficProven before
// the first routed report of the new generation arrives.
Expand Down Expand Up @@ -1990,6 +2010,9 @@ private void RecordSuccessfulIo(DeviceSession session)

private void MarkSessionOffline(DeviceSession session, string detail)
{
// Retain timestamped historical ingress proof but do not let the old socket
// credit a future planning attempt after transport loss.
session.CurrentStaticParityAttemptId = Guid.Empty;
var wasConnected = session.Device.IsConnected;
session.Device.IsConnected = false;
session.Device.HasReportStream = false;
Expand Down
60 changes: 57 additions & 3 deletions Services/StaticAcquisitionParityTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,58 @@ public static StaticAcquisitionParitySnapshot Record(
return updated;
}

/// <summary>
/// Credits actual schema-safe routed process report traffic to one exact planned RCB,
/// and only to the planning attempt that owns this association. A previous association,
/// a sibling RCB or a different ingress must not satisfy this proof.
/// No MMS calls, RCB writes or fallback behaviors originate here.
/// </summary>
internal static bool TryRecordRoutedReport(
Iec61850MonitorDevice device,
ReportControlPlan plan,
Guid expectedPlanningAttemptId,
out StaticAcquisitionParitySnapshot updated)
{
ArgumentNullException.ThrowIfNull(device);
ArgumentNullException.ThrowIfNull(plan);

var previous = device.StaticAcquisitionParity;
updated = previous;
if (expectedPlanningAttemptId == Guid.Empty ||
!plan.IsEngineAuthoritative ||
!plan.EngineAcquisitionKind.StartsWith("Static", StringComparison.OrdinalIgnoreCase))
return false;

var evidence = previous.Discovery?.PlanningAttemptId == expectedPlanningAttemptId
? previous.Discovery
: previous.OpenScl?.PlanningAttemptId == expectedPlanningAttemptId
? previous.OpenScl
: null;

if (evidence is null || evidence.Ingress != ResolveIngress(device))
return false;

var target = BuildRuntimeTarget(plan);
if (!evidence.RuntimeTargets.Contains(target, StringComparer.Ordinal) ||
evidence.RoutedReportTargets.Contains(target, StringComparer.Ordinal))
return false;

var routed = evidence.RoutedReportTargets.Append(target)
.OrderBy(item => item, StringComparer.Ordinal)
.ToArray();
var credited = evidence with
{
RoutedReportTargets = routed,
FirstRoutedReportAtUtc = evidence.FirstRoutedReportAtUtc ?? DateTimeOffset.UtcNow
};

updated = Compare(
evidence.Ingress == StaticAcquisitionIngressKind.LiveDiscovery ? credited : previous.Discovery,
evidence.Ingress == StaticAcquisitionIngressKind.OpenScl ? credited : previous.OpenScl);
device.StaticAcquisitionParity = updated;
return true;
}

internal static StaticAcquisitionIngressEvidence BuildEvidence(
Iec61850MonitorDevice device,
NativeHybridReportPlanningResult planning,
Expand Down Expand Up @@ -114,9 +166,7 @@ internal static StaticAcquisitionIngressEvidence BuildEvidence(
$"{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}");
runtimeTargets.Add(BuildRuntimeTarget(plan));
}

var canonicalText = string.Join("\n", semanticLines);
Expand Down Expand Up @@ -207,6 +257,10 @@ internal static StaticAcquisitionParitySnapshot Compare(
};
}

private static string BuildRuntimeTarget(ReportControlPlan plan)
=> $"{NormalizeKind(plan.EngineAcquisitionKind)}|dataset={NormalizeReference(plan.DataSetReference)}|" +
$"rcb={NormalizeReference(plan.ReportControlReference)}|bindings={plan.Bindings.Count}";

private static StaticAcquisitionIngressKind ResolveIngress(Iec61850MonitorDevice device)
{
if (device.SclWorkspace?.DesignModel is not null)
Expand Down
103 changes: 103 additions & 0 deletions Services/StaticDataSetInitialImageDiagnostic.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using ArIED61850Tester.Models;

namespace ArIED61850Tester.Services;

/// <summary>
/// Read-only inspection of already displayed Static DataSet points. Unlike RCB traffic
/// qualification, this is UI image completeness, not protocol/quality authority.
/// Does not read MMS, synthesize process values, or change report subscriptions.
/// </summary>
internal static class StaticDataSetInitialImageDiagnostic
{
internal sealed record Group(
string DataSetReference,
string RcbReference,
int Selected,
int ValueVisible,
int ValuePending,
int QualityNotSupplied);

internal sealed record PendingPoint(
string Reference,
string DataSetReference,
string RcbReference,
string Status,
string Reason);

internal sealed record Snapshot(
int Selected,
int ValueVisible,
int ValuePending,
int QualityNotSupplied,
int QuestionableQuality,
IReadOnlyList<Group> Groups,
IReadOnlyList<PendingPoint> PendingPoints)
{
public string Summary =>
$"displayed={ValueVisible}/{Selected}, valuePending={ValuePending}, " +
$"qualityNotSupplied={QualityNotSupplied}, questionableQuality={QuestionableQuality}";
}

internal static Snapshot Evaluate(IEnumerable<Iec61850MonitorPoint> points)
{
ArgumentNullException.ThrowIfNull(points);
// Detach a bounded in-memory snapshot. "Ready" means a process value is
// visible; it does NOT imply q=Good, an InformationReport or physical parity.
var rows = points
.Where(point => !string.IsNullOrWhiteSpace(point.DataSetReference))
.ToArray();
var visible = rows.Count(point => HasVisibleValue(point.Value));
var qualityMissing = rows.Count(point => !HasReportedQuality(point.Quality));
var questionable = rows.Count(point =>
(point.Quality ?? string.Empty).Contains("questionable", StringComparison.OrdinalIgnoreCase));
var groups = rows
.GroupBy(point => new
{
DataSet = Normalize(point.DataSetReference),
Rcb = Normalize(point.ReportControlReference)
})
.OrderBy(group => group.Key.DataSet, StringComparer.Ordinal)
.ThenBy(group => group.Key.Rcb, StringComparer.Ordinal)
.Select(group => new Group(
group.Key.DataSet,
group.Key.Rcb,
group.Count(),
group.Count(point => HasVisibleValue(point.Value)),
group.Count(point => !HasVisibleValue(point.Value)),
group.Count(point => !HasReportedQuality(point.Quality))))
.ToArray();
var pending = rows
.Where(point => !HasVisibleValue(point.Value))
.OrderBy(point => point.IecReference, StringComparer.OrdinalIgnoreCase)
.Select(point => new PendingPoint(
point.IecReference,
point.DataSetReference,
point.ReportControlReference,
point.Status,
point.Reason))
.ToArray();

return new Snapshot(rows.Length, visible, rows.Length - visible,
qualityMissing, questionable, groups, pending);
}

internal static bool HasVisibleValue(string? value)
{
var text = (value ?? string.Empty).Trim();
return text.Length > 0 && text != "-" &&
!text.Equals("Unknown", StringComparison.OrdinalIgnoreCase) &&
!text.Equals("Pending", StringComparison.OrdinalIgnoreCase);
}

internal static bool HasReportedQuality(string? value)
{
var text = (value ?? string.Empty).Trim();
return text.Length > 0 && text != "-" &&
!text.Equals("Unknown", StringComparison.OrdinalIgnoreCase) &&
!text.StartsWith("Pending", StringComparison.OrdinalIgnoreCase);
}

private static string Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? "(not assigned)" :
value.Trim().Replace('$', '.');
}
Loading