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
14 changes: 14 additions & 0 deletions Models/MonitorModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ public sealed class Iec61850MonitorDevice : ObservableObject
private string _sclAccessPointName = string.Empty;
private string _sclEndpointOrigin = "Unbound";
private Iec61850ReportContinuitySnapshot? _reportContinuityEvidence;
private IReadOnlyList<Iec61850RcbActivationEvidence> _rcbActivationEvidence =
Array.Empty<Iec61850RcbActivationEvidence>();

public string DeviceId { get; set; } = Guid.NewGuid().ToString("N");
public BulkObservableCollection<SignalDefinition> Signals { get; } = new();
Expand Down Expand Up @@ -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<Iec61850RcbActivationEvidence> RcbActivationEvidence
{
get => System.Threading.Volatile.Read(ref _rcbActivationEvidence);
set => System.Threading.Volatile.Write(
ref _rcbActivationEvidence,
value ?? Array.Empty<Iec61850RcbActivationEvidence>());
}

public SclIedWorkspace? SclWorkspace
{
get => _sclWorkspace;
Expand Down
15 changes: 15 additions & 0 deletions Models/NativeReportMonitorModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ public sealed class NativeReportMonitorStartResult
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}

/// <summary>
/// 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.
/// </summary>
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;
Expand Down
12 changes: 8 additions & 4 deletions Services/DiagnosticReportBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ public static async Task<string> 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}");
Expand Down Expand Up @@ -338,16 +339,19 @@ private static void AppendStaticInitialImage(
internal static void AppendStaticRcbImageTriage(
StringBuilder builder,
IEnumerable<Iec61850MonitorPoint> points,
Iec61850ReportContinuitySnapshot? continuity)
Iec61850ReportContinuitySnapshot? continuity,
IReadOnlyList<Iec61850RcbActivationEvidence>? 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")}");
Expand Down
35 changes: 35 additions & 0 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,7 @@
session.ContinuityProcessUpdatesSeen = 0;
session.ContinuityUntrackedStreams = 0;
session.Device.ReportContinuityEvidence = null;
session.Device.RcbActivationEvidence = Array.Empty<Iec61850RcbActivationEvidence>();
session.LastUnroutedReportCount = 0;
session.ReportPlanCursor = 0;
session.PendingReportPlans = Array.Empty<ReportControlPlan>();
Expand Down Expand Up @@ -1623,6 +1624,10 @@
{
var pending = new Queue<ReportControlPlan>(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<string, Iec61850RcbActivationEvidence>(
StringComparer.OrdinalIgnoreCase);

while (pending.Count > 0)
{
Expand All @@ -1638,6 +1643,22 @@
? 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,
Expand Down Expand Up @@ -1761,6 +1782,18 @@
}
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}"
Expand Down Expand Up @@ -2063,7 +2096,7 @@
{
if (update.HasValue &&
IedBrowserReportValueAuthority.SafeScalar(update.Value) &&
!LooksLikeReferenceEcho(update.Value, update.Reference, update.Reference))

Check warning on line 2099 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 2099 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 2099 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.
session.Device.LiveValues.PublishForAssociation(
session.BrowserObservationAssociation,
update.Reference, reportFc, update.Value,
Expand Down Expand Up @@ -3185,6 +3218,7 @@
session.ContinuityProcessUpdatesSeen = 0;
session.ContinuityUntrackedStreams = 0;
session.Device.ReportContinuityEvidence = null;
session.Device.RcbActivationEvidence = Array.Empty<Iec61850RcbActivationEvidence>();
session.LastUnroutedReportCount = 0;
session.StaticReadJournalCursor = session.Device.LiveValues.Version;
session.StaticReadResyncIndex = -1;
Expand Down Expand Up @@ -3520,6 +3554,7 @@
session.ContinuityProcessUpdatesSeen = 0;
session.ContinuityUntrackedStreams = 0;
session.Device.ReportContinuityEvidence = null;
session.Device.RcbActivationEvidence = Array.Empty<Iec61850RcbActivationEvidence>();
session.LastUnroutedReportCount = 0;
session.ReportPlanCursor = 0;
session.PendingReportPlans = Array.Empty<ReportControlPlan>();
Expand Down
40 changes: 37 additions & 3 deletions Services/StaticDataSetRcbImageTriage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -31,7 +45,8 @@ internal sealed record Row(
internal static IReadOnlyList<Row> Evaluate(
IEnumerable<Iec61850MonitorPoint> points,
Iec61850ReportContinuitySnapshot? continuity,
int maxGroups = 16)
int maxGroups = 16,
IReadOnlyList<Iec61850RcbActivationEvidence>? activations = null)
{
ArgumentNullException.ThrowIfNull(points);
if (maxGroups is < 1 or > 64)
Expand All @@ -44,11 +59,30 @@ internal static IReadOnlyList<Row> 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();
}
}
93 changes: 93 additions & 0 deletions tests/ARSAS.Tests/StaticDataSetRcbImageTriageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading