diff --git a/Models/NativeHybridReportAcquisitionModels.cs b/Models/NativeHybridReportAcquisitionModels.cs new file mode 100644 index 00000000..d6d4005e --- /dev/null +++ b/Models/NativeHybridReportAcquisitionModels.cs @@ -0,0 +1,76 @@ +namespace ArIED61850Tester.Models; + +/// +/// ARSAS projection of an ARIEC-owned hybrid report acquisition plan. +/// The application never infers report capability from these counters; they only expose +/// the typed decision already returned by the engine planner. +/// +public sealed class NativeHybridReportPlanningResult +{ + public bool IsAuthoritative { get; init; } + public string Authority { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public string Summary { get; init; } = string.Empty; + public IReadOnlyList ReportPlans { get; init; } = Array.Empty(); + public IReadOnlyList PollingPointKeys { get; init; } = Array.Empty(); + public IReadOnlyList UncoveredPointKeys { get; init; } = Array.Empty(); + public IReadOnlyList UnmappedPointKeys { get; init; } = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + public int RequestedPointCount { get; init; } + public int CatalogMappedPointCount { get; init; } + public int StaticBrcbSignalCount { get; init; } + public int StaticUrcbSignalCount { get; init; } + public int DynamicBrcbSignalCount { get; init; } + public int DynamicUrcbSignalCount { get; init; } + public int PollingFallbackSignalCount { get; init; } + public int UncoveredSignalCount { get; init; } + + public bool HasReportPlans => ReportPlans.Count > 0; +} + +/// +/// Physical-validation evidence collected by ARSAS after executing an engine-authoritative +/// report plan against a real IED. Silence is never interpreted as signal absence. +/// +public sealed class HybridReportPhysicalValidationSnapshot +{ + public DateTimeOffset CapturedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public string DeviceId { get; init; } = string.Empty; + public string DeviceName { get; init; } = string.Empty; + public string Endpoint { get; init; } = string.Empty; + public int PlannedStaticBrcbCount { get; init; } + public int PlannedStaticUrcbCount { get; init; } + public int PlannedDynamicBrcbCount { get; init; } + public int PlannedDynamicUrcbCount { get; init; } + public int ActivatedReportPlanCount { get; init; } + public int FailedActivationCount { get; init; } + public int ReportFrameCount { get; init; } + public int ReportUpdateCount { get; init; } + public int ChangeVerifiedPointCount { get; init; } + public int PollingFallbackPointCount { get; init; } + public int UncoveredPointCount { get; init; } + public IReadOnlyList Plans { get; init; } = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public bool HasPhysicalReportEvidence => ReportFrameCount > 0 || ReportUpdateCount > 0; +} + +public sealed class HybridReportPhysicalValidationPlan +{ + public string PlanId { get; init; } = string.Empty; + public string AcquisitionKind { get; init; } = string.Empty; + public string ReportControlReference { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public int PlannedSignalCount { get; init; } + public bool ActivationSucceeded { get; init; } + public string ActivationMessage { get; init; } = string.Empty; + public string SubscriptionSummary { get; init; } = string.Empty; + public int MemberCount { get; init; } + public int SetupWriteStepCount { get; init; } + public bool UsedDynamicDataSet { get; init; } + public int ReportFrameCount { get; init; } + public int ReportUpdateCount { get; init; } + public int ChangeVerifiedPointCount { get; init; } + public DateTimeOffset? FirstReportAtUtc { get; init; } + public DateTimeOffset? LastReportAtUtc { get; init; } +} diff --git a/Models/ReportControlPlan.cs b/Models/ReportControlPlan.cs index 4572d3d4..22a1c73e 100644 --- a/Models/ReportControlPlan.cs +++ b/Models/ReportControlPlan.cs @@ -17,6 +17,12 @@ public sealed class ReportControlPlan public string TriggerOptions { get; set; } = string.Empty; public string OptionalFields { get; set; } = string.Empty; public string Status { get; set; } = "Planned"; + /// + /// True when the RCB/DataSet choice and exact subscription plan were emitted by + /// ARIEC61850 MmsHybridReportAcquisitionPlanner. ARSAS executes this plan as-is. + /// + public bool IsEngineAuthoritative { get; set; } + public string EngineAcquisitionKind { get; set; } = string.Empty; public List Bindings { get; set; } = new(); public int BindingCount => Bindings.Count; diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..63d6d0ef --- /dev/null +++ b/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ARSAS.Tests")] diff --git a/Services/HybridReportPhysicalValidationTracker.cs b/Services/HybridReportPhysicalValidationTracker.cs new file mode 100644 index 00000000..841e959e --- /dev/null +++ b/Services/HybridReportPhysicalValidationTracker.cs @@ -0,0 +1,166 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Collects application-side physical evidence after ARIEC has produced an authoritative +/// hybrid report plan. The tracker records only observations from actual start/slice +/// results. A lack of report traffic is intentionally not converted into signal absence. +/// +internal sealed class HybridReportPhysicalValidationTracker +{ + private sealed class PlanState + { + public required ReportControlPlan Plan { get; init; } + public bool ActivationAttempted { get; set; } + public bool ActivationSucceeded { get; set; } + public string ActivationMessage { get; set; } = "Not attempted"; + public string SubscriptionSummary { get; set; } = string.Empty; + public int MemberCount { get; set; } + public int SetupWriteStepCount { get; set; } + public bool UsedDynamicDataSet { get; set; } + public int ReportFrameCount { get; set; } + public int ReportUpdateCount { get; set; } + public HashSet ChangeVerifiedPointKeys { get; } = new(StringComparer.OrdinalIgnoreCase); + public DateTimeOffset? FirstReportAtUtc { get; set; } + public DateTimeOffset? LastReportAtUtc { get; set; } + } + + private readonly Dictionary _plans = new(StringComparer.OrdinalIgnoreCase); + private readonly List _warnings = new(); + private NativeHybridReportPlanningResult? _planning; + + public void Reset(NativeHybridReportPlanningResult? planning) + { + _planning = planning; + _plans.Clear(); + _warnings.Clear(); + + if (planning is null) + return; + + foreach (var warning in planning.Warnings) + AddWarning(warning); + + foreach (var plan in planning.ReportPlans) + _plans[plan.PlanId] = new PlanState { Plan = plan }; + } + + public void RecordActivation(ReportControlPlan plan, NativeReportMonitorStartResult result) + { + if (!plan.IsEngineAuthoritative) + return; + + if (!_plans.TryGetValue(plan.PlanId, out var state)) + { + state = new PlanState { Plan = plan }; + _plans[plan.PlanId] = state; + } + + state.ActivationAttempted = true; + state.ActivationSucceeded = result.IsSuccess; + state.ActivationMessage = result.Message; + state.SubscriptionSummary = result.SubscriptionSummary; + state.MemberCount = result.MemberCount; + state.SetupWriteStepCount = result.WriteStepCount; + state.UsedDynamicDataSet = result.UsedDynamicDataSet; + foreach (var warning in result.Warnings) + AddWarning(warning); + } + + public void RecordSlice( + ReportControlPlan plan, + NativeReportMonitorSliceResult slice, + IEnumerable? changeVerifiedPointKeys = null) + { + if (!plan.IsEngineAuthoritative || !_plans.TryGetValue(plan.PlanId, out var state)) + return; + + state.ReportFrameCount += slice.ReportFrames.Count; + state.ReportUpdateCount += slice.Updates.Count; + + var observedTimes = slice.ReportFrames + .Select(frame => frame.ReceivedAt) + .Where(value => value != default) + .OrderBy(value => value) + .ToArray(); + if (observedTimes.Length > 0) + { + state.FirstReportAtUtc ??= observedTimes[0]; + state.LastReportAtUtc = observedTimes[^1]; + } + else if (slice.ReportFrames.Count > 0 || slice.Updates.Count > 0) + { + var now = DateTimeOffset.UtcNow; + state.FirstReportAtUtc ??= now; + state.LastReportAtUtc = now; + } + + if (changeVerifiedPointKeys is not null) + { + foreach (var key in changeVerifiedPointKeys.Where(key => !string.IsNullOrWhiteSpace(key))) + state.ChangeVerifiedPointKeys.Add(key); + } + + foreach (var warning in slice.Warnings) + AddWarning(warning); + } + + public HybridReportPhysicalValidationSnapshot Capture(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + var planning = _planning; + var plans = _plans.Values + .OrderBy(state => state.Plan.EngineAcquisitionKind, StringComparer.OrdinalIgnoreCase) + .ThenBy(state => state.Plan.ReportControlReference, StringComparer.OrdinalIgnoreCase) + .Select(state => new HybridReportPhysicalValidationPlan + { + PlanId = state.Plan.PlanId, + AcquisitionKind = state.Plan.EngineAcquisitionKind, + ReportControlReference = state.Plan.ReportControlReference, + DataSetReference = state.Plan.DataSetReference, + PlannedSignalCount = state.Plan.Bindings.Count, + ActivationSucceeded = state.ActivationSucceeded, + ActivationMessage = state.ActivationMessage, + SubscriptionSummary = state.SubscriptionSummary, + MemberCount = state.MemberCount, + SetupWriteStepCount = state.SetupWriteStepCount, + UsedDynamicDataSet = state.UsedDynamicDataSet, + ReportFrameCount = state.ReportFrameCount, + ReportUpdateCount = state.ReportUpdateCount, + ChangeVerifiedPointCount = state.ChangeVerifiedPointKeys.Count, + FirstReportAtUtc = state.FirstReportAtUtc, + LastReportAtUtc = state.LastReportAtUtc + }) + .ToArray(); + + return new HybridReportPhysicalValidationSnapshot + { + CapturedAtUtc = DateTimeOffset.UtcNow, + DeviceId = device.DeviceId, + DeviceName = device.Name, + Endpoint = device.EndpointText, + PlannedStaticBrcbCount = planning?.StaticBrcbSignalCount ?? 0, + PlannedStaticUrcbCount = planning?.StaticUrcbSignalCount ?? 0, + PlannedDynamicBrcbCount = planning?.DynamicBrcbSignalCount ?? 0, + PlannedDynamicUrcbCount = planning?.DynamicUrcbSignalCount ?? 0, + ActivatedReportPlanCount = _plans.Values.Count(state => state.ActivationAttempted && state.ActivationSucceeded), + FailedActivationCount = _plans.Values.Count(state => state.ActivationAttempted && !state.ActivationSucceeded), + ReportFrameCount = plans.Sum(plan => plan.ReportFrameCount), + ReportUpdateCount = plans.Sum(plan => plan.ReportUpdateCount), + ChangeVerifiedPointCount = plans.Sum(plan => plan.ChangeVerifiedPointCount), + PollingFallbackPointCount = planning?.PollingFallbackSignalCount ?? 0, + UncoveredPointCount = planning?.UncoveredSignalCount ?? 0, + Plans = plans, + Warnings = _warnings.ToArray() + }; + } + + private void AddWarning(string? warning) + { + var text = (warning ?? string.Empty).Trim(); + if (text.Length == 0 || _warnings.Contains(text, StringComparer.OrdinalIgnoreCase)) + return; + _warnings.Add(text); + } +} diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 952d5a02..7caec5a9 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -78,6 +78,7 @@ private sealed class DeviceSession public int ConsecutiveHealthProbeFailures { get; set; } public string HealthProbePointKey { get; set; } = string.Empty; public int ControlCommandActive; + public HybridReportPhysicalValidationTracker HybridValidation { get; } = new(); } private readonly ConcurrentDictionary _sessions = new(StringComparer.OrdinalIgnoreCase); @@ -377,6 +378,7 @@ public async Task> StartMonitoringAsync( session.NextHealthProbeUtc = DateTime.UtcNow.AddSeconds(1); session.ConsecutiveHealthProbeFailures = 0; session.HealthProbePointKey = string.Empty; + session.HybridValidation.Reset(null); var safePollMs = Math.Clamp(pollingIntervalMs <= 0 ? 1000 : pollingIntervalMs, 50, 600000); foreach (var signal in selected) @@ -398,9 +400,12 @@ public async Task> StartMonitoringAsync( .Select(point => point.PointKey) .FirstOrDefault() ?? string.Empty; - var plans = Iec61850ReportPlanner.BuildPlans(device, session.Points.Values); + var hasHybridAuthority = session.Client.CanUseHybridReportPlanner(device); + var plans = hasHybridAuthority + ? Array.Empty() + : Iec61850ReportPlanner.BuildPlans(device, session.Points.Values); session.PendingReportPlans = plans; - session.ReportSetupPending = plans.Count > 0; + session.ReportSetupPending = hasHybridAuthority || plans.Count > 0; session.ReportSetupNotBeforeUtc = DateTime.UtcNow.AddMilliseconds(350); session.ReportSetupDeadlineUtc = DateTime.UtcNow.AddMilliseconds(1500); ResetPollQueue(session); @@ -408,16 +413,16 @@ public async Task> StartMonitoringAsync( device.IsMonitoring = true; device.IsConnected = true; device.Status = "Monitoring"; - device.AcquisitionMode = plans.Count > 0 - ? "MMS live start • arming smart reporting" + device.AcquisitionMode = session.ReportSetupPending + ? "MMS live start • arming ARIEC hybrid reporting" : $"MMS polling fallback • {session.Points.Count} point(s)"; - device.Detail = plans.Count > 0 - ? $"{session.Points.Count} point(s): MMS is reading the initial live image immediately while static/dynamic reporting is validated in the same independent IED session." + device.Detail = session.ReportSetupPending + ? $"{session.Points.Count} point(s): MMS is reading the initial live image immediately while the ARIEC hybrid planner validates fresh static/dynamic BRCB/URCB capability in the same independent IED session." : $"{session.Points.Count} point(s): no report candidate is available; MMS polling is active."; device.RefreshComputed(); Log("INFO", device.Name, - $"Fast live start: points={session.Points.Count}, pending report plan(s)={plans.Count}, initial MMS scheduler={session.PollQueue.Count}, target={safePollMs} ms. Full signal discovery is not part of monitor start."); + $"Fast live start: points={session.Points.Count}, legacy compatibility plan(s)={plans.Count}, ARIEC hybrid authority={(hasHybridAuthority ? "available" : "unavailable")}, initial MMS scheduler={session.PollQueue.Count}, target={safePollMs} ms. Full signal discovery is not part of monitor start."); session.MonitorTask = Task.Run( () => MonitorLoopAsync(session, session.MonitorCancellation.Token), @@ -550,6 +555,14 @@ private void ApplyControlFeedbackToMonitor(DeviceSession session, SignalDefiniti $"Live Monitor feedback injected immediately: {point.IecReference}={display}; reportCorrelation={(state.AwaitingCommandReportEdge ? "awaiting dchg" : "not report-assigned")}."); } + public HybridReportPhysicalValidationSnapshot CaptureHybridReportPhysicalValidation(string deviceId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(deviceId); + if (!_sessions.TryGetValue(deviceId, out var session)) + throw new InvalidOperationException($"No IEC 61850 runtime session exists for device '{deviceId}'."); + return session.HybridValidation.Capture(session.Device); + } + public async Task StopMonitoringAsync(string deviceId) { if (!_sessions.TryGetValue(deviceId, out var session)) @@ -598,7 +611,10 @@ private async Task StartReportPlansAsync( cancellationToken.ThrowIfCancellationRequested(); try { - var result = await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false); + var result = plan.IsEngineAuthoritative + ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) + : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false); + session.HybridValidation.RecordActivation(plan, result); if (!result.IsSuccess) { Log("WARN", session.Device.Name, @@ -608,7 +624,9 @@ private async Task StartReportPlansAsync( continue; } - plan.Status = result.UsedDynamicDataSet ? "Dynamic active" : "Static active"; + plan.Status = plan.IsEngineAuthoritative + ? $"{plan.EngineAcquisitionKind} active" + : result.UsedDynamicDataSet ? "Dynamic active" : "Static active"; session.ActiveReportPlans[plan.PlanId] = plan; session.ActiveReportPlanOrder.Add(plan); @@ -634,7 +652,8 @@ private async Task StartReportPlansAsync( if (result.CoveredReferences.Count == 0) { - var recoveryWillRun = !result.UsedDynamicDataSet && + var recoveryWillRun = !plan.IsEngineAuthoritative && + !result.UsedDynamicDataSet && plan.AllowDynamicDataSetWrites && plan.Bindings.Count > 0; Log(recoveryWillRun ? "INFO" : "WARN", session.Device.Name, @@ -651,7 +670,8 @@ private async Task StartReportPlansAsync( // A discovered static DataSet can cover only part of a heuristic group. // Recover the exact uncovered remainder through temporary dynamic reporting // before leaving those points on cyclic MMS polling. - if (!result.UsedDynamicDataSet && + if (!plan.IsEngineAuthoritative && + !result.UsedDynamicDataSet && plan.AllowDynamicDataSetWrites && coveredPoints.Count < plan.Bindings.Count) { @@ -762,6 +782,45 @@ private async Task TryStartPendingReportSetupAsync( ? "Initial live image is available. Validating static/dynamic report acquisition in the background monitor pipeline." : "Initial live-image deadline reached. Continuing report validation while MMS fallback remains active."); + if (session.Client.CanUseHybridReportPlanner(session.Device)) + { + NativeHybridReportPlanningResult hybrid; + try + { + hybrid = await session.Client.BuildHybridReportPlansAsync( + session.Device, + session.Points.Values.ToArray(), + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + hybrid = new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + Authority = "ARIEC61850 hybrid acquisition", + Status = "Planner failure / polling safe", + Summary = $"ARIEC hybrid planning failed closed: {ex.GetType().Name}: {ex.Message}. No local RCB heuristic was substituted; bounded MMS polling remains active.", + RequestedPointCount = session.Points.Count, + PollingPointKeys = session.Points.Keys.ToArray(), + PollingFallbackSignalCount = session.Points.Count, + Warnings = [$"Hybrid planning exception: {ex.GetType().Name}: {ex.Message}"] + }; + } + + session.HybridValidation.Reset(hybrid); + plans = hybrid.ReportPlans; + Log("INFO", session.Device.Name, + $"Hybrid authority={hybrid.Authority}; status={hybrid.Status}; requested={hybrid.RequestedPointCount}, catalog={hybrid.CatalogMappedPointCount}, staticBRCB={hybrid.StaticBrcbSignalCount}, staticURCB={hybrid.StaticUrcbSignalCount}, dynamicBRCB={hybrid.DynamicBrcbSignalCount}, dynamicURCB={hybrid.DynamicUrcbSignalCount}, polling={hybrid.PollingFallbackSignalCount}, uncovered={hybrid.UncoveredSignalCount}. {hybrid.Summary}"); + foreach (var warning in hybrid.Warnings.Take(5)) + Log("WARN", session.Device.Name, warning); + } + else + { + session.HybridValidation.Reset(null); + Log("INFO", session.Device.Name, + "ARIEC typed live-model authority is unavailable for this saved/session model; retaining the existing legacy report planner only as compatibility fallback."); + } + await StartReportPlansAsync(session, plans, cancellationToken).ConfigureAwait(false); ResetPollQueue(session); UpdateDeviceAcquisitionSummary(session); @@ -915,6 +974,16 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT hasProcessValue: update.HasValue); } + var verifiedReportPointKeys = slice.Updates + .Select(update => FindPointForReportReference(session, update.Reference)) + .Where(point => point is not null) + .Select(point => point!) + .Where(point => session.States.TryGetValue(point.PointKey, out var state) && state.ReportChangeVerified) + .Select(point => point.PointKey) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + session.HybridValidation.RecordSlice(plan, slice, verifiedReportPointKeys); + foreach (var warning in slice.Warnings.Take(2)) Log("WARN", session.Device.Name, warning); } diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs new file mode 100644 index 00000000..79b78f97 --- /dev/null +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -0,0 +1,504 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// Session-owned bridge from the ARIEC typed signal catalog and hybrid acquisition planner +/// into ARSAS runtime plans. ARSAS preserves the exact engine subscription plan and does +/// not recreate RCB capability, DataSet semantics, or vendor aliases locally. +/// +public sealed partial class NativeIec61850Client +{ + private sealed record AuthoritativeHybridSubscription( + ArMms.MmsHybridAcquisitionKind Kind, + string ReportControlReference, + Iec61850SignalCatalogDocument Catalog, + IReadOnlyList Signals, + ArMms.MmsHybridReportAcquisitionOptions Options); + + private readonly Dictionary _authoritativeHybridSubscriptions = + new(StringComparer.OrdinalIgnoreCase); + + internal bool CanUseHybridReportPlanner(Iec61850MonitorDevice device) + => device?.LiveDiscoveryModel is not null; + + public async Task BuildHybridReportPlansAsync( + Iec61850MonitorDevice device, + IReadOnlyCollection points, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(points); + cancellationToken.ThrowIfCancellationRequested(); + + _authoritativeHybridSubscriptions.Clear(); + + if (device.LiveDiscoveryModel is null) + { + return new NativeHybridReportPlanningResult + { + IsAuthoritative = false, + Authority = "Legacy compatibility", + Status = "Typed catalog unavailable", + Summary = "ARIEC hybrid planning was not used because no engine live-discovery model is attached to this device.", + RequestedPointCount = points.Count + }; + } + + if (!_session.IsMmsInitiated) + { + return new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + Authority = "ARIEC61850 hybrid acquisition", + Status = "Transport unavailable", + Summary = $"ARIEC hybrid planning was withheld because the MMS association is not initiated ({_session.State}). All points remain on polling/reconnect safety handling; no missing conclusion was made.", + RequestedPointCount = points.Count, + PollingPointKeys = points.Select(point => point.PointKey).ToArray(), + PollingFallbackSignalCount = points.Count + }; + } + + var catalog = Iec61850SignalCatalogBuilder.Build(device.LiveDiscoveryModel); + var index = BuildLiteralCatalogIndex(catalog); + var descriptorPoints = new Dictionary(); + var unmapped = new List(); + + foreach (var point in points.OrderBy(point => point.PointKey, StringComparer.OrdinalIgnoreCase)) + { + if (TryResolveLiteralCatalogSignal(index, point.IecReference, out var descriptor)) + descriptorPoints.TryAdd(descriptor, point); + else + unmapped.Add(point); + } + + if (descriptorPoints.Count == 0) + { + return new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + Authority = "ARIEC61850 typed signal catalog", + Status = "No exact catalog mapping", + Summary = "No selected point had one unambiguous literal match to an ARIEC catalog descriptor. ARSAS did not guess an IEC reference; bounded MMS polling remains active.", + RequestedPointCount = points.Count, + CatalogMappedPointCount = 0, + PollingPointKeys = points.Select(point => point.PointKey).ToArray(), + UnmappedPointKeys = points.Select(point => point.PointKey).ToArray(), + PollingFallbackSignalCount = points.Count, + Warnings = ["Hybrid report planning was conservatively skipped for unmapped points; this is not signal-absence evidence."] + }; + } + + var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false); + if (discovery is null) + { + return new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + Authority = "ARIEC61850 hybrid acquisition", + Status = "Fresh report discovery unavailable", + Summary = string.IsNullOrWhiteSpace(LastErrorMessage) + ? "Fresh ARIEC report discovery was unavailable. Points remain on MMS polling fallback." + : LastErrorMessage, + RequestedPointCount = points.Count, + CatalogMappedPointCount = descriptorPoints.Count, + PollingPointKeys = points.Select(point => point.PointKey).ToArray(), + UnmappedPointKeys = unmapped.Select(point => point.PointKey).ToArray(), + PollingFallbackSignalCount = points.Count + }; + } + + var callerOwned = _reportMonitorSessions.Values + .Select(session => session.ReportControl.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var availability = await RunMmsOperationAsync( + () => _session.CheckReportControlAvailabilityAsync( + discovery.ReportInventory, + discovery.IedDirectory, + new ArMms.MmsRcbAvailabilityOptions + { + MaxReportControls = 512, + ReadDataSetDirectories = true, + CallerOwnedRcbReferences = callerOwned + }, + cancellationToken), + cancellationToken).ConfigureAwait(false); + + var plannerOptions = new ArMms.MmsHybridReportAcquisitionOptions + { + AllowStaticBrcb = true, + AllowStaticUrcb = true, + AllowDynamicBrcb = device.AllowDynamicDataSetWrites, + AllowDynamicUrcb = device.AllowDynamicDataSetWrites, + // Existing caller-owned RCB reuse needs session aliasing semantics in ARSAS. + // Until that is explicit, fail closed instead of starting a second monitor + // against an RCB already owned by this association. + AllowCallerOwnedReports = false, + AllowPollingFallback = true, + RequireExactAvailabilityEvidence = true + }; + var enginePlan = ArMms.MmsHybridReportAcquisitionPlanner.Build( + catalog, + descriptorPoints.Keys, + discovery.ReportInventory, + availability, + discovery.IedDirectory, + plannerOptions); + + var reportPlans = new List(); + foreach (var segment in enginePlan.Segments.Where(segment => segment.IsReportBacked)) + { + if (segment.ReportPlan is null) + continue; + + var bindings = segment.Signals + .Where(descriptorPoints.ContainsKey) + .Select(signal => descriptorPoints[signal]) + .GroupBy(point => point.PointKey, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(point => point.IecReference, StringComparer.OrdinalIgnoreCase) + .ToList(); + if (bindings.Count == 0) + continue; + + var appPlan = new ReportControlPlan + { + RelayId = device.DeviceId, + RelayName = device.Name, + RelayIpAddress = device.IpAddress, + IedName = device.Name, + ReportControlReference = segment.ReportControlReference, + DataSetReference = segment.DataSetReference, + Mode = $"ARIEC Hybrid • {segment.Kind}", + AllowDynamicDataSetWrites = segment.Kind is ArMms.MmsHybridAcquisitionKind.DynamicBrcb or ArMms.MmsHybridAcquisitionKind.DynamicUrcb, + Buffered = segment.Kind is ArMms.MmsHybridAcquisitionKind.StaticBrcb or ArMms.MmsHybridAcquisitionKind.DynamicBrcb, + Status = $"{segment.Kind} planned", + IsEngineAuthoritative = true, + EngineAcquisitionKind = segment.Kind.ToString(), + Bindings = bindings + }; + + _authoritativeHybridSubscriptions[appPlan.PlanId] = new AuthoritativeHybridSubscription( + segment.Kind, + segment.ReportControlReference, + catalog, + segment.Signals.ToArray(), + plannerOptions); + reportPlans.Add(appPlan); + } + + var polling = enginePlan.Assignments + .Where(assignment => assignment.Kind == ArMms.MmsHybridAcquisitionKind.MmsPollingFallback) + .Select(assignment => FindPointKeyForAssignment(assignment.SignalReference, descriptorPoints)) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Concat(unmapped.Select(point => point.PointKey)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var uncovered = enginePlan.Assignments + .Where(assignment => assignment.Kind == ArMms.MmsHybridAcquisitionKind.Uncovered) + .Select(assignment => FindPointKeyForAssignment(assignment.SignalReference, descriptorPoints)) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var warnings = enginePlan.Warnings + .Concat(enginePlan.Blockers) + .Concat(availability.Warnings) + .Concat(unmapped.Count == 0 + ? Array.Empty() + : [$"{unmapped.Count} selected point(s) had no unique literal ARIEC catalog match and remain on bounded MMS polling. No absence conclusion was made."]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + Authority = "ARIEC61850 MmsHybridReportAcquisitionPlanner", + Status = enginePlan.Status.ToString(), + Summary = enginePlan.Summary, + ReportPlans = reportPlans, + PollingPointKeys = polling, + UncoveredPointKeys = uncovered, + UnmappedPointKeys = unmapped.Select(point => point.PointKey).ToArray(), + Warnings = warnings, + RequestedPointCount = points.Count, + CatalogMappedPointCount = descriptorPoints.Count, + StaticBrcbSignalCount = enginePlan.StaticBrcbSignalCount, + StaticUrcbSignalCount = enginePlan.StaticUrcbSignalCount, + DynamicBrcbSignalCount = enginePlan.DynamicBrcbSignalCount, + DynamicUrcbSignalCount = enginePlan.DynamicUrcbSignalCount, + PollingFallbackSignalCount = enginePlan.PollingFallbackSignalCount + unmapped.Count, + UncoveredSignalCount = enginePlan.UncoveredSignalCount + }; + } + + public async Task StartHybridReportMonitorAsync( + ReportControlPlan plan, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(plan); + cancellationToken.ThrowIfCancellationRequested(); + + if (!plan.IsEngineAuthoritative) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = "Hybrid report start rejected: the plan is not marked ARIEC-authoritative. No local re-planning was performed." + }; + } + + if (!_session.IsMmsInitiated) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = $"ARIEC hybrid report monitor requires an initiated MMS association. Current state: {_session.State}." + }; + } + + if (_reportMonitorSessions.ContainsKey(plan.PlanId)) + { + return new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = plan.PlanId, + Message = $"ARIEC hybrid report monitor already active for {plan.DisplayReference}.", + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + AcquisitionLabel = $"ARIEC Hybrid: {plan.EngineAcquisitionKind}", + CoveredReferences = _reportMonitorCoverage.TryGetValue(plan.PlanId, out var existingCoverage) + ? existingCoverage + : Array.Empty() + }; + } + + if (!_authoritativeHybridSubscriptions.TryGetValue(plan.PlanId, out var authoritative)) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = "ARIEC-authoritative subscription evidence is no longer present for this plan. ARSAS refused to rebuild the RCB/DataSet plan locally; MMS polling remains the safe fallback." + }; + } + + var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false); + if (discovery is null) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = LastErrorMessage + }; + } + + // P2.2 planning is intentionally an intent, not permission to write forever. + // Re-read the exact selected RCB immediately before execution, then ask the same + // ARIEC planner to classify that fresh evidence again. ARSAS never reimplements + // RptEna/reservation/DataSet safety semantics here. + var callerOwned = _reportMonitorSessions.Values + .Select(session => session.ReportControl.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var freshAvailability = await RunMmsOperationAsync( + () => _session.CheckReportControlAvailabilityAsync( + discovery.ReportInventory, + discovery.IedDirectory, + new ArMms.MmsRcbAvailabilityOptions + { + MaxReportControls = 512, + ReadDataSetDirectories = true, + CallerOwnedRcbReferences = callerOwned + }, + cancellationToken), + cancellationToken).ConfigureAwait(false); + + var selectedSnapshots = freshAvailability.ReportControls + .Where(snapshot => SameLiteralReference(snapshot.Reference, authoritative.ReportControlReference)) + .ToArray(); + if (selectedSnapshots.Length != 1) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = $"ARIEC execution revalidation withheld {authoritative.Kind}: expected one fresh availability snapshot for {authoritative.ReportControlReference}, found {selectedSnapshots.Length}. No RCB/DataSet write was attempted; MMS polling remains active.", + Warnings = freshAvailability.Warnings + }; + } + + var selectedAvailability = new ArMms.MmsRcbAvailabilityResult + { + CheckedAtUtc = freshAvailability.CheckedAtUtc, + ReportControls = selectedSnapshots, + Warnings = freshAvailability.Warnings + }; + var revalidatedPlan = ArMms.MmsHybridReportAcquisitionPlanner.Build( + authoritative.Catalog, + authoritative.Signals, + discovery.ReportInventory, + selectedAvailability, + discovery.IedDirectory, + authoritative.Options); + var revalidatedSegment = revalidatedPlan.Segments.FirstOrDefault(segment => + segment.IsReportBacked && + segment.ReportPlan is not null && + segment.Kind == authoritative.Kind && + SameLiteralReference(segment.ReportControlReference, authoritative.ReportControlReference)); + if (revalidatedSegment?.ReportPlan is null) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = $"ARIEC execution revalidation withheld {authoritative.Kind} on {authoritative.ReportControlReference}: fresh engine evidence no longer reproduces the planned report segment. No RCB/DataSet write was attempted; MMS polling remains active.", + Warnings = freshAvailability.Warnings + .Concat(revalidatedPlan.Warnings) + .Concat(revalidatedPlan.Blockers) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + }; + } + + var subscription = revalidatedSegment.ReportPlan; + if (!subscription.IsReady) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = $"ARIEC authoritative subscription is not ready: {subscription.Summary}", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + Warnings = subscription.Warnings.Concat(subscription.Blockers).ToArray() + }; + } + + var isDynamic = authoritative.Kind is ArMms.MmsHybridAcquisitionKind.DynamicBrcb or ArMms.MmsHybridAcquisitionKind.DynamicUrcb; + var coveredReferences = ExtractSubscriptionMemberReferences(subscription.Members); + var start = await RunMmsOperationAsync( + () => _session.StartPersistentReportMonitorAsync( + subscription, + triggerGeneralInterrogation: true, + deleteDynamicDataSetOnStop: isDynamic, + discovery.IedDirectory, + cancellationToken), + cancellationToken).ConfigureAwait(false); + + if (!start.IsSuccess || start.Session is null) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = $"ARIEC hybrid report activation failed for {plan.DisplayReference}: {start.Message}", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + WriteStepCount = start.WriteSteps.Count, + UsedDynamicDataSet = isDynamic, + CoveredReferences = coveredReferences, + Warnings = start.Warnings.Concat(subscription.Warnings).ToArray() + }; + } + + if (!string.IsNullOrWhiteSpace(start.Session.ReportControl.Reference)) + plan.ReportControlReference = start.Session.ReportControl.Reference; + if (!string.IsNullOrWhiteSpace(start.Session.Plan.DataSetReference)) + plan.DataSetReference = start.Session.Plan.DataSetReference; + + _reportMonitorSessions[plan.PlanId] = start.Session; + _reportMonitorCoverage[plan.PlanId] = coveredReferences; + + return new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = plan.PlanId, + Message = $"ARIEC authoritative {authoritative.Kind} monitor active. {start.Message}", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + WriteStepCount = start.WriteSteps.Count, + UsedDynamicDataSet = isDynamic, + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + AcquisitionLabel = $"ARIEC Hybrid: {authoritative.Kind}", + CoveredReferences = coveredReferences, + Warnings = start.Warnings.Concat(subscription.Warnings).ToArray() + }; + } + + private static Dictionary BuildLiteralCatalogIndex( + Iec61850SignalCatalogDocument catalog) + => catalog.Signals + .SelectMany(descriptor => EngineReferenceCandidates(descriptor) + .Select(reference => new { Reference = reference, Descriptor = descriptor })) + .GroupBy(item => item.Reference, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.Select(item => item.Descriptor).Distinct().ToArray(), + StringComparer.OrdinalIgnoreCase); + + private static bool TryResolveLiteralCatalogSignal( + IReadOnlyDictionary index, + string? pointReference, + out Iec61850SignalDescriptor descriptor) + { + var key = LiteralReference(pointReference); + if (key.Length > 0 && index.TryGetValue(key, out var matches) && matches.Length == 1) + { + descriptor = matches[0]; + return true; + } + + descriptor = null!; + return false; + } + + private static string FindPointKeyForAssignment( + string? signalReference, + IReadOnlyDictionary descriptorPoints) + { + var key = LiteralReference(signalReference); + if (key.Length == 0) + return string.Empty; + + var matches = descriptorPoints + .Where(pair => EngineReferenceCandidates(pair.Key).Contains(key, StringComparer.OrdinalIgnoreCase)) + .Select(pair => pair.Value.PointKey) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return matches.Length == 1 ? matches[0] : string.Empty; + } + + private static IEnumerable EngineReferenceCandidates(Iec61850SignalDescriptor descriptor) + { + var values = new[] + { + descriptor.PrimaryValueReference, + descriptor.DesignReference, + descriptor.ObservedReference, + descriptor.PrimaryValueMmsReference, + descriptor.CanonicalMmsReference, + descriptor.EffectiveMmsReference, + descriptor.ObservedMmsReference + }; + + return values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(LiteralReference) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + private static bool SameLiteralReference(string? left, string? right) + => string.Equals(LiteralReference(left), LiteralReference(right), StringComparison.OrdinalIgnoreCase); + + private static string LiteralReference(string? reference) + => (reference ?? string.Empty).Trim(); +} diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 03cd9c9e..b4b7ed63 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "b60d1272345ec7eabde9efb4e0acbfe26ebf2584", - "sourcePullRequest": 71, - "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Baseline includes engine-owned connected reconciliation plus merged COMTRADE/MMS FileOpen interoperability through PR #71, including canonical nested-path handling, bounded directory revalidation/recovery, preservation of the server-returned raw FileDirectory GraphicString identity, and one bounded evidence-driven replay of that exact identity. ARSAS does not invent MMS file-path semantics." + "commit": "2af88f51df759c7a7083795b3804ab8dc41e8922", + "sourcePullRequest": 74, + "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Baseline includes connected reconciliation, COMTRADE/MMS FileOpen interoperability, UTC-Time wire provenance, typed signal catalog query/coverage APIs, and PR #74 MmsHybridReportAcquisitionPlanner with fresh exact RCB availability authority, partial static DataSet coverage, fail-closed static/dynamic BRCB/URCB planning, caller-owned report reuse, and residual MMS polling without false signal-absence inference. ARSAS consumes these typed engine decisions and does not recreate IEC report capability heuristics." } diff --git a/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs new file mode 100644 index 00000000..4c5c83ca --- /dev/null +++ b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs @@ -0,0 +1,225 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class HybridReportPhysicalValidationTests +{ + [Fact] + public void PlanAndActivationWithoutTraffic_DoNotBecomePhysicalReportEvidence() + { + var staticPlan = EnginePlan("static", "StaticBrcb", "IEDLD0/LLN0.BR.brcb01", "IEDLD0/LLN0.Events", "p-static"); + var dynamicPlan = EnginePlan("dynamic", "DynamicUrcb", "IEDLD0/LLN0.RP.urcb02", "IEDLD0/LLN0.ARSAS_DYNAMIC", "p-dynamic"); + var tracker = new HybridReportPhysicalValidationTracker(); + tracker.Reset(new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + Authority = "ARIEC61850 MmsHybridReportAcquisitionPlanner", + Status = "FullReportCoverage", + ReportPlans = [staticPlan, dynamicPlan], + StaticBrcbSignalCount = 1, + DynamicUrcbSignalCount = 1, + Warnings = ["Characterization warning"] + }); + + tracker.RecordActivation(staticPlan, new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = staticPlan.PlanId, + Message = "Static BRCB active" + }); + tracker.RecordActivation(dynamicPlan, new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = dynamicPlan.PlanId, + Message = "Dynamic URCB remained unavailable" + }); + + var snapshot = tracker.Capture(Device()); + + Assert.False(snapshot.HasPhysicalReportEvidence); + Assert.Equal(1, snapshot.ActivatedReportPlanCount); + Assert.Equal(1, snapshot.FailedActivationCount); + Assert.Equal(0, snapshot.ReportFrameCount); + Assert.Equal(0, snapshot.ReportUpdateCount); + Assert.Equal(0, snapshot.ChangeVerifiedPointCount); + Assert.Equal(2, snapshot.Plans.Count); + Assert.Contains("Characterization warning", snapshot.Warnings); + } + + [Fact] + public void RealReportSlice_IsRecordedSeparatelyFromActivationAndPollingFallback() + { + var reportPlan = EnginePlan("static", "StaticUrcb", "IEDLD0/LLN0.RP.urcb01", "IEDLD0/LLN0.Events", "p-report"); + var tracker = new HybridReportPhysicalValidationTracker(); + tracker.Reset(new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + Authority = "ARIEC61850 MmsHybridReportAcquisitionPlanner", + Status = "HybridReportAndPolling", + ReportPlans = [reportPlan], + StaticUrcbSignalCount = 1, + PollingFallbackSignalCount = 3, + UncoveredSignalCount = 2 + }); + tracker.RecordActivation(reportPlan, new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = reportPlan.PlanId, + Message = "Static URCB active" + }); + + var receivedAt = new DateTimeOffset(2026, 8, 15, 1, 2, 3, TimeSpan.Zero); + tracker.RecordSlice( + reportPlan, + new NativeReportMonitorSliceResult + { + PlanId = reportPlan.PlanId, + ReportFrames = + [ + new NativeReportFrameMetadata + { + ReportControlReference = reportPlan.ReportControlReference, + DataSetReference = reportPlan.DataSetReference, + ReceivedAt = receivedAt + } + ], + Updates = + [ + new NativeReportValueUpdate + { + Reference = "IEDLD0/GGIO1.Ind1.stVal", + Value = "true", + Reason = "dchg", + UpdatedAt = receivedAt + } + ] + }, + ["point-1"]); + + var snapshot = tracker.Capture(Device()); + var plan = Assert.Single(snapshot.Plans); + + Assert.True(snapshot.HasPhysicalReportEvidence); + Assert.Equal(1, snapshot.ReportFrameCount); + Assert.Equal(1, snapshot.ReportUpdateCount); + Assert.Equal(1, snapshot.ChangeVerifiedPointCount); + Assert.Equal(3, snapshot.PollingFallbackPointCount); + Assert.Equal(2, snapshot.UncoveredPointCount); + Assert.Equal(receivedAt, plan.FirstReportAtUtc); + Assert.Equal(receivedAt, plan.LastReportAtUtc); + Assert.Equal("StaticUrcb", plan.AcquisitionKind); + } + + [Fact] + public void DynamicSetupEvidence_DoesNotClaimReportTrafficBeforeAFrameArrives() + { + var dynamicPlan = EnginePlan("dynamic-brcb", "DynamicBrcb", "IEDLD0/LLN0.BR.brcb02", "IEDLD0/LLN0.AR_HYB_01", "p-dynamic"); + var tracker = new HybridReportPhysicalValidationTracker(); + tracker.Reset(new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + Authority = "ARIEC61850 MmsHybridReportAcquisitionPlanner", + Status = "FullReportCoverage", + ReportPlans = [dynamicPlan], + DynamicBrcbSignalCount = 1 + }); + + tracker.RecordActivation(dynamicPlan, new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = dynamicPlan.PlanId, + Message = "Dynamic BRCB configured and enabled", + SubscriptionSummary = "dynamic dataset AR_HYB_01", + MemberCount = 1, + WriteStepCount = 5, + UsedDynamicDataSet = true + }); + + var snapshot = tracker.Capture(Device()); + var plan = Assert.Single(snapshot.Plans); + + Assert.False(snapshot.HasPhysicalReportEvidence); + Assert.Equal(1, snapshot.ActivatedReportPlanCount); + Assert.Equal(0, snapshot.ReportFrameCount); + Assert.Equal("DynamicBrcb", plan.AcquisitionKind); + Assert.True(plan.UsedDynamicDataSet); + Assert.Equal(1, plan.MemberCount); + Assert.Equal(5, plan.SetupWriteStepCount); + Assert.Equal("dynamic dataset AR_HYB_01", plan.SubscriptionSummary); + } + + [Fact] + public void LegacyPlanTraffic_IsNotClaimedAsAriecHybridPhysicalEvidence() + { + var legacyPlan = new ReportControlPlan + { + PlanId = "legacy", + IsEngineAuthoritative = false, + EngineAcquisitionKind = string.Empty, + ReportControlReference = "IEDLD0/LLN0.RP.urcbLegacy" + }; + var tracker = new HybridReportPhysicalValidationTracker(); + tracker.Reset(new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + ReportPlans = [] + }); + + tracker.RecordActivation(legacyPlan, new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = legacyPlan.PlanId, + Message = "Legacy active" + }); + tracker.RecordSlice( + legacyPlan, + new NativeReportMonitorSliceResult + { + PlanId = legacyPlan.PlanId, + ReportFrames = [new NativeReportFrameMetadata { ReceivedAt = DateTimeOffset.UtcNow }] + }, + ["legacy-point"]); + + var snapshot = tracker.Capture(Device()); + + Assert.False(snapshot.HasPhysicalReportEvidence); + Assert.Empty(snapshot.Plans); + Assert.Equal(0, snapshot.ReportFrameCount); + Assert.Equal(0, snapshot.ChangeVerifiedPointCount); + } + + private static ReportControlPlan EnginePlan( + string planId, + string kind, + string rcb, + string dataSet, + string pointReference) + => new() + { + PlanId = planId, + IsEngineAuthoritative = true, + EngineAcquisitionKind = kind, + ReportControlReference = rcb, + DataSetReference = dataSet, + Bindings = + [ + new Iec61850MonitorPoint + { + DeviceId = "ied-1", + DeviceName = "IED-1", + SignalName = pointReference, + IecReference = pointReference + } + ] + }; + + private static Iec61850MonitorDevice Device() + => new() + { + DeviceId = "ied-1", + Name = "IED-1", + IpAddress = "192.0.2.10", + Port = 102 + }; +}