diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 4d9b1ee3b..124373e6f 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -51,6 +51,8 @@ public sealed class Iec61850MonitorDevice : ObservableObject private string _sclAccessPointName = string.Empty; private string _sclEndpointOrigin = "Unbound"; private Iec61850ReportContinuitySnapshot? _reportContinuityEvidence; + private IReadOnlyList _rcbActivationEvidence = + Array.Empty(); public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); public BulkObservableCollection Signals { get; } = new(); @@ -107,6 +109,18 @@ public Iec61850ReportContinuitySnapshot? ReportContinuityEvidence set => System.Threading.Volatile.Write(ref _reportContinuityEvidence, value); } + // Published once per completed RCB activation attempt, not per Report + // frame. The immutable array belongs to this MMS association only. + // Reading diagnostics never locks or drives the native receive loop. + [JsonIgnore] + public IReadOnlyList RcbActivationEvidence + { + get => System.Threading.Volatile.Read(ref _rcbActivationEvidence); + set => System.Threading.Volatile.Write( + ref _rcbActivationEvidence, + value ?? Array.Empty()); + } + public SclIedWorkspace? SclWorkspace { get => _sclWorkspace; diff --git a/Models/NativeReportMonitorModels.cs b/Models/NativeReportMonitorModels.cs index 30a8c981c..882baa5db 100644 --- a/Models/NativeReportMonitorModels.cs +++ b/Models/NativeReportMonitorModels.cs @@ -32,6 +32,21 @@ public sealed class NativeReportMonitorStartResult public IReadOnlyList Warnings { get; init; } = Array.Empty(); } +/// +/// Immutable per-association RCB activation result for the operator's diagnostics. +/// These flags are ARIEC write-step outcomes, not RCB readback, Report traffic, +/// or certification that the IED delivered every configured DataSet member. +/// +public sealed record Iec61850RcbActivationEvidence +{ + public string Rcb { get; init; } = string.Empty; + public string DataSet { get; init; } = string.Empty; + public bool SetupSucceeded { get; init; } + public bool? RptEnaWriteAccepted { get; init; } + public bool? GiWriteAccepted { get; init; } + public DateTimeOffset? ActivationReturnedAtUtc { get; init; } +} + public sealed class NativeReportMonitorSliceResult { public string PlanId { get; init; } = string.Empty; diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 886de6c23..b0c12e716 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -187,7 +187,8 @@ public static async Task BuildAsync( if (device.IsMonitoring && Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device)) { AppendStaticInitialImage(builder, device.Points); - AppendStaticRcbImageTriage(builder, device.Points, device.ReportContinuityEvidence); + AppendStaticRcbImageTriage(builder, device.Points, + device.ReportContinuityEvidence, device.RcbActivationEvidence); } builder.AppendLine($"Saved model : {device.HasDiscoveryCache} ({device.SignalCount:N0} signal(s))"); builder.AppendLine($"Selected : live={device.SelectedLiveSignalCount:N0}, control={device.SelectedControlSignalCount:N0}"); @@ -338,16 +339,19 @@ private static void AppendStaticInitialImage( internal static void AppendStaticRcbImageTriage( StringBuilder builder, IEnumerable points, - Iec61850ReportContinuitySnapshot? continuity) + Iec61850ReportContinuitySnapshot? continuity, + IReadOnlyList? activations = null) { ArgumentNullException.ThrowIfNull(builder); - var groups = StaticDataSetRcbImageTriage.Evaluate(points, continuity, maxGroups: 16); + var groups = StaticDataSetRcbImageTriage.Evaluate( + points, continuity, maxGroups: 16, activations: activations); builder.AppendLine($"Static RCB traffic : shown={groups.Count}, " + $"routed={groups.Count(group => group.HasActualReportFrames)}, " + $"noRoutedFrames={groups.Count(group => !group.HasActualReportFrames)}"); builder.AppendLine(" Attribution : one exact DataSet/RCB per row; zero routed frames " + "is not proof of IED silence, GI rejection or MMS connection failure. " + - "Missing activation proof stays unverified."); + "Write-step outcome, first routed Report, and value visibility are separate proofs; " + + "missing setup proof stays unverified."); foreach (var group in groups) builder.AppendLine($" RCB evidence : {group.Summary}, " + $"ingress={(group.HasActualReportFrames ? "ROUTED" : "NO ROUTED FRAMES")}"); diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 0f783af40..4888f59eb 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -1016,6 +1016,7 @@ public async Task> StartMonitoringAsync( session.ContinuityProcessUpdatesSeen = 0; session.ContinuityUntrackedStreams = 0; session.Device.ReportContinuityEvidence = null; + session.Device.RcbActivationEvidence = Array.Empty(); session.LastUnroutedReportCount = 0; session.ReportPlanCursor = 0; session.PendingReportPlans = Array.Empty(); @@ -1623,6 +1624,10 @@ private async Task StartReportPlansAsync( { var pending = new Queue(plans); var queuedPlanIds = plans.Select(plan => plan.PlanId).ToHashSet(StringComparer.OrdinalIgnoreCase); + // At most 64 immutable activation records per association. Publish + // only when a setup attempt completes; never allocate per Report. + var activationEvidence = new Dictionary( + StringComparer.OrdinalIgnoreCase); while (pending.Count > 0) { @@ -1638,6 +1643,22 @@ private async Task StartReportPlansAsync( ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false); session.HybridValidation.RecordActivation(plan, result); + if (session.StaticDataSetReportOnly && + (activationEvidence.Count < 64 || activationEvidence.ContainsKey(plan.PlanId))) + { + activationEvidence[plan.PlanId] = new Iec61850RcbActivationEvidence + { + Rcb = string.IsNullOrWhiteSpace(result.ReportControlReference) + ? plan.ReportControlReference : result.ReportControlReference, + DataSet = string.IsNullOrWhiteSpace(result.DataSetReference) + ? plan.DataSetReference : result.DataSetReference, + SetupSucceeded = result.IsSuccess, + RptEnaWriteAccepted = result.RptEnaWriteAccepted, + GiWriteAccepted = result.GeneralInterrogationWriteAccepted, + ActivationReturnedAtUtc = result.ActivationReturnedAtUtc + }; + session.Device.RcbActivationEvidence = activationEvidence.Values.ToArray(); + } if (!result.IsSuccess) { Log("WARN", session.Device.Name, @@ -1761,6 +1782,18 @@ private async Task StartReportPlansAsync( } catch (Exception ex) when (ex is not OperationCanceledException) { + if (session.StaticDataSetReportOnly && + !activationEvidence.ContainsKey(plan.PlanId) && + activationEvidence.Count < 64) + { + activationEvidence[plan.PlanId] = new Iec61850RcbActivationEvidence + { + Rcb = plan.ReportControlReference, + DataSet = plan.DataSetReference, + SetupSucceeded = false + }; + session.Device.RcbActivationEvidence = activationEvidence.Values.ToArray(); + } Log("WARN", session.Device.Name, session.StaticDataSetReportOnly ? $"Static DataSet report setup failed for {plan.DisplayReference}; MMS process fallback is disabled. {ex.GetType().Name}: {ex.Message}" @@ -3185,6 +3218,7 @@ await DisposeClientForReconnectAsync( session.ContinuityProcessUpdatesSeen = 0; session.ContinuityUntrackedStreams = 0; session.Device.ReportContinuityEvidence = null; + session.Device.RcbActivationEvidence = Array.Empty(); session.LastUnroutedReportCount = 0; session.StaticReadJournalCursor = session.Device.LiveValues.Version; session.StaticReadResyncIndex = -1; @@ -3520,6 +3554,7 @@ private async Task StopMonitoringSessionAsync(DeviceSession session, bool preser session.ContinuityProcessUpdatesSeen = 0; session.ContinuityUntrackedStreams = 0; session.Device.ReportContinuityEvidence = null; + session.Device.RcbActivationEvidence = Array.Empty(); session.LastUnroutedReportCount = 0; session.ReportPlanCursor = 0; session.PendingReportPlans = Array.Empty(); diff --git a/Services/StaticDataSetRcbImageTriage.cs b/Services/StaticDataSetRcbImageTriage.cs index d495ccfb8..1dd164263 100644 --- a/Services/StaticDataSetRcbImageTriage.cs +++ b/Services/StaticDataSetRcbImageTriage.cs @@ -14,12 +14,26 @@ internal sealed record Row( int QualityNotSupplied, long Frames, bool? RptEnaWriteAccepted, bool? GiWriteAccepted, long Overflow) { + internal bool? SetupSucceeded { get; init; } + internal bool? SetupRptEnaWriteAccepted { get; init; } + internal bool? SetupGiWriteAccepted { get; init; } + internal DateTimeOffset? SetupReturnedAtUtc { get; init; } internal bool HasActualReportFrames => Frames > 0; internal string Summary => $"{DataSet} / {Rcb}: initial image {Visible}/{Selected}, pending={Pending}, " + $"q-unverified={QualityNotSupplied}, actualFrames={Frames}, " + $"RptEnaWrite={Proof(RptEnaWriteAccepted)}, " + - $"GiWrite={Proof(GiWriteAccepted)}, overflowObserved={Overflow}"; + $"GiWrite={Proof(GiWriteAccepted)}, overflowObserved={Overflow}; " + + $"activationCall={CallStatus(SetupSucceeded)}, " + + $"setupRptEnaWrite={Proof(SetupRptEnaWriteAccepted)}, " + + $"setupGiWrite={Proof(SetupGiWriteAccepted)}, " + + $"activationReturnedUtc={SetupReturnedAtUtc?.ToString("O") ?? "unknown"}"; + private static string CallStatus(bool? success) => success switch + { + true => "success", + false => "failed", + _ => "unknown" + }; private static string Proof(bool? value) => value switch { true => "accepted", @@ -31,7 +45,8 @@ internal sealed record Row( internal static IReadOnlyList Evaluate( IEnumerable points, Iec61850ReportContinuitySnapshot? continuity, - int maxGroups = 16) + int maxGroups = 16, + IReadOnlyList? activations = null) { ArgumentNullException.ThrowIfNull(points); if (maxGroups is < 1 or > 64) @@ -44,11 +59,30 @@ internal static IReadOnlyList Evaluate( { var stream = IedBrowserRcbReportEvidence.Select( continuity, group.RcbReference, group.DataSetReference, ""); + // Exact RCB AND DataSet match. An activation for another plan, + // even one that shares selected process values, cannot be borrowed. + // The small bounded snapshot is read only on operator diagnosis, + // never on the MMS receive hot path. + var exact = activations?.Where(a => + string.Equals(IedBrowserRcbReportEvidence.Canonical(a.Rcb), + IedBrowserRcbReportEvidence.Canonical(group.RcbReference), + StringComparison.OrdinalIgnoreCase) && + string.Equals(IedBrowserRcbReportEvidence.Canonical(a.DataSet), + IedBrowserRcbReportEvidence.Canonical(group.DataSetReference), + StringComparison.OrdinalIgnoreCase)) + .Take(2).ToArray(); + var activation = exact is { Length: 1 } ? exact[0] : null; return new Row( group.DataSetReference, group.RcbReference, group.Selected, group.ValueVisible, group.ValuePending, group.QualityNotSupplied, stream?.Frames ?? 0L, stream?.RptEnaWriteAccepted, - stream?.GiWriteAccepted, stream?.Overflow ?? 0L); + stream?.GiWriteAccepted, stream?.Overflow ?? 0L) + { + SetupSucceeded = activation?.SetupSucceeded, + SetupRptEnaWriteAccepted = activation?.RptEnaWriteAccepted, + SetupGiWriteAccepted = activation?.GiWriteAccepted, + SetupReturnedAtUtc = activation?.ActivationReturnedAtUtc + }; }).ToArray(); } } diff --git a/tests/ARSAS.Tests/StaticDataSetRcbImageTriageTests.cs b/tests/ARSAS.Tests/StaticDataSetRcbImageTriageTests.cs index b79c21936..ac07e58a8 100644 --- a/tests/ARSAS.Tests/StaticDataSetRcbImageTriageTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetRcbImageTriageTests.cs @@ -58,6 +58,99 @@ public void OneActiveRcbNeverClaimsFramesGiOrImageForAnotherRcb() Assert.Contains("GiWrite=unverified", i.Summary); } + [Fact] + public void SilentSg7RcbRetainsExactNativeWriteResultWithoutPretendingItReceivedAReport() + { + const string datasetA = "SG7_BCUMaster/LLN0.TT6DataSet3"; + const string datasetI = "SG7_BCUMaster/LLN0.TT6DataSet4"; + const string rcbA = "SG7_BCUMaster/LLN0.BR.BrcbA01"; + const string rcbI = "SG7_BCUMaster/LLN0.BR.BrcbI01"; + var points = new[] + { + Point("A.phsA.cVal.mag.f", datasetA, rcbA, "120"), + Point("A.phsB.cVal.mag.f", datasetI, rcbI) + }; + var traffic = new Iec61850ReportContinuitySnapshot + { + Streams = new[] + { + new Iec61850ReportContinuityStreamSnapshot + { + Rcb = rcbA, DataSet = datasetA, Frames = 1, + LastFrameReceivedAtUtc = DateTimeOffset.UtcNow, + RptEnaWriteAccepted = true, GiWriteAccepted = true + } + } + }; + var activation = new[] + { + new Iec61850RcbActivationEvidence + { + Rcb = rcbA, DataSet = datasetA, + SetupSucceeded = true, RptEnaWriteAccepted = true, GiWriteAccepted = true + }, + new Iec61850RcbActivationEvidence + { + Rcb = rcbI.Replace('.', '$'), DataSet = datasetI.Replace('.', '$'), + SetupSucceeded = true, RptEnaWriteAccepted = true, GiWriteAccepted = false, + ActivationReturnedAtUtc = DateTimeOffset.UtcNow + } + }; + var rows = StaticDataSetRcbImageTriage.Evaluate( + points, traffic, activations: activation); + var active = Assert.Single(rows, row => row.Rcb == rcbA); + var silent = Assert.Single(rows, row => row.Rcb == rcbI); + + Assert.Equal(1, active.Frames); + Assert.Equal(0, silent.Frames); + Assert.False(silent.HasActualReportFrames); + Assert.Equal(1, silent.Pending); + Assert.True(silent.SetupSucceeded); + Assert.True(silent.SetupRptEnaWriteAccepted); + Assert.False(silent.SetupGiWriteAccepted); + Assert.Null(silent.RptEnaWriteAccepted); // 0-frame continuity is not receipt proof. + Assert.Contains("activationCall=success", silent.Summary); + Assert.Contains("setupRptEnaWrite=accepted", silent.Summary); + Assert.Contains("setupGiWrite=rejected", silent.Summary); + + var log = new System.Text.StringBuilder(); + DiagnosticReportBuilder.AppendStaticRcbImageTriage(log, points, traffic, activation); + Assert.Contains("routed=1, noRoutedFrames=1", log.ToString()); + Assert.Contains("actualFrames=0", log.ToString()); + Assert.Contains("setupGiWrite=rejected", log.ToString()); + } + + [Fact] + public void WrongDatasetAmbiguousSetupAndAbsentAssociationCannotDonateWriteProof() + { + const string rcb = "IED/LLN0.BR.BrcbI01"; + const string ds = "IED/LLN0.TT6DataSet4"; + var point = Point("Hz.mag.f", ds, rcb); + var wrong = new Iec61850RcbActivationEvidence + { + Rcb = rcb, DataSet = "IED/LLN0.OtherDataSet", + SetupSucceeded = true, RptEnaWriteAccepted = true, GiWriteAccepted = true + }; + var row = Assert.Single(StaticDataSetRcbImageTriage.Evaluate( + [point], null, activations: [wrong])); + Assert.Null(row.SetupSucceeded); + Assert.Null(row.SetupRptEnaWriteAccepted); + Assert.Null(row.SetupGiWriteAccepted); + Assert.Contains("activationCall=unknown", row.Summary); + + var first = new Iec61850RcbActivationEvidence + { + Rcb = rcb, DataSet = ds, SetupSucceeded = true, GiWriteAccepted = true + }; + var second = first with { GiWriteAccepted = false }; + row = Assert.Single(StaticDataSetRcbImageTriage.Evaluate( + [point], null, activations: [first, second])); + Assert.Null(row.SetupGiWriteAccepted); // Ambiguous same-key evidence is rejected. + + row = Assert.Single(StaticDataSetRcbImageTriage.Evaluate([point], null)); + Assert.Null(row.SetupSucceeded); // Association reset -> empty proof. + } + [Fact] public void WrongDataSetOrAmbiguousReportStreamsCannotSupplyProof() {