diff --git a/Models/StaticAcquisitionParityModels.cs b/Models/StaticAcquisitionParityModels.cs index b1ab99539..7ca6b2cb2 100644 --- a/Models/StaticAcquisitionParityModels.cs +++ b/Models/StaticAcquisitionParityModels.cs @@ -24,9 +24,11 @@ public enum StaticAcquisitionParityStatus /// Rpt01 to Rpt02 across associations while the configured DataSet/report semantics remain /// equivalent. /// -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; @@ -37,6 +39,13 @@ public sealed class StaticAcquisitionIngressEvidence public int UncoveredSignalCount { get; init; } public IReadOnlyList SemanticLines { get; init; } = Array.Empty(); public IReadOnlyList RuntimeTargets { get; init; } = Array.Empty(); + public IReadOnlyList RoutedReportTargets { get; init; } = Array.Empty(); + 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; @@ -53,6 +62,15 @@ public sealed class StaticAcquisitionParitySnapshot public StaticAcquisitionParityStatus Status { get; init; } = StaticAcquisitionParityStatus.NotAvailable; public IReadOnlyList Differences { get; init; } = Array.Empty(); + // 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 => diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index ae7c2ec19..08ae50e73 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -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."); @@ -137,6 +137,8 @@ public static async Task 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)}"); @@ -220,24 +222,43 @@ public static async Task BuildAsync( } + private static void AppendStaticInitialImage( + StringBuilder builder, + IEnumerable 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)) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 915808183..801d3b0d5 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -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 _sessions = new(StringComparer.OrdinalIgnoreCase); @@ -1017,6 +1018,7 @@ private async Task> BuildReportPlansForCurrentA IReadOnlyList legacyPlans, CancellationToken cancellationToken) { + session.CurrentStaticParityAttemptId = Guid.Empty; if (session.Client.CanUseHybridReportPlanner(session.Device)) { NativeHybridReportPlanningResult hybrid; @@ -1051,6 +1053,9 @@ private async Task> 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, @@ -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."); + } } } @@ -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. @@ -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; diff --git a/Services/StaticAcquisitionParityTracker.cs b/Services/StaticAcquisitionParityTracker.cs index dfe0f7bd3..e07855994 100644 --- a/Services/StaticAcquisitionParityTracker.cs +++ b/Services/StaticAcquisitionParityTracker.cs @@ -38,6 +38,58 @@ public static StaticAcquisitionParitySnapshot Record( return updated; } + /// + /// 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. + /// + 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, @@ -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); @@ -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) diff --git a/Services/StaticDataSetInitialImageDiagnostic.cs b/Services/StaticDataSetInitialImageDiagnostic.cs new file mode 100644 index 000000000..4c45fd90b --- /dev/null +++ b/Services/StaticDataSetInitialImageDiagnostic.cs @@ -0,0 +1,103 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// 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. +/// +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 Groups, + IReadOnlyList PendingPoints) + { + public string Summary => + $"displayed={ValueVisible}/{Selected}, valuePending={ValuePending}, " + + $"qualityNotSupplied={QualityNotSupplied}, questionableQuality={QuestionableQuality}"; + } + + internal static Snapshot Evaluate(IEnumerable 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('$', '.'); +} diff --git a/tests/ARSAS.Tests/StaticAcquisitionCausalTrafficTests.cs b/tests/ARSAS.Tests/StaticAcquisitionCausalTrafficTests.cs new file mode 100644 index 000000000..b31082a28 --- /dev/null +++ b/tests/ARSAS.Tests/StaticAcquisitionCausalTrafficTests.cs @@ -0,0 +1,209 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class StaticAcquisitionCausalTrafficTests +{ + [Fact] + public void SemanticMatchAlone_IsNotDualIngressTrafficProof() + { + var device = DeviceWithTwoDataSets(); + var discoveryPlans = Plans("Rpt_ind01", "Rpt_meas01"); + var discovery = StaticAcquisitionParityTracker.Record(device, discoveryPlans); + Assert.Equal(StaticAcquisitionParityStatus.AwaitingOtherIngress, discovery.Status); + Assert.False(discovery.DualIngressTrafficProven); + var discoveryAttempt = discovery.Discovery!.PlanningAttemptId; + + // Each exact RCB must provide a routed, schema-safe process update. + Assert.True(StaticAcquisitionParityTracker.TryRecordRoutedReport( + device, discoveryPlans.ReportPlans[0], discoveryAttempt, out var first)); + Assert.Equal(1, first.Discovery!.RoutedTargetCount); + Assert.False(first.Discovery.AllPlannedTargetsRouted); + Assert.False(first.DualIngressTrafficProven); + Assert.True(StaticAcquisitionParityTracker.TryRecordRoutedReport( + device, discoveryPlans.ReportPlans[1], discoveryAttempt, out var bothDiscovery)); + Assert.True(bothDiscovery.Discovery!.AllPlannedTargetsRouted); + + device.SclWorkspace = new SclIedWorkspace + { + IedName = device.Name, + AccessPointName = "AP1", + DesignModel = device.LiveDiscoveryModel! + }; + var sclPlans = Plans("Rpt_ind02", "Rpt_meas01"); + var matched = StaticAcquisitionParityTracker.Record(device, sclPlans); + Assert.Equal(StaticAcquisitionParityStatus.Equivalent, matched.Status); + Assert.Equal(matched.Discovery!.SemanticFingerprint, matched.OpenScl!.SemanticFingerprint); + Assert.False(matched.DualIngressTrafficProven); + Assert.Equal(0, matched.OpenScl.RoutedTargetCount); + + var sclAttempt = matched.OpenScl.PlanningAttemptId; + Assert.True(StaticAcquisitionParityTracker.TryRecordRoutedReport( + device, sclPlans.ReportPlans[0], sclAttempt, out var partiallyRouted)); + Assert.Equal(1, partiallyRouted.OpenScl!.RoutedTargetCount); + Assert.False(partiallyRouted.DualIngressTrafficProven); + Assert.True(StaticAcquisitionParityTracker.TryRecordRoutedReport( + device, sclPlans.ReportPlans[1], sclAttempt, out var complete)); + Assert.True(complete.DualIngressTrafficProven); + Assert.Contains("DUAL INGRESS TRAFFIC PROVEN", complete.TrafficQualificationSummary); + Assert.Equal(StaticAcquisitionParityStatus.Equivalent, complete.Status); + } + + [Fact] + public void StaleAssociationAndOtherRcb_CannotBorrowPreviousTrafficProof() + { + var device = DeviceWithTwoDataSets(); + var plans = Plans("Rpt_ind01", "Rpt_meas01"); + var first = StaticAcquisitionParityTracker.Record(device, plans); + var oldAttempt = first.Discovery!.PlanningAttemptId; + var notSelected = Plans("Rpt_ind99", "Rpt_meas01").ReportPlans[0]; + + Assert.False(StaticAcquisitionParityTracker.TryRecordRoutedReport( + device, notSelected, oldAttempt, out _)); + Assert.False(StaticAcquisitionParityTracker.TryRecordRoutedReport( + device, plans.ReportPlans[0], Guid.Empty, out _)); + Assert.True(StaticAcquisitionParityTracker.TryRecordRoutedReport( + device, plans.ReportPlans[0], oldAttempt, out var routed)); + Assert.False(StaticAcquisitionParityTracker.TryRecordRoutedReport( + device, plans.ReportPlans[0], oldAttempt, out _)); + Assert.Equal(1, routed.Discovery!.RoutedTargetCount); + + // A new plan on the same ingress is a replacement MMS association's attempt. + var rearmed = StaticAcquisitionParityTracker.Record(device, plans); + var newAttempt = rearmed.Discovery!.PlanningAttemptId; + Assert.NotEqual(oldAttempt, newAttempt); + Assert.Equal(0, rearmed.Discovery.RoutedTargetCount); + Assert.False(StaticAcquisitionParityTracker.TryRecordRoutedReport( + device, plans.ReportPlans[1], oldAttempt, out _)); + Assert.True(StaticAcquisitionParityTracker.TryRecordRoutedReport( + device, plans.ReportPlans[1], newAttempt, out var newReport)); + Assert.Equal(1, newReport.Discovery!.RoutedTargetCount); + Assert.False(newReport.Discovery.AllPlannedTargetsRouted); + } + + [Fact] + public void SwitchingIngress_DoesNotRecreditOldAssociation() + { + var device = DeviceWithTwoDataSets(); + var plans = Plans("Rpt_ind01", "Rpt_meas01"); + var recorded = StaticAcquisitionParityTracker.Record(device, plans); + var discoveryAttempt = recorded.Discovery!.PlanningAttemptId; + device.SclWorkspace = new SclIedWorkspace + { + IedName = device.Name, + AccessPointName = "AP1", + DesignModel = device.LiveDiscoveryModel! + }; + + Assert.False(StaticAcquisitionParityTracker.TryRecordRoutedReport( + device, plans.ReportPlans[0], discoveryAttempt, out _)); + } + + [Fact] + public void RuntimeOnlyCreditsAcceptedProcessValueAndDoesNotChangeReportingSetup() + { + var runtime = Read("Services/Iec61850MonitorRuntime.cs"); + var tracker = Read("Services/StaticAcquisitionParityTracker.cs"); + var diagnostic = Read("Services/DiagnosticReportBuilder.cs"); + + Assert.Contains("session.StaticDataSetReportOnly && update.HasValue", runtime); + Assert.Contains("StaticAcquisitionParityTracker.TryRecordRoutedReport", runtime); + Assert.Contains("CurrentStaticParityAttemptId = Guid.Empty", runtime); + Assert.Contains("trustReportEdge: true", runtime); + Assert.Contains("expectedPlanningAttemptId == Guid.Empty", tracker); + Assert.Contains("RoutedReportTargets.Contains(target", tracker); + Assert.Contains("Routed traffic :", diagnostic); + Assert.Contains("Physical qualifier: semantic MATCH is not traffic proof", diagnostic); + } + + private static Iec61850MonitorDevice DeviceWithTwoDataSets() + => new() + { + Name = "IED-A", + IpAddress = "192.0.2.10", + LiveDiscoveryModel = new LiveIedModelDiscoveryDocument + { + IedName = "IED-A", + Source = "Test", + DataSets = + [ + DataSet("IED-A/LLN0.Indications", "IED-A/XCBR1.Pos.stVal", "ST"), + DataSet("IED-A/LLN0.Measurements", "IED-A/MMXU1.A.phsA.cVal.mag.f", "MX") + ] + } + }; + + private static LiveIedDataSetModel DataSet(string reference, string point, string fc) + => new() + { + Reference = reference, + Domain = "IED-A", + LogicalNode = "LLN0", + Name = reference.Split('.').Last(), + MemberCount = 1, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = point, + FunctionalConstraint = fc + } + ] + }; + + private static NativeHybridReportPlanningResult Plans(string brcb, string urcb) + => new() + { + IsAuthoritative = true, + Authority = "ARIEC61850 canonical static acquisition", + Status = "Ready", + RequestedPointCount = 2, + CatalogMappedPointCount = 2, + StaticBrcbSignalCount = 1, + StaticUrcbSignalCount = 1, + UncoveredSignalCount = 0, + ReportPlans = + [ + Plan("StaticBrcb", "IED-A/LLN0.Indications", "IED-A/LLN0.BR." + brcb, "IED-A/XCBR1.Pos.stVal", "ST"), + Plan("StaticUrcb", "IED-A/LLN0.Measurements", "IED-A/LLN0.RP." + urcb, "IED-A/MMXU1.A.phsA.cVal.mag.f", "MX") + ] + }; + + private static ReportControlPlan Plan(string kind, string dataSet, string rcb, string point, string fc) + => new() + { + PlanId = dataSet, + IsEngineAuthoritative = true, + EngineAcquisitionKind = kind, + Buffered = kind == "StaticBrcb", + DataSetReference = dataSet, + ReportControlReference = rcb, + Bindings = + [ + new Iec61850MonitorPoint + { + DeviceId = "device-a", + IecReference = point, + FunctionalConstraint = fc + } + ] + }; + + private static string Read(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var path = Path.Combine(directory.FullName, relativePath); + if (File.Exists(path)) + return File.ReadAllText(path); + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/StaticDataSetInitialImageDiagnosticTests.cs b/tests/ARSAS.Tests/StaticDataSetInitialImageDiagnosticTests.cs new file mode 100644 index 000000000..7bfbb4d32 --- /dev/null +++ b/tests/ARSAS.Tests/StaticDataSetInitialImageDiagnosticTests.cs @@ -0,0 +1,102 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class StaticDataSetInitialImageDiagnosticTests +{ + [Fact] + public void PartialImage_SeparatesMissingValuesFromMissingQuality() + { + var image = StaticDataSetInitialImageDiagnostic.Evaluate(new[] + { + Point("IED/LLN0.Binary1.stVal", "IED/LLN0.Digital", "IED/LLN0.BR.01", "-", "Unknown"), + Point("IED/LLN0.Binary2.stVal", "IED/LLN0.Digital", "IED/LLN0.BR.01", "Open [01]", "Pending / q not supplied"), + Point("IED/MMXU1.A.phsA.cVal.mag.f", "IED/LLN0.Analog", "IED/LLN0.RP.01", "0", "questionable") + }); + + Assert.Equal(3, image.Selected); + Assert.Equal(2, image.ValueVisible); + Assert.Equal(1, image.ValuePending); + Assert.Equal(2, image.QualityNotSupplied); + Assert.Equal(1, image.QuestionableQuality); + Assert.Equal(2, image.Groups.Count); + Assert.Equal("IED/LLN0.Binary1.stVal", image.PendingPoints.Single().Reference); + Assert.Contains("displayed=2/3", image.Summary); + } + + [Fact] + public void NewReportImage_MonotonicallyResolvesPendingValueWithoutPretendingQualityIsGood() + { + var point = Point("IED/LLN0.Switch.stVal", "IED/LLN0.Digital", "IED/LLN0.BR.01", "Unknown", "Unknown"); + var before = StaticDataSetInitialImageDiagnostic.Evaluate([point]); + Assert.Equal(1, before.ValuePending); + Assert.Equal(1, before.QualityNotSupplied); + + point.Value = "Closed [10]"; + var afterValue = StaticDataSetInitialImageDiagnostic.Evaluate([point]); + Assert.Equal(0, afterValue.ValuePending); + Assert.Equal(1, afterValue.QualityNotSupplied); + + point.Quality = "questionable"; + var afterQuality = StaticDataSetInitialImageDiagnostic.Evaluate([point]); + Assert.Equal(0, afterQuality.QualityNotSupplied); + Assert.Equal(1, afterQuality.QuestionableQuality); + } + + [Fact] + public void GroupingPreservesExactDataSetAndConcreteRcbSeparation() + { + var image = StaticDataSetInitialImageDiagnostic.Evaluate(new[] + { + Point("IED/LLN0.One.stVal", "IED/LLN0$Events", "IED/LLN0$BR$Rpt01", "1", "Good"), + Point("IED/LLN0.Two.stVal", "IED/LLN0$Events", "IED/LLN0$BR$Rpt02", "-", "Unknown") + }); + Assert.Equal(2, image.Groups.Count); + Assert.Equal(1, image.ValueVisible); + Assert.Equal(1, image.ValuePending); + Assert.Contains(image.Groups, group => group.RcbReference == "IED/LLN0.BR.Rpt01" && group.ValueVisible == 1); + Assert.Contains(image.Groups, group => group.RcbReference == "IED/LLN0.BR.Rpt02" && group.ValuePending == 1); + } + + [Fact] + public void AuditIsReadOnlyAndDiagnosticDoesNotInjectMmsAcquisition() + { + var path = FindRepoFile("Services/StaticDataSetInitialImageDiagnostic.cs"); + var source = File.ReadAllText(path); + var diagnostic = File.ReadAllText(FindRepoFile("Services/DiagnosticReportBuilder.cs")); + Assert.Contains("Static initial image: ", diagnostic, StringComparison.Ordinal); + Assert.Contains("AppendStaticInitialImage(builder, device.Points)", diagnostic, StringComparison.Ordinal); + Assert.Contains("Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device)", diagnostic, StringComparison.Ordinal); + Assert.DoesNotContain("ReadAsync(", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartReport", source, StringComparison.Ordinal); + Assert.DoesNotContain("MmsClient", source, StringComparison.Ordinal); + } + + private static Iec61850MonitorPoint Point( + string reference, string dataset, string rcb, string value, string quality) + => new() + { + DeviceId = "device-a", + IecReference = reference, + DataSetReference = dataset, + ReportControlReference = rcb, + Value = value, + Quality = quality, + Status = value == "-" || value == "Unknown" ? "Awaiting first static report" : "Live" + }; + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var path = Path.Combine(directory.FullName, relativePath); + if (File.Exists(path)) + return path; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +}