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
+ };
+}