Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions src/AR.Iec61850/Mms/MmsG26FieldCapabilityStableRuntimePlanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
using System.Security.Cryptography;
using System.Text;
using AR.Iec61850.Acse;
using AR.Iec61850.Discovery;

namespace AR.Iec61850.Mms;

/// <summary>
/// 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_<hash> name derived from its exact RCB
/// reference. Member scope, RCB choice, capability gates, limits and ProductionEligible
/// semantics remain owned by MmsGuardedDynamicReportFieldCapabilityRuntimePlanner.
/// </summary>
public static class MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner
{
public static MmsCapabilityAwareHybridReportAcquisitionPlan Build(
Iec61850SignalCatalogDocument catalog,
IEnumerable<Iec61850SignalDescriptor> 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<string, string>(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<string, string> 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<string, string> 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)..];
}
}
Original file line number Diff line number Diff line change
@@ -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
};
}
}