diff --git a/src/AR.Iec61850/Mms/MmsG26FieldCapabilityStableRuntimePlanner.cs b/src/AR.Iec61850/Mms/MmsG26FieldCapabilityStableRuntimePlanner.cs new file mode 100644 index 00000000..bc2119b4 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsG26FieldCapabilityStableRuntimePlanner.cs @@ -0,0 +1,206 @@ +using System.Security.Cryptography; +using System.Text; +using AR.Iec61850.Acse; +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Mms; + +/// +/// P1.6 execution-stable wrapper around the field-capability runtime planner. +/// +/// The generic hybrid planner numbers temporary DataSets by plan order. That is sufficient +/// for one planning pass, but ARSAS deliberately revalidates one RCB immediately before each +/// write. Isolating the second dynamic group would otherwise renumber it back to AR_HYB_01, +/// which can collide with an already-active first group in the same logical device. +/// +/// This wrapper changes only temporary dynamic DataSet identity: every dynamic RCB receives +/// a deterministic association-independent AR_HYB_ name derived from its exact RCB +/// reference. Member scope, RCB choice, capability gates, limits and ProductionEligible +/// semantics remain owned by MmsGuardedDynamicReportFieldCapabilityRuntimePlanner. +/// +public static class MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner +{ + public static MmsCapabilityAwareHybridReportAcquisitionPlan Build( + Iec61850SignalCatalogDocument catalog, + IEnumerable requestedSignals, + MmsReportInventory inventory, + MmsRcbAvailabilityResult availability, + MmsIedModelDirectory liveDirectory, + AcseMmsNegotiatedCapabilities? negotiatedCapabilities, + MmsHybridReportAcquisitionOptions? options, + MmsDynamicReportGuardedRuntimePlanningContext sourceContext, + MmsDynamicReportLegacyDataChangeCompatibilityEvidence evidence) + { + var plan = MmsGuardedDynamicReportFieldCapabilityRuntimePlanner.Build( + catalog, + requestedSignals, + inventory, + availability, + liveDirectory, + negotiatedCapabilities, + options, + sourceContext, + evidence); + + return WithStableDynamicDataSetIdentities(plan); + } + + internal static MmsCapabilityAwareHybridReportAcquisitionPlan WithStableDynamicDataSetIdentities( + MmsCapabilityAwareHybridReportAcquisitionPlan source) + { + ArgumentNullException.ThrowIfNull(source); + + var replacements = new Dictionary(StringComparer.OrdinalIgnoreCase); + var segments = source.AcquisitionPlan.Segments + .Select(segment => StabilizeSegment(segment, replacements)) + .ToArray(); + + if (replacements.Count == 0) + return source; + + var assignments = source.AcquisitionPlan.Assignments + .Select(assignment => StabilizeAssignment(assignment, replacements)) + .ToArray(); + + var acquisition = new MmsHybridReportAcquisitionPlan + { + SchemaVersion = source.AcquisitionPlan.SchemaVersion, + Status = source.AcquisitionPlan.Status, + Capability = source.AcquisitionPlan.Capability, + Segments = segments, + Assignments = assignments, + Warnings = source.AcquisitionPlan.Warnings, + Blockers = source.AcquisitionPlan.Blockers + }; + + return new MmsCapabilityAwareHybridReportAcquisitionPlan + { + AcquisitionPlan = acquisition, + AssociationCapability = source.AssociationCapability, + AutomaticDynamicActivationQuarantined = source.AutomaticDynamicActivationQuarantined, + ProductionDynamicActivationAuthorized = source.ProductionDynamicActivationAuthorized, + ProductionDynamicAuthorizationReason = source.ProductionDynamicAuthorizationReason, + ProductionQualifiedDynamicMemberCount = source.ProductionQualifiedDynamicMemberCount, + ProductionQualifiedRcbReference = source.ProductionQualifiedRcbReference, + PolicyWarnings = source.PolicyWarnings + }; + } + + private static MmsHybridAcquisitionSegment StabilizeSegment( + MmsHybridAcquisitionSegment segment, + IDictionary replacements) + { + if (segment.Kind is not (MmsHybridAcquisitionKind.DynamicBrcb or MmsHybridAcquisitionKind.DynamicUrcb) || + segment.ReportPlan is null || + string.IsNullOrWhiteSpace(segment.ReportControlReference) || + string.IsNullOrWhiteSpace(segment.ReportPlan.DataSetReference)) + { + return segment; + } + + var oldReference = segment.ReportPlan.DataSetReference; + var newReference = BuildStableDataSetReference(oldReference, segment.ReportControlReference); + if (oldReference.Equals(newReference, StringComparison.OrdinalIgnoreCase)) + return segment; + + replacements[ReplacementKey(segment.ReportControlReference, oldReference)] = newReference; + var reportPlan = CloneReportPlan(segment.ReportPlan, oldReference, newReference); + + return new MmsHybridAcquisitionSegment + { + Kind = segment.Kind, + Activation = segment.Activation, + ReportPlan = reportPlan, + Availability = segment.Availability, + Signals = segment.Signals, + RequiresWrite = segment.RequiresWrite, + IsAlreadyActiveByCaller = segment.IsAlreadyActiveByCaller, + Reason = segment.Reason + }; + } + + private static MmsReportSubscriptionPlan CloneReportPlan( + MmsReportSubscriptionPlan source, + string oldDataSetReference, + string newDataSetReference) + => new() + { + Mode = source.Mode, + Status = source.Status, + ReportControl = source.ReportControl, + DataSetReference = newDataSetReference, + Members = source.Members, + DynamicPoints = source.DynamicPoints, + Steps = source.Steps + .Select(step => ReplaceOrdinalIgnoreCase(step, oldDataSetReference, newDataSetReference)) + .ToArray(), + Warnings = source.Warnings, + Blockers = source.Blockers, + RcbSelection = source.RcbSelection + }; + + private static MmsHybridSignalAssignment StabilizeAssignment( + MmsHybridSignalAssignment assignment, + IReadOnlyDictionary replacements) + { + if (assignment.Kind is not (MmsHybridAcquisitionKind.DynamicBrcb or MmsHybridAcquisitionKind.DynamicUrcb) || + string.IsNullOrWhiteSpace(assignment.ReportControlReference) || + string.IsNullOrWhiteSpace(assignment.DataSetReference)) + { + return assignment; + } + + if (!replacements.TryGetValue( + ReplacementKey(assignment.ReportControlReference, assignment.DataSetReference), + out var replacement)) + { + return assignment; + } + + return new MmsHybridSignalAssignment + { + SignalReference = assignment.SignalReference, + Kind = assignment.Kind, + ReportControlReference = assignment.ReportControlReference, + DataSetReference = replacement, + IsReportBacked = assignment.IsReportBacked, + Reason = assignment.Reason + }; + } + + private static string BuildStableDataSetReference(string currentDataSetReference, string rcbReference) + { + var current = (currentDataSetReference ?? string.Empty).Trim().Replace('\\', '/'); + var separator = current.LastIndexOf('.'); + if (separator < 0) + return current; + + return current[..(separator + 1)] + BuildStableDataSetName(rcbReference); + } + + internal static string BuildStableDataSetName(string rcbReference) + { + var normalized = MmsRcbAvailabilityEvaluator.NormalizeReference(rcbReference) + .Trim() + .Replace('\\', '/') + .ToUpperInvariant(); + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); + return "AR_HYB_" + Convert.ToHexString(digest)[..12]; + } + + private static string ReplacementKey(string rcbReference, string dataSetReference) + => MmsRcbAvailabilityEvaluator.NormalizeReference(rcbReference).Replace('\\', '/') + "\n" + + (dataSetReference ?? string.Empty).Trim().Replace('\\', '/'); + + private static string ReplaceOrdinalIgnoreCase(string source, string oldValue, string newValue) + { + if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(oldValue)) + return source; + + var index = source.IndexOf(oldValue, StringComparison.OrdinalIgnoreCase); + if (index < 0) + return source; + + return source[..index] + newValue + source[(index + oldValue.Length)..]; + } +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG26FieldCapabilityStableRuntimePlannerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG26FieldCapabilityStableRuntimePlannerTests.cs new file mode 100644 index 00000000..27ba22ec --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsG26FieldCapabilityStableRuntimePlannerTests.cs @@ -0,0 +1,150 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsG26FieldCapabilityStableRuntimePlannerTests +{ + [Fact] + public void SameRcb_KeepsSameDynamicDataSetIdentity_AcrossFullAndIsolatedRevalidation() + { + var full = MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.WithStableDynamicDataSetIdentities( + CapabilityPlan( + DynamicSegment("LD0/LLN0.RP.U01", "LD0/LLN0.AR_HYB_01", "LD0/GGIO1$ST$Ind1$stVal"), + DynamicSegment("LD0/LLN0.RP.U02", "LD0/LLN0.AR_HYB_02", "LD0/GGIO1$ST$Ind2$stVal"))); + + var isolatedSecond = MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.WithStableDynamicDataSetIdentities( + CapabilityPlan( + DynamicSegment("LD0/LLN0.RP.U02", "LD0/LLN0.AR_HYB_01", "LD0/GGIO1$ST$Ind2$stVal"))); + + var fullSegments = full.AcquisitionPlan.Segments.ToArray(); + var fullFirst = fullSegments[0].DataSetReference; + var fullSecond = fullSegments[1].DataSetReference; + var isolated = Assert.Single(isolatedSecond.AcquisitionPlan.Segments).DataSetReference; + + Assert.NotEqual(fullFirst, fullSecond); + Assert.Equal(fullSecond, isolated); + Assert.StartsWith("LD0/LLN0.AR_HYB_", fullSecond, StringComparison.OrdinalIgnoreCase); + Assert.False(fullSecond.EndsWith("_01", StringComparison.OrdinalIgnoreCase)); + Assert.False(fullSecond.EndsWith("_02", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void StableIdentity_ChangesOnlyDynamicDataSetReference_NotRcbOrMembersOrProductionBoundary() + { + var source = CapabilityPlan( + DynamicSegment("LD0/LLN0.RP.U01", "LD0/LLN0.AR_HYB_01", "LD0/GGIO1$ST$Ind1$stVal")); + var result = MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.WithStableDynamicDataSetIdentities(source); + + var before = Assert.Single(source.AcquisitionPlan.Segments); + var after = Assert.Single(result.AcquisitionPlan.Segments); + + Assert.Equal(before.ReportControlReference, after.ReportControlReference); + Assert.Equal(before.ReportPlan!.DynamicPoints.Select(point => point.MmsReference), + after.ReportPlan!.DynamicPoints.Select(point => point.MmsReference)); + Assert.NotEqual(before.DataSetReference, after.DataSetReference); + Assert.False(result.ProductionDynamicActivationAuthorized); + Assert.Equal(source.ProductionDynamicAuthorizationReason, result.ProductionDynamicAuthorizationReason); + } + + [Fact] + public void StaticSegment_IsReturnedUnchanged() + { + var staticSegment = new MmsHybridAcquisitionSegment + { + Kind = MmsHybridAcquisitionKind.StaticUrcb, + Activation = MmsHybridReportActivation.EnableExistingDataSet, + ReportPlan = new MmsReportSubscriptionPlan + { + Mode = MmsReportSubscriptionPlanMode.StaticDataSet, + Status = MmsReportSubscriptionPlanStatus.ReadyRequiresWrite, + ReportControl = Candidate("LD0/LLN0.RP.Static01"), + DataSetReference = "LD0/LLN0.StaticData" + } + }; + var source = CapabilityPlan(staticSegment); + var result = MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.WithStableDynamicDataSetIdentities(source); + + Assert.Same(source, result); + } + + private static MmsCapabilityAwareHybridReportAcquisitionPlan CapabilityPlan(params MmsHybridAcquisitionSegment[] segments) + => new() + { + AcquisitionPlan = new MmsHybridReportAcquisitionPlan + { + Status = MmsHybridAcquisitionPlanStatus.FullReportCoverage, + Segments = segments, + Assignments = segments.SelectMany(segment => segment.Signals.Select(signal => new MmsHybridSignalAssignment + { + SignalReference = signal.EffectiveMmsReference, + Kind = segment.Kind, + ReportControlReference = segment.ReportControlReference, + DataSetReference = segment.DataSetReference, + IsReportBacked = true, + Reason = "fixture" + })).ToArray() + }, + ProductionDynamicActivationAuthorized = false, + ProductionDynamicAuthorizationReason = "ProductionEligible remains separate." + }; + + private static MmsHybridAcquisitionSegment DynamicSegment(string rcb, string dataSet, string member) + { + var point = Point(member); + var signal = new AR.Iec61850.Discovery.Iec61850SignalDescriptor + { + EffectiveMmsReference = member, + CanonicalMmsReference = member, + FunctionalConstraint = "ST", + SemanticRole = AR.Iec61850.Discovery.Iec61850DataAttributeSemanticRole.PrimaryValue, + IsOperationalCandidate = true + }; + return new MmsHybridAcquisitionSegment + { + Kind = MmsHybridAcquisitionKind.DynamicUrcb, + Activation = MmsHybridReportActivation.ConfigureDynamicDataSet, + Signals = [signal], + RequiresWrite = true, + ReportPlan = new MmsReportSubscriptionPlan + { + Mode = MmsReportSubscriptionPlanMode.DynamicDataSet, + Status = MmsReportSubscriptionPlanStatus.ReadyRequiresWrite, + ReportControl = Candidate(rcb), + DataSetReference = dataSet, + DynamicPoints = [point], + Members = [new MmsDataSetDirectoryMember { Domain = point.Domain, MmsItemName = point.MmsItemName }], + Steps = [$"Create dynamic DataSet {dataSet}."] + } + }; + } + + private static MmsReportControlCandidate Candidate(string reference) + => new() + { + Domain = reference.Split('/')[0], + LogicalNode = "LLN0", + FunctionalConstraint = "RP", + Name = reference.Split('.').Last(), + Reference = reference, + Buffered = false, + EnabledState = "false", + ReservationState = "false" + }; + + private static MmsFcResolvedPoint Point(string member) + { + var slash = member.IndexOf('/'); + var domain = member[..slash]; + var item = member[(slash + 1)..]; + return new MmsFcResolvedPoint + { + Domain = domain, + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + DataObjectPath = "Ind.stVal", + MmsItemName = item, + Source = "fixture", + Confidence = 100 + }; + } +}