Skip to content
Draft
191 changes: 24 additions & 167 deletions Services/NativeIec61850Client.HybridReporting.P4.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,184 +7,41 @@ namespace ArIED61850Tester.Services;
public sealed partial class NativeIec61850Client
{
/// <summary>
/// P4 recovery path used only after an engine-authoritative static report can no longer
/// be activated from fresh association evidence. Static planning is disabled for this
/// recovery pass so the same signal must either obtain a dynamic BRCB/URCB attempt or
/// return an explicit engine reason explaining why polling is the final fallback.
/// P6.1 baseline-safety compatibility hook.
///
/// P4 originally converted a failed static activation into a brand-new dynamic
/// DataSet/RCB write attempt. That changed the proven pre-P0 failure semantics and made
/// one static problem capable of mutating another RCB or destabilizing the association.
/// Static failure is now isolated again: no dynamic DataSet is created, no alternate RCB
/// is written, and bounded MMS polling remains the fallback for the affected signal set.
///
/// The method name is retained temporarily so existing call-sites stay source-compatible;
/// its behavior is deliberately fail-closed and side-effect free.
/// </summary>
private async Task<NativeReportMonitorStartResult> TryStartDynamicRecoveryAfterStaticFailureP4Async(
private Task<NativeReportMonitorStartResult> TryStartDynamicRecoveryAfterStaticFailureP4Async(
ReportControlPlan appPlan,
AuthoritativeHybridSubscription authoritative,
ArMms.MmsDiscoveryResult discovery,
ArMms.MmsRcbAvailabilityResult freshAvailability,
string staticFailure,
CancellationToken cancellationToken)
{
// P6: a previous real dynamic write failure is field evidence that this device must
// not receive another temporary DataSet/RCB write in the current application run.
// Static reporting remains eligible on every fresh association; only this recovery
// path is circuit-broken so reconnect cannot become a dynamic-write retry loop.
if (!string.IsNullOrWhiteSpace(appPlan.RelayId) &&
DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId, out var circuitReason))
{
return new NativeReportMonitorStartResult
{
IsSuccess = false,
PlanId = appPlan.PlanId,
Message = $"{staticFailure} P6 dynamic recovery is circuit-broken after a previous real activation failure ({circuitReason}); MMS polling is the bounded fallback for this residual signal set.",
UsedDynamicDataSet = true,
DynamicAttempted = false,
DynamicAttemptState = "Skipped",
FailureReason = "DynamicWriteCircuitOpen",
PollingFallbackReason = "DynamicWriteCircuitOpen"
};
}

if (!authoritative.Options.AllowDynamicBrcb && !authoritative.Options.AllowDynamicUrcb)
{
return new NativeReportMonitorStartResult
{
IsSuccess = false,
PlanId = appPlan.PlanId,
Message = $"{staticFailure} Dynamic recovery is disabled by policy; MMS polling is the final fallback.",
DynamicAttemptState = "Skipped",
FailureReason = "StaticActivationFailed",
PollingFallbackReason = ArMms.MmsHybridPollingFallbackReason.DynamicDisabledByPolicy.ToString()
};
}
_ = authoritative;
_ = discovery;
_ = freshAvailability;
_ = cancellationToken;

var dynamicOnlyOptions = new ArMms.MmsHybridReportAcquisitionOptions
return Task.FromResult(new NativeReportMonitorStartResult
{
AllowStaticBrcb = false,
AllowStaticUrcb = false,
AllowDynamicBrcb = authoritative.Options.AllowDynamicBrcb,
AllowDynamicUrcb = authoritative.Options.AllowDynamicUrcb,
AllowCallerOwnedReports = false,
AllowPollingFallback = true,
RequireExactAvailabilityEvidence = authoritative.Options.RequireExactAvailabilityEvidence,
MaxDynamicMembersPerReport = authoritative.Options.MaxDynamicMembersPerReport,
MaxDynamicReportPlans = authoritative.Options.MaxDynamicReportPlans
};

var recovery = ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build(
authoritative.Catalog,
authoritative.Signals,
discovery.ReportInventory,
freshAvailability,
discovery.IedDirectory,
_session.LastNegotiatedCapabilities,
dynamicOnlyOptions);
var attemptEvidence = ArMms.MmsHybridDynamicAttemptEvidenceBuilder.Build(recovery, dynamicOnlyOptions);
var dynamicSegment = recovery.AcquisitionPlan.Segments.FirstOrDefault(segment =>
segment.ReportPlan is not null &&
segment.Kind is ArMms.MmsHybridAcquisitionKind.DynamicBrcb or ArMms.MmsHybridAcquisitionKind.DynamicUrcb);

if (dynamicSegment?.ReportPlan is null)
{
var fallbackReasons = attemptEvidence
.Where(item => item.DynamicAttemptDisposition == ArMms.MmsHybridDynamicAttemptDisposition.Skipped)
.Select(item => item.PollingFallbackReason.ToString())
.Where(reason => !reason.Equals("None", StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var reason = fallbackReasons.Length == 0
? ArMms.MmsHybridPollingFallbackReason.DynamicPlanUnavailableAfterCapabilityQualification.ToString()
: string.Join(",", fallbackReasons);

return new NativeReportMonitorStartResult
{
IsSuccess = false,
PlanId = appPlan.PlanId,
Message = $"{staticFailure} ARIEC P4 dynamic recovery produced no ready dynamic report segment; MMS polling is now the final fallback. reason={reason}.",
DynamicAttempted = false,
DynamicAttemptState = "Skipped",
FailureReason = "StaticActivationFailed",
PollingFallbackReason = reason,
Warnings = recovery.Warnings
.Concat(recovery.Blockers)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray()
};
}

var subscription = dynamicSegment.ReportPlan;
var coveredReferences = ExtractSubscriptionMemberReferences(subscription.Members);
var attempt = await RunMmsOperationAsync(
() => _session.StartPersistentReportMonitorWithAttemptEvidenceAsync(
subscription,
triggerGeneralInterrogation: true,
deleteDynamicDataSetOnStop: true,
discovery.IedDirectory,
cancellationToken),
cancellationToken).ConfigureAwait(false);
var start = attempt.StartResult;
var warnings = start.Warnings
.Concat(subscription.Warnings)
.Concat(attempt.CleanupWarnings)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();

if (!attempt.IsSuccess || start.Session is null)
{
if (attempt.DynamicAttempted && !string.IsNullOrWhiteSpace(appPlan.RelayId))
{
var failure = attempt.FailureReason.ToString();
if (string.IsNullOrWhiteSpace(failure) || failure.Equals("None", StringComparison.OrdinalIgnoreCase))
failure = "DynamicRecoveryActivationFailed";
DynamicWriteCircuitByDevice[appPlan.RelayId] = failure;
}

return new NativeReportMonitorStartResult
{
IsSuccess = false,
PlanId = appPlan.PlanId,
Message = $"{staticFailure} Dynamic recovery was {(attempt.DynamicAttempted ? "attempted" : "not attempted")} and failed: {start.Message}",
SubscriptionSummary = subscription.Summary,
MemberCount = subscription.Members.Count,
WriteStepCount = start.WriteSteps.Count,
UsedDynamicDataSet = true,
DynamicAttempted = attempt.DynamicAttempted,
DynamicAttemptState = attempt.DynamicAttemptState.ToString(),
FailureReason = attempt.FailureReason.ToString(),
PollingFallbackReason = attempt.DynamicAttempted ? "DynamicActivationFailed" : "DynamicActivationNotAttempted",
CleanupAttempted = attempt.CleanupAttempted,
CleanupSucceeded = attempt.CleanupSucceeded,
CoveredReferences = coveredReferences,
Warnings = warnings
};
}

appPlan.EngineAcquisitionKind = dynamicSegment.Kind.ToString();
appPlan.AllowDynamicDataSetWrites = true;
appPlan.Buffered = dynamicSegment.Kind == ArMms.MmsHybridAcquisitionKind.DynamicBrcb;
appPlan.Mode = $"ARIEC Hybrid • {dynamicSegment.Kind} • P4 static recovery";
appPlan.Status = $"{dynamicSegment.Kind} active after static recovery";
if (!string.IsNullOrWhiteSpace(start.Session.ReportControl.Reference))
appPlan.ReportControlReference = start.Session.ReportControl.Reference;
if (!string.IsNullOrWhiteSpace(start.Session.Plan.DataSetReference))
appPlan.DataSetReference = start.Session.Plan.DataSetReference;

_reportMonitorSessions[appPlan.PlanId] = start.Session;
_reportMonitorCoverage[appPlan.PlanId] = coveredReferences;

return new NativeReportMonitorStartResult
{
IsSuccess = true,
IsSuccess = false,
PlanId = appPlan.PlanId,
Message = $"ARIEC P4 recovered failed static acquisition with {dynamicSegment.Kind}. {start.Message}",
SubscriptionSummary = subscription.Summary,
MemberCount = subscription.Members.Count,
WriteStepCount = start.WriteSteps.Count,
UsedDynamicDataSet = true,
DynamicAttempted = true,
DynamicAttemptState = attempt.DynamicAttemptState.ToString(),
FailureReason = string.Empty,
ReportControlReference = appPlan.ReportControlReference,
DataSetReference = appPlan.DataSetReference,
AcquisitionLabel = $"ARIEC Hybrid: {dynamicSegment.Kind} (P4 recovery)",
CoveredReferences = coveredReferences,
Warnings = warnings
};
Message = $"{staticFailure} P6.1 preserved baseline static-failure isolation: no dynamic DataSet/RCB write was attempted; bounded MMS polling remains active for this affected signal set.",
UsedDynamicDataSet = false,
DynamicAttempted = false,
DynamicAttemptState = "Skipped",
FailureReason = "StaticActivationFailed",
PollingFallbackReason = "StaticActivationFailed"
});
}

private static bool IsStaticHybridKind(ArMms.MmsHybridAcquisitionKind kind)
Expand Down
27 changes: 26 additions & 1 deletion Services/NativeIec61850Client.HybridReporting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ public async Task<NativeHybridReportPlanningResult> BuildHybridReportPlansAsync(
reportPlans.Add(appPlan);
}

var activationPlans = OrderHybridActivationPlans(reportPlans);

var polling = enginePlan.Assignments
.Where(assignment => assignment.Kind == ArMms.MmsHybridAcquisitionKind.MmsPollingFallback)
.Select(assignment => FindPointKeyForAssignment(assignment.SignalReference, descriptorPoints))
Expand Down Expand Up @@ -280,6 +282,11 @@ public async Task<NativeHybridReportPlanningResult> BuildHybridReportPlansAsync(
p6Warnings.Add(
$"P6 static inventory bridge mapped {staticInventoryMappedCount} selected point(s) through ARIEC mandatory DataSet member evidence before broad catalog matching.");
}
if (activationPlans.Count > 1 && activationPlans[0].AllowDynamicDataSetWrites && activationPlans.Any(plan => !plan.AllowDynamicDataSetWrites))
{
p6Warnings.Add(
"P6.1 mixed-plan safety: dynamic DataSet mutation is probationed before static RCB activation so a relay-aborting dynamic write cannot orphan an already-armed static RCB. Static coverage priority is unchanged.");
}
if (dynamicWriteCircuitOpen)
{
p6Warnings.Add(
Expand All @@ -304,7 +311,7 @@ public async Task<NativeHybridReportPlanningResult> BuildHybridReportPlansAsync(
Summary = $"{enginePlan.Summary} {associationCapability.Summary}" +
(staticInventoryMappedCount > 0 ? $" Static inventory bridge={staticInventoryMappedCount}." : string.Empty) +
(dynamicWriteCircuitOpen ? " Dynamic writes circuit-broken after field failure evidence." : string.Empty),
ReportPlans = reportPlans,
ReportPlans = activationPlans,
PollingPointKeys = polling,
UncoveredPointKeys = uncovered,
UnmappedPointKeys = unmapped.Select(point => point.PointKey).ToArray(),
Expand All @@ -321,6 +328,24 @@ public async Task<NativeHybridReportPlanningResult> BuildHybridReportPlansAsync(
};
}

internal static IReadOnlyList<ReportControlPlan> OrderHybridActivationPlans(IEnumerable<ReportControlPlan> plans)
{
var materialized = plans.ToArray();
var hasDynamic = materialized.Any(plan => plan.IsEngineAuthoritative && plan.AllowDynamicDataSetWrites);
var hasStatic = materialized.Any(plan => plan.IsEngineAuthoritative && !plan.AllowDynamicDataSetWrites);
if (!hasDynamic || !hasStatic)
return materialized;

// Coverage precedence remains static -> dynamic -> polling. This ordering controls
// only activation side effects. A dynamic DefineNamedVariableList write is the risky
// operation on IEDs that abort the association. Run that probation before arming the
// already-proven static RCB so a failed dynamic attempt cannot leave that static RCB
// transiently enabled/owned and then unavailable to the immediate reconnect.
return materialized
.OrderByDescending(plan => plan.IsEngineAuthoritative && plan.AllowDynamicDataSetWrites)
.ToArray();
}

public async Task<NativeReportMonitorStartResult> StartHybridReportMonitorAsync(
ReportControlPlan plan,
CancellationToken cancellationToken)
Expand Down
6 changes: 3 additions & 3 deletions engines/ARIEC61850.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
"commit": "2a932e183931eb65c775fe01cf8a47bf8a9af458",
"sourcePullRequest": 86,
"purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, and PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation."
"commit": "1d052c3919ca1fdf043747d858dab9b6c384e827",
"sourcePullRequest": 87,
"purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation, and PR #87 restores baseline-safe static precedence by keeping freshly verified populated static RCBs visible to the stable planner while capability qualification remains an additional gate only for empty RCB slots that require dynamic DataSet/RCB mutation."
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@ public void HybridPlanning_UsesAssociationCapabilityForInitialPlanAndFreshRevali
}

[Fact]
public void EngineLock_PinsReviewedP4EngineWithoutRegressingP3CapabilityEvidence()
public void EngineLock_PinsP61StaticPreservingAttemptAwareEngine()
{
var source = Read("engines/ARIEC61850.lock.json");

Assert.Contains("2a932e183931eb65c775fe01cf8a47bf8a9af458", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("\"sourcePullRequest\": 86", source, StringComparison.Ordinal);
Assert.Contains("1d052c3919ca1fdf043747d858dab9b6c384e827", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("\"sourcePullRequest\": 87", source, StringComparison.Ordinal);
Assert.Contains("DefineNamedVariableList", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("DeleteNamedVariableList", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("TrgOps", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("dynamic-attempt", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("rollback", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("baseline-safe static precedence", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("dynamic DataSet/RCB mutation", source, StringComparison.OrdinalIgnoreCase);
}

private static int Count(string source, string value)
Expand Down
Loading
Loading