From c1a7f8087a2c02a57d2e5384d750d5325e0462b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 14:28:18 +0700 Subject: [PATCH 1/2] feat(g2.7): bound native dynamic groups to proven envelope --- ...MmsG27NativeEnvelopeBoundRuntimePlanner.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsG27NativeEnvelopeBoundRuntimePlanner.cs diff --git a/src/AR.Iec61850/Mms/MmsG27NativeEnvelopeBoundRuntimePlanner.cs b/src/AR.Iec61850/Mms/MmsG27NativeEnvelopeBoundRuntimePlanner.cs new file mode 100644 index 00000000..15c20888 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsG27NativeEnvelopeBoundRuntimePlanner.cs @@ -0,0 +1,65 @@ +using AR.Iec61850.Acse; +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Mms; + +/// +/// Field-stability wrapper for the P1.7 native per-IED capability runtime. +/// +/// A native dchg + cleanup witness proves that dynamic reporting may be generalized to +/// fresh exact-resolved members, but it does not prove that a larger NamedVariableList than +/// the G2.3 accepted envelope is safe for the IED association. Runtime therefore keeps the +/// general member-capability semantics while capping each individual Dynamic DataSet to the +/// physically proven G2.3 member-count envelope. +/// +/// This planner does not alter identity/witness authorization, RCB availability, deterministic +/// AR_HYB identity, ProductionEligible state, or polling fallback. Those remain owned by the +/// existing stable P1.7 planner and its downstream hybrid planner. +/// +public static class MmsGuardedDynamicReportNativeFieldCapabilityEnvelopeBoundRuntimePlanner +{ + public static MmsCapabilityAwareHybridReportAcquisitionPlan Build( + Iec61850SignalCatalogDocument catalog, + IEnumerable requestedSignals, + MmsReportInventory inventory, + MmsRcbAvailabilityResult availability, + MmsIedModelDirectory liveDirectory, + AcseMmsNegotiatedCapabilities? negotiatedCapabilities, + MmsHybridReportAcquisitionOptions? options, + MmsDynamicReportGuardedRuntimePlanningContext sourceContext, + MmsDynamicReportNativeFieldCapabilityEvidence evidence) + { + ArgumentNullException.ThrowIfNull(sourceContext); + options ??= new MmsHybridReportAcquisitionOptions(); + + var provenEnvelopeMembers = sourceContext.Profile.ProvenSafeMemberCount; + var boundedMembers = provenEnvelopeMembers > 1 + ? Math.Min(options.MaxDynamicMembersPerReport, provenEnvelopeMembers) + : options.MaxDynamicMembersPerReport; + + var boundedOptions = new MmsHybridReportAcquisitionOptions + { + MaxStaticReportPlans = options.MaxStaticReportPlans, + MaxDynamicReportPlans = options.MaxDynamicReportPlans, + MaxDynamicMembersPerReport = boundedMembers, + RequireExactAvailabilityEvidence = options.RequireExactAvailabilityEvidence, + AllowCallerOwnedReports = options.AllowCallerOwnedReports, + AllowStaticBrcb = options.AllowStaticBrcb, + AllowStaticUrcb = options.AllowStaticUrcb, + AllowDynamicBrcb = options.AllowDynamicBrcb, + AllowDynamicUrcb = options.AllowDynamicUrcb, + AllowPollingFallback = options.AllowPollingFallback + }; + + return MmsGuardedDynamicReportNativeFieldCapabilityStableRuntimePlanner.Build( + catalog, + requestedSignals, + inventory, + availability, + liveDirectory, + negotiatedCapabilities, + boundedOptions, + sourceContext, + evidence); + } +} From f9b7649e97c07de7b0c1deffc44a5c126f893093 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 14:28:46 +0700 Subject: [PATCH 2/2] test(g2.7): cap native runtime groups to proven envelope --- .../MmsG27NativeEnvelopeBoundRuntimeTests.cs | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsG27NativeEnvelopeBoundRuntimeTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG27NativeEnvelopeBoundRuntimeTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG27NativeEnvelopeBoundRuntimeTests.cs new file mode 100644 index 00000000..4c81122d --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsG27NativeEnvelopeBoundRuntimeTests.cs @@ -0,0 +1,252 @@ +using AR.Iec61850.Acse; +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsG27NativeEnvelopeBoundRuntimeTests +{ + [Fact] + public void NativeRuntime_GenericLimitAbovePhysicalEnvelope_UsesMoreBoundedGroupsInsteadOfOversizedDataSet() + { + var signals = Enumerable.Range(1, 5).Select(Signal).ToArray(); + var profile = NativeProfile(); + var context = new MmsDynamicReportGuardedRuntimePlanningContext + { + Profile = profile, + CurrentIdentity = Identity() + }; + + var plan = MmsGuardedDynamicReportNativeFieldCapabilityEnvelopeBoundRuntimePlanner.Build( + new Iec61850SignalCatalogDocument + { + IedName = "G27_BOUND_IED", + Source = "P1.7 envelope-bound runtime fixture", + Signals = signals + }, + signals, + DynamicInventory(1, 2, 3), + DynamicAvailability(1, 2, 3), + Directory(1, 2, 3, 4, 5), + new AcseMmsNegotiatedCapabilities + { + IsDecoded = true, + SupportsWrite = true, + SupportsDefineNamedVariableList = true, + SupportsDeleteNamedVariableList = true + }, + new MmsHybridReportAcquisitionOptions + { + MaxDynamicReportPlans = 8, + MaxDynamicMembersPerReport = 64, + AllowStaticBrcb = false, + AllowStaticUrcb = false, + AllowDynamicBrcb = false, + AllowDynamicUrcb = true, + AllowCallerOwnedReports = false, + AllowPollingFallback = true, + RequireExactAvailabilityEvidence = true + }, + context, + NativeEvidence()); + + Assert.False(plan.AutomaticDynamicActivationQuarantined); + Assert.Equal(5, plan.AcquisitionPlan.DynamicUrcbSignalCount); + Assert.Equal(0, plan.AcquisitionPlan.PollingFallbackSignalCount); + + var groups = plan.AcquisitionPlan.Segments + .Where(segment => segment.Kind == MmsHybridAcquisitionKind.DynamicUrcb) + .ToArray(); + + Assert.Equal(3, groups.Length); + Assert.Equal(new[] { 2, 2, 1 }, groups.Select(group => group.Signals.Count).ToArray()); + Assert.All(groups, group => Assert.True(group.Signals.Count <= profile.ProvenSafeMemberCount)); + Assert.All(groups, group => Assert.Contains("AR_HYB_", group.DataSetReference, StringComparison.OrdinalIgnoreCase)); + } + + private static MmsDynamicReportQualificationProfile NativeProfile() + { + var members = new[] { Member(1), Member(2) }; + var assessment = MmsDynamicDataSetQualificationLadder.Assess( + [ + new MmsDynamicDataSetQualificationAttemptEvidence + { + AttemptId = "g27-bound-envelope", + ObservedAtUtc = Time(1), + DataSetReference = ProvenDataSetReference, + MemberReferences = members, + DefineRequestByteCount = 220, + NegotiatedMaxMmsPduSize = 65000, + RequestWithinKnownNegotiatedPdu = true, + IsSuccess = true, + FailureStage = MmsDynamicDataSetQualificationFailureStage.None, + DynamicMutationAttempted = true, + AssociationSurvived = true, + CleanupSucceeded = true + } + ]); + var envelope = MmsDynamicDataSetQualificationLadder.AcceptExactEnvelope(assessment, "g27-bound-envelope"); + var profile = MmsDynamicReportQualificationProfilePolicy.CreateEnvelopeQualifiedProfile( + Identity(), envelope, assessment, null, "g27-bound-envelope-evidence", Time(2)); + + var activated = MmsDynamicReportQualificationProfilePolicy.RecordRcbActivationProof( + profile, + Identity(), + new MmsDynamicRcbActivationProof + { + EvidenceId = ActivationEvidenceId, + ObservedAtUtc = Time(3), + RcbReference = DynamicRcb(1), + DataSetReference = ProvenDataSetReference, + MemberReferences = members, + FreshRcbAvailabilityVerified = true, + DataSetReadbackVerified = true, + RcbDataSetBindingAccepted = true, + RptEnaAccepted = true, + AssociationHealthyAfterActivation = true + }); + + return MmsDynamicReportQualificationProfilePolicy.RecordInformationReportProof( + activated, + Identity(), + new MmsDynamicInformationReportProof + { + EvidenceId = ReportEvidenceId, + ObservedAtUtc = Time(4), + RcbReference = DynamicRcb(1), + DataSetReference = ProvenDataSetReference, + MemberReferences = members, + Kind = MmsDynamicInformationReportKind.DataChange, + ActualInformationReportReceived = true, + ReportIdentityVerified = true, + ExactMemberMappingVerified = true, + AssociationHealthyAfterReport = true, + ReportAuthoritativePointCount = 1 + }); + } + + private static MmsDynamicReportNativeFieldCapabilityEvidence NativeEvidence() + => new() + { + EvidenceId = "g27-bound-native-witness", + ObservedAtUtc = Time(5), + StableIdentityKey = Identity().StableIdentityKey, + ModelFingerprint = Identity().ModelFingerprint, + ProfileRevision = Identity().ProfileRevision, + RcbReference = DynamicRcb(1), + DataSetReference = ProvenDataSetReference, + RcbActivationEvidenceId = ActivationEvidenceId, + InformationReportEvidenceId = ReportEvidenceId, + IncludedMemberReferences = [Member(1)], + ActualInformationReportReceived = true, + DataChangeReasonVerified = true, + GeneralInterrogationDisabled = true, + ExactMemberMappingVerified = true, + AssociationHealthyAfterReport = true, + MonitorCleanupSucceeded = true, + ProofFieldRestoreSucceeded = true, + FreshCleanupClosureSucceeded = true + }; + + private static MmsDynamicReportIedIdentity Identity() + => new() + { + StableIdentityKey = "ied:g27:bound", + ModelFingerprint = "sha256:g27-bound-model", + Manufacturer = "Example", + Model = "BoundIED", + FirmwareRevision = "1.0.0", + ProfileRevision = "cfg-bound" + }; + + private static MmsReportInventory DynamicInventory(params int[] indexes) + { + var inventory = new MmsReportInventory(); + foreach (var index in indexes) + { + inventory.ReportControls.Add(new MmsReportControlCandidate + { + Domain = "LD0", + LogicalNode = "LLN0", + FunctionalConstraint = "RP", + Name = $"Unbuffer{index:00}", + Reference = DynamicRcb(index), + Buffered = false, + DataSetReference = string.Empty, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + EnabledState = "false", + ReservationState = "false", + TriggerOptions = "dchg", + ReportId = $"Unbuffer{index:00}", + ConfRev = "1" + }); + } + return inventory; + } + + private static MmsRcbAvailabilityResult DynamicAvailability(params int[] indexes) + => new() + { + CheckedAtUtc = Time(10), + ReportControls = indexes.Select(index => new MmsRcbAvailabilitySnapshot + { + Reference = DynamicRcb(index), + Domain = "LD0", + LogicalNode = "LLN0", + Name = $"Unbuffer{index:00}", + Mode = "URCB", + Buffered = false, + DataSetReference = string.Empty, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + ReportId = $"Unbuffer{index:00}", + ConfRev = "1", + EnabledState = "false", + ReservationState = "false", + TriggerOptions = "dchg", + DataSetDirectoryRead = false, + DataSetDirectorySuccess = false, + DataSetMemberCount = 0, + DataSetMembers = Array.Empty(), + Availability = MmsRcbOperationalAvailability.NoDataSet, + Confidence = MmsRcbAvailabilityConfidence.Exact, + Reason = "P1.7 exact empty URCB fixture", + Attributes = ["DatSet", "RptEna", "TrgOps", "OptFlds", "Resv"] + }).ToArray() + }; + + private static MmsIedModelDirectory Directory(params int[] indexes) + => new(indexes.Select(index => new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + DataObjectPath = $"Ind{index}.stVal", + MmsItemName = $"GGIO1$ST$Ind{index}$stVal", + Source = "P1.7 synthetic live directory", + Confidence = 100 + })); + + private static Iec61850SignalDescriptor Signal(int index) + => new() + { + DesignReference = $"LD0/GGIO1.Ind{index}.stVal", + ObservedReference = $"LD0/GGIO1.Ind{index}.stVal", + CanonicalMmsReference = Member(index), + EffectiveMmsReference = Member(index), + PrimaryValueReference = $"LD0/GGIO1.Ind{index}.stVal", + PrimaryValueMmsReference = Member(index), + FunctionalConstraint = "ST", + SemanticRole = Iec61850DataAttributeSemanticRole.PrimaryValue, + IsOperationalCandidate = true, + ResolutionStatus = Iec61850SignalCatalogResolutionStatus.DesignAttribute, + LiveStatus = Iec61850DesignLiveStatus.Exact + }; + + private static string DynamicRcb(int index) => $"LD0/LLN0.RP.Unbuffer{index:00}"; + private static string Member(int index) => $"LD0/GGIO1$ST$Ind{index}$stVal"; + private static DateTimeOffset Time(int minutes) => DateTimeOffset.Parse("2026-09-03T00:00:00Z").AddMinutes(minutes); + + private const string ProvenDataSetReference = "LD0/LLN0.AR_G27_BOUND"; + private const string ActivationEvidenceId = "g27-bound-activation"; + private const string ReportEvidenceId = "g27-bound-dchg-report"; +}