From 9c3a8ebbb230380ddb76e212be077d524fde9e00 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:19:59 +0700 Subject: [PATCH 01/28] Add hybrid report acquisition planning contracts --- Models/NativeHybridReportAcquisitionModels.cs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 Models/NativeHybridReportAcquisitionModels.cs diff --git a/Models/NativeHybridReportAcquisitionModels.cs b/Models/NativeHybridReportAcquisitionModels.cs new file mode 100644 index 00000000..9311119e --- /dev/null +++ b/Models/NativeHybridReportAcquisitionModels.cs @@ -0,0 +1,72 @@ +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 int ReportFrameCount { get; init; } + public int ReportUpdateCount { get; init; } + public int ChangeVerifiedPointCount { get; init; } + public DateTimeOffset? FirstReportAtUtc { get; init; } + public DateTimeOffset? LastReportAtUtc { get; init; } +} From 7466feeffc22966aa8e0833dd67471bfba37a2b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:20:20 +0700 Subject: [PATCH 02/28] Add hybrid report physical validation tracker --- .../HybridReportPhysicalValidationTracker.cs | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 Services/HybridReportPhysicalValidationTracker.cs diff --git a/Services/HybridReportPhysicalValidationTracker.cs b/Services/HybridReportPhysicalValidationTracker.cs new file mode 100644 index 00000000..9994b3d6 --- /dev/null +++ b/Services/HybridReportPhysicalValidationTracker.cs @@ -0,0 +1,154 @@ +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 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; + 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, + 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); + } +} From ea9e70204337ac7fbec5a8d6090760cba12ea666 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:21:30 +0700 Subject: [PATCH 03/28] Consume ARIEC hybrid report acquisition planner --- .../NativeIec61850Client.HybridReporting.cs | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 Services/NativeIec61850Client.HybridReporting.cs diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs new file mode 100644 index 00000000..f3d36b67 --- /dev/null +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -0,0 +1,423 @@ +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.MmsReportSubscriptionPlan Subscription, + ArMms.MmsHybridAcquisitionKind Kind); + + 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 enginePlan = ArMms.MmsHybridReportAcquisitionPlanner.Build( + catalog, + descriptorPoints.Keys, + discovery.ReportInventory, + availability, + discovery.IedDirectory, + new ArMms.MmsHybridReportAcquisitionOptions + { + AllowStaticBrcb = true, + AllowStaticUrcb = true, + AllowDynamicBrcb = device.AllowDynamicDataSetWrites, + AllowDynamicUrcb = device.AllowDynamicDataSetWrites, + AllowCallerOwnedReports = true, + AllowPollingFallback = true, + RequireExactAvailabilityEvidence = true + }); + + 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.ReportPlan, + segment.Kind); + 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 + }; + } + + var subscription = authoritative.Subscription; + 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 string LiteralReference(string? reference) + => (reference ?? string.Empty).Trim(); +} From d9a851acf128903c4ab7653e63c25f5581bc1db3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:21:48 +0700 Subject: [PATCH 04/28] Mark engine-authoritative report plans --- Models/ReportControlPlan.cs | 6 ++++++ 1 file changed, 6 insertions(+) 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; From cfe583172d5310e2d60ff50b11b86be5a1aead77 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:22:08 +0700 Subject: [PATCH 05/28] Pin ARIEC hybrid acquisition planner revision --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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." } From 4ad02ab30b7f8fa4040d07548f1f88074c6c9aa0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:23:04 +0700 Subject: [PATCH 06/28] Add one-shot guarded hybrid runtime patch --- .agent/apply_hybrid_runtime_patch.py | 138 +++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 .agent/apply_hybrid_runtime_patch.py diff --git a/.agent/apply_hybrid_runtime_patch.py b/.agent/apply_hybrid_runtime_patch.py new file mode 100644 index 00000000..eee03bf4 --- /dev/null +++ b/.agent/apply_hybrid_runtime_patch.py @@ -0,0 +1,138 @@ +from pathlib import Path + +path = Path("Services/Iec61850MonitorRuntime.cs") +text = path.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one source match, found {count}") + text = text.replace(old, new, 1) + + +replace_once( + " public int ControlCommandActive;\n }", + " public int ControlCommandActive;\n public HybridReportPhysicalValidationTracker HybridValidation { get; } = new();\n }", + "device-session-validation-tracker") + +replace_once( + " session.HealthProbePointKey = string.Empty;\n\n var safePollMs", + " session.HealthProbePointKey = string.Empty;\n session.HybridValidation.Reset(null);\n\n var safePollMs", + "reset-validation-tracker") + +replace_once( + " session.PendingReportPlans = plans;\n session.ReportSetupPending = plans.Count > 0;", + " session.PendingReportPlans = plans;\n session.ReportSetupPending = plans.Count > 0 || session.Client.CanUseHybridReportPlanner(device);", + "arm-hybrid-planner") + +replace_once( + " device.AcquisitionMode = plans.Count > 0\n ? \"MMS live start • arming smart reporting\"", + " device.AcquisitionMode = session.ReportSetupPending\n ? \"MMS live start • arming ARIEC hybrid reporting\"", + "hybrid-start-mode") + +replace_once( + " device.Detail = plans.Count > 0\n ? $\"{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\n ? $\"{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.\"", + "hybrid-start-detail") + +replace_once( + " $\"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={(session.Client.CanUseHybridReportPlanner(device) ? \"available\" : \"unavailable\")}, initial MMS scheduler={session.PollQueue.Count}, target={safePollMs} ms. Full signal discovery is not part of monitor start.\");", + "hybrid-start-log") + +old_setup = """ await StartReportPlansAsync(session, plans, cancellationToken).ConfigureAwait(false); + ResetPollQueue(session); + UpdateDeviceAcquisitionSummary(session);""" +new_setup = """ 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);""" +replace_once(old_setup, new_setup, "hybrid-plan-consumption") + +replace_once( + " var result = await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);\n if (!result.IsSuccess)", + " var result = plan.IsEngineAuthoritative\n ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false)\n : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);\n session.HybridValidation.RecordActivation(plan, result);\n if (!result.IsSuccess)", + "execute-authoritative-plan") + +replace_once( + " plan.Status = result.UsedDynamicDataSet ? \"Dynamic active\" : \"Static active\";", + " plan.Status = plan.IsEngineAuthoritative\n ? $\"{plan.EngineAcquisitionKind} active\"\n : result.UsedDynamicDataSet ? \"Dynamic active\" : \"Static active\";", + "preserve-engine-kind") + +replace_once( + " if (!result.UsedDynamicDataSet &&\n plan.AllowDynamicDataSetWrites &&", + " if (!plan.IsEngineAuthoritative &&\n !result.UsedDynamicDataSet &&\n plan.AllowDynamicDataSetWrites &&", + "disable-legacy-recovery-for-engine-plan") + +old_warning_loop = """ foreach (var warning in slice.Warnings.Take(2)) + Log("WARN", session.Device.Name, warning);""" +new_warning_loop = """ 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);""" +replace_once(old_warning_loop, new_warning_loop, "record-physical-report-evidence") + +capture_anchor = """ public async Task StopMonitoringAsync(string deviceId) + {""" +capture_method = """ 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) + {""" +replace_once(capture_anchor, capture_method, "physical-validation-snapshot-api") + +path.write_text(text, encoding="utf-8") +print("Applied guarded ARIEC hybrid acquisition runtime integration.") From 91a63f3069bc25944157cea07ea88d1aed0139b5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:23:22 +0700 Subject: [PATCH 07/28] Run guarded hybrid runtime patch --- .../workflows/agent-hybrid-runtime-patch.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/agent-hybrid-runtime-patch.yml diff --git a/.github/workflows/agent-hybrid-runtime-patch.yml b/.github/workflows/agent-hybrid-runtime-patch.yml new file mode 100644 index 00000000..1fef0f57 --- /dev/null +++ b/.github/workflows/agent-hybrid-runtime-patch.yml @@ -0,0 +1,37 @@ +name: Agent guarded hybrid runtime patch + +on: + push: + branches: + - agent/consume-hybrid-report-planner-p23 + +permissions: + contents: write + +jobs: + patch-runtime: + if: ${{ !contains(github.event.head_commit.message, '[hybrid-runtime-applied]') }} + runs-on: ubuntu-latest + steps: + - name: Checkout exact branch head + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Apply exact guarded patch + run: python .agent/apply_hybrid_runtime_patch.py + + - name: Commit guarded patch + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Services/Iec61850MonitorRuntime.cs + if git diff --cached --quiet; then + echo "Runtime patch produced no diff; refusing an empty patch commit." + exit 1 + fi + git commit -m "Integrate ARIEC hybrid acquisition into monitor runtime [hybrid-runtime-applied]" + git push origin HEAD:${{ github.ref_name }} From 1f434b57bdf0807d5adaa25d80e1a106680f0429 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:24:26 +0700 Subject: [PATCH 08/28] Narrow guarded hybrid runtime patch scope --- .agent/apply_hybrid_runtime_patch_v2.py | 159 ++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 .agent/apply_hybrid_runtime_patch_v2.py diff --git a/.agent/apply_hybrid_runtime_patch_v2.py b/.agent/apply_hybrid_runtime_patch_v2.py new file mode 100644 index 00000000..b46a0ffe --- /dev/null +++ b/.agent/apply_hybrid_runtime_patch_v2.py @@ -0,0 +1,159 @@ +from pathlib import Path + +path = Path("Services/Iec61850MonitorRuntime.cs") +text = path.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one source match, found {count}") + text = text.replace(old, new, 1) + + +def replace_once_in_method(method_start: str, method_end: str, old: str, new: str, label: str) -> None: + global text + start = text.find(method_start) + if start < 0: + raise SystemExit(f"{label}: method start not found") + end = text.find(method_end, start) + if end < 0: + raise SystemExit(f"{label}: method end not found") + body = text[start:end] + count = body.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one source match inside method, found {count}") + body = body.replace(old, new, 1) + text = text[:start] + body + text[end:] + + +replace_once( + " public int ControlCommandActive;\n }", + " public int ControlCommandActive;\n public HybridReportPhysicalValidationTracker HybridValidation { get; } = new();\n }", + "device-session-validation-tracker") + +replace_once( + " session.HealthProbePointKey = string.Empty;\n\n var safePollMs", + " session.HealthProbePointKey = string.Empty;\n session.HybridValidation.Reset(null);\n\n var safePollMs", + "reset-validation-tracker") + +replace_once( + " session.PendingReportPlans = plans;\n session.ReportSetupPending = plans.Count > 0;", + " session.PendingReportPlans = plans;\n session.ReportSetupPending = plans.Count > 0 || session.Client.CanUseHybridReportPlanner(device);", + "arm-hybrid-planner") + +replace_once( + " device.AcquisitionMode = plans.Count > 0\n ? \"MMS live start • arming smart reporting\"", + " device.AcquisitionMode = session.ReportSetupPending\n ? \"MMS live start • arming ARIEC hybrid reporting\"", + "hybrid-start-mode") + +replace_once( + " device.Detail = plans.Count > 0\n ? $\"{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\n ? $\"{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.\"", + "hybrid-start-detail") + +replace_once( + " $\"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={(session.Client.CanUseHybridReportPlanner(device) ? \"available\" : \"unavailable\")}, initial MMS scheduler={session.PollQueue.Count}, target={safePollMs} ms. Full signal discovery is not part of monitor start.\");", + "hybrid-start-log") + +old_setup = """ await StartReportPlansAsync(session, plans, cancellationToken).ConfigureAwait(false); + ResetPollQueue(session); + UpdateDeviceAcquisitionSummary(session);""" +new_setup = """ 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);""" +replace_once_in_method( + " private async Task TryStartPendingReportSetupAsync(", + " private void UpdateDeviceAcquisitionSummary(", + old_setup, + new_setup, + "hybrid-plan-consumption") + +replace_once( + " var result = await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);\n if (!result.IsSuccess)", + " var result = plan.IsEngineAuthoritative\n ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false)\n : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);\n session.HybridValidation.RecordActivation(plan, result);\n if (!result.IsSuccess)", + "execute-authoritative-plan") + +replace_once( + " plan.Status = result.UsedDynamicDataSet ? \"Dynamic active\" : \"Static active\";", + " plan.Status = plan.IsEngineAuthoritative\n ? $\"{plan.EngineAcquisitionKind} active\"\n : result.UsedDynamicDataSet ? \"Dynamic active\" : \"Static active\";", + "preserve-engine-kind") + +replace_once( + " if (!result.UsedDynamicDataSet &&\n plan.AllowDynamicDataSetWrites &&", + " if (!plan.IsEngineAuthoritative &&\n !result.UsedDynamicDataSet &&\n plan.AllowDynamicDataSetWrites &&", + "disable-legacy-recovery-for-engine-plan") + +old_warning_loop = """ foreach (var warning in slice.Warnings.Take(2)) + Log("WARN", session.Device.Name, warning);""" +new_warning_loop = """ 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);""" +replace_once(old_warning_loop, new_warning_loop, "record-physical-report-evidence") + +capture_anchor = """ public async Task StopMonitoringAsync(string deviceId) + {""" +capture_method = """ 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) + {""" +replace_once(capture_anchor, capture_method, "physical-validation-snapshot-api") + +path.write_text(text, encoding="utf-8") +print("Applied guarded ARIEC hybrid acquisition runtime integration.") From b92f823104c06db2f2e71ad53a7a1160a90760c3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:24:48 +0700 Subject: [PATCH 09/28] Retry guarded hybrid runtime patch with method scope --- .github/workflows/agent-hybrid-runtime-patch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agent-hybrid-runtime-patch.yml b/.github/workflows/agent-hybrid-runtime-patch.yml index 1fef0f57..85b15c0f 100644 --- a/.github/workflows/agent-hybrid-runtime-patch.yml +++ b/.github/workflows/agent-hybrid-runtime-patch.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: Apply exact guarded patch - run: python .agent/apply_hybrid_runtime_patch.py + run: python .agent/apply_hybrid_runtime_patch_v2.py - name: Commit guarded patch shell: bash From 555c262549fc7591c6f12cb8124ff6fe891d369e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:24:57 +0000 Subject: [PATCH 10/28] Integrate ARIEC hybrid acquisition into monitor runtime [hybrid-runtime-applied] --- Services/Iec61850MonitorRuntime.cs | 83 ++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 952d5a02..edcf3306 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) @@ -400,7 +402,7 @@ public async Task> StartMonitoringAsync( var plans = Iec61850ReportPlanner.BuildPlans(device, session.Points.Values); session.PendingReportPlans = plans; - session.ReportSetupPending = plans.Count > 0; + session.ReportSetupPending = plans.Count > 0 || session.Client.CanUseHybridReportPlanner(device); session.ReportSetupNotBeforeUtc = DateTime.UtcNow.AddMilliseconds(350); session.ReportSetupDeadlineUtc = DateTime.UtcNow.AddMilliseconds(1500); ResetPollQueue(session); @@ -408,16 +410,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={(session.Client.CanUseHybridReportPlanner(device) ? "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 +552,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 +608,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 +621,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); @@ -651,7 +666,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 +778,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 +970,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); } From 02e6a2b881ff8ca125a2912d3888ba918b4b6273 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:25:26 +0700 Subject: [PATCH 11/28] Remove one-shot hybrid runtime patch workflow --- .../workflows/agent-hybrid-runtime-patch.yml | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/agent-hybrid-runtime-patch.yml diff --git a/.github/workflows/agent-hybrid-runtime-patch.yml b/.github/workflows/agent-hybrid-runtime-patch.yml deleted file mode 100644 index 85b15c0f..00000000 --- a/.github/workflows/agent-hybrid-runtime-patch.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Agent guarded hybrid runtime patch - -on: - push: - branches: - - agent/consume-hybrid-report-planner-p23 - -permissions: - contents: write - -jobs: - patch-runtime: - if: ${{ !contains(github.event.head_commit.message, '[hybrid-runtime-applied]') }} - runs-on: ubuntu-latest - steps: - - name: Checkout exact branch head - uses: actions/checkout@v4 - with: - ref: ${{ github.ref_name }} - fetch-depth: 0 - - - name: Apply exact guarded patch - run: python .agent/apply_hybrid_runtime_patch_v2.py - - - name: Commit guarded patch - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Services/Iec61850MonitorRuntime.cs - if git diff --cached --quiet; then - echo "Runtime patch produced no diff; refusing an empty patch commit." - exit 1 - fi - git commit -m "Integrate ARIEC hybrid acquisition into monitor runtime [hybrid-runtime-applied]" - git push origin HEAD:${{ github.ref_name }} From 86aefa47f89fa30c624bacf13bff3ce4a735457b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:25:43 +0700 Subject: [PATCH 12/28] Remove one-shot hybrid runtime patch script --- .agent/apply_hybrid_runtime_patch.py | 138 --------------------------- 1 file changed, 138 deletions(-) delete mode 100644 .agent/apply_hybrid_runtime_patch.py diff --git a/.agent/apply_hybrid_runtime_patch.py b/.agent/apply_hybrid_runtime_patch.py deleted file mode 100644 index eee03bf4..00000000 --- a/.agent/apply_hybrid_runtime_patch.py +++ /dev/null @@ -1,138 +0,0 @@ -from pathlib import Path - -path = Path("Services/Iec61850MonitorRuntime.cs") -text = path.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one source match, found {count}") - text = text.replace(old, new, 1) - - -replace_once( - " public int ControlCommandActive;\n }", - " public int ControlCommandActive;\n public HybridReportPhysicalValidationTracker HybridValidation { get; } = new();\n }", - "device-session-validation-tracker") - -replace_once( - " session.HealthProbePointKey = string.Empty;\n\n var safePollMs", - " session.HealthProbePointKey = string.Empty;\n session.HybridValidation.Reset(null);\n\n var safePollMs", - "reset-validation-tracker") - -replace_once( - " session.PendingReportPlans = plans;\n session.ReportSetupPending = plans.Count > 0;", - " session.PendingReportPlans = plans;\n session.ReportSetupPending = plans.Count > 0 || session.Client.CanUseHybridReportPlanner(device);", - "arm-hybrid-planner") - -replace_once( - " device.AcquisitionMode = plans.Count > 0\n ? \"MMS live start • arming smart reporting\"", - " device.AcquisitionMode = session.ReportSetupPending\n ? \"MMS live start • arming ARIEC hybrid reporting\"", - "hybrid-start-mode") - -replace_once( - " device.Detail = plans.Count > 0\n ? $\"{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\n ? $\"{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.\"", - "hybrid-start-detail") - -replace_once( - " $\"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={(session.Client.CanUseHybridReportPlanner(device) ? \"available\" : \"unavailable\")}, initial MMS scheduler={session.PollQueue.Count}, target={safePollMs} ms. Full signal discovery is not part of monitor start.\");", - "hybrid-start-log") - -old_setup = """ await StartReportPlansAsync(session, plans, cancellationToken).ConfigureAwait(false); - ResetPollQueue(session); - UpdateDeviceAcquisitionSummary(session);""" -new_setup = """ 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);""" -replace_once(old_setup, new_setup, "hybrid-plan-consumption") - -replace_once( - " var result = await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);\n if (!result.IsSuccess)", - " var result = plan.IsEngineAuthoritative\n ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false)\n : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);\n session.HybridValidation.RecordActivation(plan, result);\n if (!result.IsSuccess)", - "execute-authoritative-plan") - -replace_once( - " plan.Status = result.UsedDynamicDataSet ? \"Dynamic active\" : \"Static active\";", - " plan.Status = plan.IsEngineAuthoritative\n ? $\"{plan.EngineAcquisitionKind} active\"\n : result.UsedDynamicDataSet ? \"Dynamic active\" : \"Static active\";", - "preserve-engine-kind") - -replace_once( - " if (!result.UsedDynamicDataSet &&\n plan.AllowDynamicDataSetWrites &&", - " if (!plan.IsEngineAuthoritative &&\n !result.UsedDynamicDataSet &&\n plan.AllowDynamicDataSetWrites &&", - "disable-legacy-recovery-for-engine-plan") - -old_warning_loop = """ foreach (var warning in slice.Warnings.Take(2)) - Log("WARN", session.Device.Name, warning);""" -new_warning_loop = """ 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);""" -replace_once(old_warning_loop, new_warning_loop, "record-physical-report-evidence") - -capture_anchor = """ public async Task StopMonitoringAsync(string deviceId) - {""" -capture_method = """ 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) - {""" -replace_once(capture_anchor, capture_method, "physical-validation-snapshot-api") - -path.write_text(text, encoding="utf-8") -print("Applied guarded ARIEC hybrid acquisition runtime integration.") From ad41199dfec892f4a5ab933d61499a893a9307ff Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:25:52 +0700 Subject: [PATCH 13/28] Remove guarded hybrid runtime patch helper --- .agent/apply_hybrid_runtime_patch_v2.py | 159 ------------------------ 1 file changed, 159 deletions(-) delete mode 100644 .agent/apply_hybrid_runtime_patch_v2.py diff --git a/.agent/apply_hybrid_runtime_patch_v2.py b/.agent/apply_hybrid_runtime_patch_v2.py deleted file mode 100644 index b46a0ffe..00000000 --- a/.agent/apply_hybrid_runtime_patch_v2.py +++ /dev/null @@ -1,159 +0,0 @@ -from pathlib import Path - -path = Path("Services/Iec61850MonitorRuntime.cs") -text = path.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one source match, found {count}") - text = text.replace(old, new, 1) - - -def replace_once_in_method(method_start: str, method_end: str, old: str, new: str, label: str) -> None: - global text - start = text.find(method_start) - if start < 0: - raise SystemExit(f"{label}: method start not found") - end = text.find(method_end, start) - if end < 0: - raise SystemExit(f"{label}: method end not found") - body = text[start:end] - count = body.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one source match inside method, found {count}") - body = body.replace(old, new, 1) - text = text[:start] + body + text[end:] - - -replace_once( - " public int ControlCommandActive;\n }", - " public int ControlCommandActive;\n public HybridReportPhysicalValidationTracker HybridValidation { get; } = new();\n }", - "device-session-validation-tracker") - -replace_once( - " session.HealthProbePointKey = string.Empty;\n\n var safePollMs", - " session.HealthProbePointKey = string.Empty;\n session.HybridValidation.Reset(null);\n\n var safePollMs", - "reset-validation-tracker") - -replace_once( - " session.PendingReportPlans = plans;\n session.ReportSetupPending = plans.Count > 0;", - " session.PendingReportPlans = plans;\n session.ReportSetupPending = plans.Count > 0 || session.Client.CanUseHybridReportPlanner(device);", - "arm-hybrid-planner") - -replace_once( - " device.AcquisitionMode = plans.Count > 0\n ? \"MMS live start • arming smart reporting\"", - " device.AcquisitionMode = session.ReportSetupPending\n ? \"MMS live start • arming ARIEC hybrid reporting\"", - "hybrid-start-mode") - -replace_once( - " device.Detail = plans.Count > 0\n ? $\"{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\n ? $\"{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.\"", - "hybrid-start-detail") - -replace_once( - " $\"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={(session.Client.CanUseHybridReportPlanner(device) ? \"available\" : \"unavailable\")}, initial MMS scheduler={session.PollQueue.Count}, target={safePollMs} ms. Full signal discovery is not part of monitor start.\");", - "hybrid-start-log") - -old_setup = """ await StartReportPlansAsync(session, plans, cancellationToken).ConfigureAwait(false); - ResetPollQueue(session); - UpdateDeviceAcquisitionSummary(session);""" -new_setup = """ 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);""" -replace_once_in_method( - " private async Task TryStartPendingReportSetupAsync(", - " private void UpdateDeviceAcquisitionSummary(", - old_setup, - new_setup, - "hybrid-plan-consumption") - -replace_once( - " var result = await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);\n if (!result.IsSuccess)", - " var result = plan.IsEngineAuthoritative\n ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false)\n : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);\n session.HybridValidation.RecordActivation(plan, result);\n if (!result.IsSuccess)", - "execute-authoritative-plan") - -replace_once( - " plan.Status = result.UsedDynamicDataSet ? \"Dynamic active\" : \"Static active\";", - " plan.Status = plan.IsEngineAuthoritative\n ? $\"{plan.EngineAcquisitionKind} active\"\n : result.UsedDynamicDataSet ? \"Dynamic active\" : \"Static active\";", - "preserve-engine-kind") - -replace_once( - " if (!result.UsedDynamicDataSet &&\n plan.AllowDynamicDataSetWrites &&", - " if (!plan.IsEngineAuthoritative &&\n !result.UsedDynamicDataSet &&\n plan.AllowDynamicDataSetWrites &&", - "disable-legacy-recovery-for-engine-plan") - -old_warning_loop = """ foreach (var warning in slice.Warnings.Take(2)) - Log("WARN", session.Device.Name, warning);""" -new_warning_loop = """ 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);""" -replace_once(old_warning_loop, new_warning_loop, "record-physical-report-evidence") - -capture_anchor = """ public async Task StopMonitoringAsync(string deviceId) - {""" -capture_method = """ 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) - {""" -replace_once(capture_anchor, capture_method, "physical-validation-snapshot-api") - -path.write_text(text, encoding="utf-8") -print("Applied guarded ARIEC hybrid acquisition runtime integration.") From fe63fcc980939debc16f7c65b07c46680eb2049f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:29:35 +0700 Subject: [PATCH 14/28] Add guarded hybrid authority cleanup helper --- .agent/apply_hybrid_authority_cleanup.py | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .agent/apply_hybrid_authority_cleanup.py diff --git a/.agent/apply_hybrid_authority_cleanup.py b/.agent/apply_hybrid_authority_cleanup.py new file mode 100644 index 00000000..523d94c6 --- /dev/null +++ b/.agent/apply_hybrid_authority_cleanup.py @@ -0,0 +1,42 @@ +from pathlib import Path + +path = Path("Services/Iec61850MonitorRuntime.cs") +text = path.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one source match, found {count}") + text = text.replace(old, new, 1) + +replace_once( + """ var plans = Iec61850ReportPlanner.BuildPlans(device, session.Points.Values); + session.PendingReportPlans = plans; + session.ReportSetupPending = plans.Count > 0 || session.Client.CanUseHybridReportPlanner(device);""", + """ var hasHybridAuthority = session.Client.CanUseHybridReportPlanner(device); + var plans = hasHybridAuthority + ? Array.Empty() + : Iec61850ReportPlanner.BuildPlans(device, session.Points.Values); + session.PendingReportPlans = plans; + session.ReportSetupPending = hasHybridAuthority || plans.Count > 0;""", + "do-not-run-legacy-planner-under-engine-authority") + +replace_once( + """ $"Fast live start: points={session.Points.Count}, legacy compatibility plan(s)={plans.Count}, ARIEC hybrid authority={(session.Client.CanUseHybridReportPlanner(device) ? "available" : "unavailable")}, 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.");""", + "reuse-authority-decision") + +replace_once( + """ var recoveryWillRun = !result.UsedDynamicDataSet && + plan.AllowDynamicDataSetWrites && + plan.Bindings.Count > 0;""", + """ var recoveryWillRun = !plan.IsEngineAuthoritative && + !result.UsedDynamicDataSet && + plan.AllowDynamicDataSetWrites && + plan.Bindings.Count > 0;""", + "engine-plan-zero-coverage-log-must-not-promise-legacy-recovery") + +path.write_text(text, encoding="utf-8") +print("Applied guarded hybrid authority cleanup.") From f4dd0a5d388b3f6620f7d2ef3018ba21973badb6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:29:49 +0700 Subject: [PATCH 15/28] Run guarded hybrid authority cleanup --- .../agent-hybrid-authority-cleanup.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/agent-hybrid-authority-cleanup.yml diff --git a/.github/workflows/agent-hybrid-authority-cleanup.yml b/.github/workflows/agent-hybrid-authority-cleanup.yml new file mode 100644 index 00000000..d30a8de1 --- /dev/null +++ b/.github/workflows/agent-hybrid-authority-cleanup.yml @@ -0,0 +1,37 @@ +name: Agent guarded hybrid authority cleanup + +on: + push: + branches: + - agent/consume-hybrid-report-planner-p23 + +permissions: + contents: write + +jobs: + cleanup-runtime: + if: ${{ !contains(github.event.head_commit.message, '[hybrid-authority-clean]') }} + runs-on: ubuntu-latest + steps: + - name: Checkout exact branch head + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Apply exact guarded cleanup + run: python .agent/apply_hybrid_authority_cleanup.py + + - name: Commit guarded cleanup + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Services/Iec61850MonitorRuntime.cs + if git diff --cached --quiet; then + echo "Authority cleanup produced no diff; refusing an empty commit." + exit 1 + fi + git commit -m "Make ARIEC hybrid authority exclusive in monitor planning [hybrid-authority-clean]" + git push origin HEAD:${{ github.ref_name }} From 5459d42f4a32d3ee90beba0b13a3b0ed45cdadde Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:29:59 +0700 Subject: [PATCH 16/28] Expose internal hybrid evidence tracker to tests --- Properties/AssemblyInfo.cs | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Properties/AssemblyInfo.cs 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")] From a43cf444992f68026b6f35baa9b01eb9955e0591 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:30:07 +0000 Subject: [PATCH 17/28] Make ARIEC hybrid authority exclusive in monitor planning [hybrid-authority-clean] --- Services/Iec61850MonitorRuntime.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index edcf3306..7caec5a9 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -400,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.Client.CanUseHybridReportPlanner(device); + session.ReportSetupPending = hasHybridAuthority || plans.Count > 0; session.ReportSetupNotBeforeUtc = DateTime.UtcNow.AddMilliseconds(350); session.ReportSetupDeadlineUtc = DateTime.UtcNow.AddMilliseconds(1500); ResetPollQueue(session); @@ -419,7 +422,7 @@ public async Task> StartMonitoringAsync( device.RefreshComputed(); Log("INFO", device.Name, - $"Fast live start: points={session.Points.Count}, legacy compatibility plan(s)={plans.Count}, ARIEC hybrid authority={(session.Client.CanUseHybridReportPlanner(device) ? "available" : "unavailable")}, 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), @@ -649,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, From 8c99499981aeabc82fedf2a1be7ec95e92158fca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:30:40 +0700 Subject: [PATCH 18/28] Remove one-shot hybrid authority cleanup workflow --- .../agent-hybrid-authority-cleanup.yml | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/agent-hybrid-authority-cleanup.yml diff --git a/.github/workflows/agent-hybrid-authority-cleanup.yml b/.github/workflows/agent-hybrid-authority-cleanup.yml deleted file mode 100644 index d30a8de1..00000000 --- a/.github/workflows/agent-hybrid-authority-cleanup.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Agent guarded hybrid authority cleanup - -on: - push: - branches: - - agent/consume-hybrid-report-planner-p23 - -permissions: - contents: write - -jobs: - cleanup-runtime: - if: ${{ !contains(github.event.head_commit.message, '[hybrid-authority-clean]') }} - runs-on: ubuntu-latest - steps: - - name: Checkout exact branch head - uses: actions/checkout@v4 - with: - ref: ${{ github.ref_name }} - fetch-depth: 0 - - - name: Apply exact guarded cleanup - run: python .agent/apply_hybrid_authority_cleanup.py - - - name: Commit guarded cleanup - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Services/Iec61850MonitorRuntime.cs - if git diff --cached --quiet; then - echo "Authority cleanup produced no diff; refusing an empty commit." - exit 1 - fi - git commit -m "Make ARIEC hybrid authority exclusive in monitor planning [hybrid-authority-clean]" - git push origin HEAD:${{ github.ref_name }} From 14d1dc68daee29c716a481a9b53f048174516035 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:30:50 +0700 Subject: [PATCH 19/28] Remove guarded hybrid authority cleanup helper --- .agent/apply_hybrid_authority_cleanup.py | 42 ------------------------ 1 file changed, 42 deletions(-) delete mode 100644 .agent/apply_hybrid_authority_cleanup.py diff --git a/.agent/apply_hybrid_authority_cleanup.py b/.agent/apply_hybrid_authority_cleanup.py deleted file mode 100644 index 523d94c6..00000000 --- a/.agent/apply_hybrid_authority_cleanup.py +++ /dev/null @@ -1,42 +0,0 @@ -from pathlib import Path - -path = Path("Services/Iec61850MonitorRuntime.cs") -text = path.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one source match, found {count}") - text = text.replace(old, new, 1) - -replace_once( - """ var plans = Iec61850ReportPlanner.BuildPlans(device, session.Points.Values); - session.PendingReportPlans = plans; - session.ReportSetupPending = plans.Count > 0 || session.Client.CanUseHybridReportPlanner(device);""", - """ var hasHybridAuthority = session.Client.CanUseHybridReportPlanner(device); - var plans = hasHybridAuthority - ? Array.Empty() - : Iec61850ReportPlanner.BuildPlans(device, session.Points.Values); - session.PendingReportPlans = plans; - session.ReportSetupPending = hasHybridAuthority || plans.Count > 0;""", - "do-not-run-legacy-planner-under-engine-authority") - -replace_once( - """ $"Fast live start: points={session.Points.Count}, legacy compatibility plan(s)={plans.Count}, ARIEC hybrid authority={(session.Client.CanUseHybridReportPlanner(device) ? "available" : "unavailable")}, 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.");""", - "reuse-authority-decision") - -replace_once( - """ var recoveryWillRun = !result.UsedDynamicDataSet && - plan.AllowDynamicDataSetWrites && - plan.Bindings.Count > 0;""", - """ var recoveryWillRun = !plan.IsEngineAuthoritative && - !result.UsedDynamicDataSet && - plan.AllowDynamicDataSetWrites && - plan.Bindings.Count > 0;""", - "engine-plan-zero-coverage-log-must-not-promise-legacy-recovery") - -path.write_text(text, encoding="utf-8") -print("Applied guarded hybrid authority cleanup.") From 162e2af5a1ac426e1f8bfd3928dd79030f17cbb3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:31:12 +0700 Subject: [PATCH 20/28] Characterize ARIEC hybrid physical evidence semantics --- .../HybridReportPhysicalValidationTests.cs | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs diff --git a/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs new file mode 100644 index 00000000..6af16e29 --- /dev/null +++ b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs @@ -0,0 +1,187 @@ +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 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 + }; +} From 35ba3744903ddd3f479d4c319dbd0b0c3470748a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:33:12 +0700 Subject: [PATCH 21/28] Strengthen hybrid physical activation evidence --- Models/NativeHybridReportAcquisitionModels.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Models/NativeHybridReportAcquisitionModels.cs b/Models/NativeHybridReportAcquisitionModels.cs index 9311119e..d6d4005e 100644 --- a/Models/NativeHybridReportAcquisitionModels.cs +++ b/Models/NativeHybridReportAcquisitionModels.cs @@ -64,6 +64,10 @@ public sealed class HybridReportPhysicalValidationPlan 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; } From 2869aa773922277de75169f4e7b0a7676406df90 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:33:42 +0700 Subject: [PATCH 22/28] Record static and dynamic activation details --- Services/HybridReportPhysicalValidationTracker.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Services/HybridReportPhysicalValidationTracker.cs b/Services/HybridReportPhysicalValidationTracker.cs index 9994b3d6..841e959e 100644 --- a/Services/HybridReportPhysicalValidationTracker.cs +++ b/Services/HybridReportPhysicalValidationTracker.cs @@ -15,6 +15,10 @@ private sealed class PlanState 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); @@ -56,6 +60,10 @@ public void RecordActivation(ReportControlPlan plan, NativeReportMonitorStartRes 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); } @@ -114,6 +122,10 @@ public HybridReportPhysicalValidationSnapshot Capture(Iec61850MonitorDevice devi 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, From 7cf61a3ba88d7ac5d62fda868cea375b2ef8b733 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:38:48 +0700 Subject: [PATCH 23/28] Add guarded hybrid execution revalidation helper --- .agent/apply_hybrid_execution_revalidation.py | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 .agent/apply_hybrid_execution_revalidation.py diff --git a/.agent/apply_hybrid_execution_revalidation.py b/.agent/apply_hybrid_execution_revalidation.py new file mode 100644 index 00000000..ae97a41b --- /dev/null +++ b/.agent/apply_hybrid_execution_revalidation.py @@ -0,0 +1,163 @@ +from pathlib import Path + +path = Path("Services/NativeIec61850Client.HybridReporting.cs") +text = path.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one source match, found {count}") + text = text.replace(old, new, 1) + +replace_once( + """ private sealed record AuthoritativeHybridSubscription( + ArMms.MmsReportSubscriptionPlan Subscription, + ArMms.MmsHybridAcquisitionKind Kind);""", + """ private sealed record AuthoritativeHybridSubscription( + ArMms.MmsHybridAcquisitionKind Kind, + string ReportControlReference, + Iec61850SignalCatalogDocument Catalog, + IReadOnlyList Signals, + ArMms.MmsHybridReportAcquisitionOptions Options);""", + "store-revalidation-authority") + +replace_once( + """ var enginePlan = ArMms.MmsHybridReportAcquisitionPlanner.Build( + catalog, + descriptorPoints.Keys, + discovery.ReportInventory, + availability, + discovery.IedDirectory, + new ArMms.MmsHybridReportAcquisitionOptions + { + AllowStaticBrcb = true, + AllowStaticUrcb = true, + AllowDynamicBrcb = device.AllowDynamicDataSetWrites, + AllowDynamicUrcb = device.AllowDynamicDataSetWrites, + AllowCallerOwnedReports = true, + AllowPollingFallback = true, + RequireExactAvailabilityEvidence = true + });""", + """ 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);""", + "planner-options-are-reusable-for-revalidation") + +replace_once( + """ _authoritativeHybridSubscriptions[appPlan.PlanId] = new AuthoritativeHybridSubscription( + segment.ReportPlan, + segment.Kind);""", + """ _authoritativeHybridSubscriptions[appPlan.PlanId] = new AuthoritativeHybridSubscription( + segment.Kind, + segment.ReportControlReference, + catalog, + segment.Signals.ToArray(), + plannerOptions);""", + "store-segment-revalidation-inputs") + +replace_once( + """ var subscription = authoritative.Subscription; + if (!subscription.IsReady)""", + """ // 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)""", + "fresh-engine-revalidation-before-write") + +replace_once( + """ private static string LiteralReference(string? reference) + => (reference ?? string.Empty).Trim();""", + """ 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();""", + "literal-reference-equality-helper") + +path.write_text(text, encoding="utf-8") +print("Applied guarded fresh ARIEC execution revalidation.") From a013a88d5c817363df97d10fb5eacba357773b36 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:40:10 +0700 Subject: [PATCH 24/28] Run guarded hybrid execution revalidation --- .../agent-hybrid-execution-revalidation.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/agent-hybrid-execution-revalidation.yml diff --git a/.github/workflows/agent-hybrid-execution-revalidation.yml b/.github/workflows/agent-hybrid-execution-revalidation.yml new file mode 100644 index 00000000..91899cbd --- /dev/null +++ b/.github/workflows/agent-hybrid-execution-revalidation.yml @@ -0,0 +1,37 @@ +name: Agent guarded hybrid execution revalidation + +on: + push: + branches: + - agent/consume-hybrid-report-planner-p23 + +permissions: + contents: write + +jobs: + revalidate-execution: + if: ${{ !contains(github.event.head_commit.message, '[hybrid-execution-revalidated]') }} + runs-on: ubuntu-latest + steps: + - name: Checkout exact branch head + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Apply exact guarded revalidation patch + run: python .agent/apply_hybrid_execution_revalidation.py + + - name: Commit guarded revalidation + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Services/NativeIec61850Client.HybridReporting.cs + if git diff --cached --quiet; then + echo "Execution revalidation produced no diff; refusing an empty commit." + exit 1 + fi + git commit -m "Revalidate ARIEC hybrid plan before execution [hybrid-execution-revalidated]" + git push origin HEAD:${{ github.ref_name }} From d51bf7f2daab6872095f8554bba9dbe555752508 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:40:19 +0000 Subject: [PATCH 25/28] Revalidate ARIEC hybrid plan before execution [hybrid-execution-revalidated] --- .../NativeIec61850Client.HybridReporting.cs | 111 +++++++++++++++--- 1 file changed, 96 insertions(+), 15 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs index f3d36b67..79b78f97 100644 --- a/Services/NativeIec61850Client.HybridReporting.cs +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -12,8 +12,11 @@ namespace ArIED61850Tester.Services; public sealed partial class NativeIec61850Client { private sealed record AuthoritativeHybridSubscription( - ArMms.MmsReportSubscriptionPlan Subscription, - ArMms.MmsHybridAcquisitionKind Kind); + ArMms.MmsHybridAcquisitionKind Kind, + string ReportControlReference, + Iec61850SignalCatalogDocument Catalog, + IReadOnlyList Signals, + ArMms.MmsHybridReportAcquisitionOptions Options); private readonly Dictionary _authoritativeHybridSubscriptions = new(StringComparer.OrdinalIgnoreCase); @@ -125,22 +128,26 @@ public async Task BuildHybridReportPlansAsync( 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, - new ArMms.MmsHybridReportAcquisitionOptions - { - AllowStaticBrcb = true, - AllowStaticUrcb = true, - AllowDynamicBrcb = device.AllowDynamicDataSetWrites, - AllowDynamicUrcb = device.AllowDynamicDataSetWrites, - AllowCallerOwnedReports = true, - AllowPollingFallback = true, - RequireExactAvailabilityEvidence = true - }); + plannerOptions); var reportPlans = new List(); foreach (var segment in enginePlan.Segments.Where(segment => segment.IsReportBacked)) @@ -176,8 +183,11 @@ public async Task BuildHybridReportPlansAsync( }; _authoritativeHybridSubscriptions[appPlan.PlanId] = new AuthoritativeHybridSubscription( - segment.ReportPlan, - segment.Kind); + segment.Kind, + segment.ReportControlReference, + catalog, + segment.Signals.ToArray(), + plannerOptions); reportPlans.Add(appPlan); } @@ -290,7 +300,75 @@ public async Task StartHybridReportMonitorAsync( }; } - var subscription = authoritative.Subscription; + // 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 @@ -418,6 +496,9 @@ private static IEnumerable EngineReferenceCandidates(Iec61850SignalDescr .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(); } From 37e4e47f3a5de5d15729441a57726aed3645af4b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:41:21 +0700 Subject: [PATCH 26/28] Remove one-shot hybrid execution revalidation workflow --- .../agent-hybrid-execution-revalidation.yml | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/agent-hybrid-execution-revalidation.yml diff --git a/.github/workflows/agent-hybrid-execution-revalidation.yml b/.github/workflows/agent-hybrid-execution-revalidation.yml deleted file mode 100644 index 91899cbd..00000000 --- a/.github/workflows/agent-hybrid-execution-revalidation.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Agent guarded hybrid execution revalidation - -on: - push: - branches: - - agent/consume-hybrid-report-planner-p23 - -permissions: - contents: write - -jobs: - revalidate-execution: - if: ${{ !contains(github.event.head_commit.message, '[hybrid-execution-revalidated]') }} - runs-on: ubuntu-latest - steps: - - name: Checkout exact branch head - uses: actions/checkout@v4 - with: - ref: ${{ github.ref_name }} - fetch-depth: 0 - - - name: Apply exact guarded revalidation patch - run: python .agent/apply_hybrid_execution_revalidation.py - - - name: Commit guarded revalidation - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Services/NativeIec61850Client.HybridReporting.cs - if git diff --cached --quiet; then - echo "Execution revalidation produced no diff; refusing an empty commit." - exit 1 - fi - git commit -m "Revalidate ARIEC hybrid plan before execution [hybrid-execution-revalidated]" - git push origin HEAD:${{ github.ref_name }} From 20e8a3038c8c2629bee510e892c1b4c9c774175c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:41:31 +0700 Subject: [PATCH 27/28] Remove guarded hybrid execution revalidation helper --- .agent/apply_hybrid_execution_revalidation.py | 163 ------------------ 1 file changed, 163 deletions(-) delete mode 100644 .agent/apply_hybrid_execution_revalidation.py diff --git a/.agent/apply_hybrid_execution_revalidation.py b/.agent/apply_hybrid_execution_revalidation.py deleted file mode 100644 index ae97a41b..00000000 --- a/.agent/apply_hybrid_execution_revalidation.py +++ /dev/null @@ -1,163 +0,0 @@ -from pathlib import Path - -path = Path("Services/NativeIec61850Client.HybridReporting.cs") -text = path.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one source match, found {count}") - text = text.replace(old, new, 1) - -replace_once( - """ private sealed record AuthoritativeHybridSubscription( - ArMms.MmsReportSubscriptionPlan Subscription, - ArMms.MmsHybridAcquisitionKind Kind);""", - """ private sealed record AuthoritativeHybridSubscription( - ArMms.MmsHybridAcquisitionKind Kind, - string ReportControlReference, - Iec61850SignalCatalogDocument Catalog, - IReadOnlyList Signals, - ArMms.MmsHybridReportAcquisitionOptions Options);""", - "store-revalidation-authority") - -replace_once( - """ var enginePlan = ArMms.MmsHybridReportAcquisitionPlanner.Build( - catalog, - descriptorPoints.Keys, - discovery.ReportInventory, - availability, - discovery.IedDirectory, - new ArMms.MmsHybridReportAcquisitionOptions - { - AllowStaticBrcb = true, - AllowStaticUrcb = true, - AllowDynamicBrcb = device.AllowDynamicDataSetWrites, - AllowDynamicUrcb = device.AllowDynamicDataSetWrites, - AllowCallerOwnedReports = true, - AllowPollingFallback = true, - RequireExactAvailabilityEvidence = true - });""", - """ 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);""", - "planner-options-are-reusable-for-revalidation") - -replace_once( - """ _authoritativeHybridSubscriptions[appPlan.PlanId] = new AuthoritativeHybridSubscription( - segment.ReportPlan, - segment.Kind);""", - """ _authoritativeHybridSubscriptions[appPlan.PlanId] = new AuthoritativeHybridSubscription( - segment.Kind, - segment.ReportControlReference, - catalog, - segment.Signals.ToArray(), - plannerOptions);""", - "store-segment-revalidation-inputs") - -replace_once( - """ var subscription = authoritative.Subscription; - if (!subscription.IsReady)""", - """ // 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)""", - "fresh-engine-revalidation-before-write") - -replace_once( - """ private static string LiteralReference(string? reference) - => (reference ?? string.Empty).Trim();""", - """ 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();""", - "literal-reference-equality-helper") - -path.write_text(text, encoding="utf-8") -print("Applied guarded fresh ARIEC execution revalidation.") From 0057fd1b87b93186bec486a5d7452db36f9cab55 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 05:43:07 +0700 Subject: [PATCH 28/28] Characterize dynamic setup evidence separately from report traffic --- .../HybridReportPhysicalValidationTests.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs index 6af16e29..4c5c83ca 100644 --- a/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs +++ b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs @@ -111,6 +111,44 @@ public void RealReportSlice_IsRecordedSeparatelyFromActivationAndPollingFallback 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() {