diff --git a/src/AR.Iec61850/Mms/MmsDynamicReportShadowProductionAcceptancePolicy.cs b/src/AR.Iec61850/Mms/MmsDynamicReportShadowProductionAcceptancePolicy.cs new file mode 100644 index 0000000..30e1cea --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsDynamicReportShadowProductionAcceptancePolicy.cs @@ -0,0 +1,83 @@ +namespace AR.Iec61850.Mms; + +/// +/// Production-facing bridge from a successful G2.6 shadow result to the persisted +/// production-acceptance contract. Unlike the generic shadow bridge, this policy +/// refuses to mark QualityRegressionPassed unless actual paired quality AND device +/// timestamp evidence were observed on report and independent-poll sides. +/// +public static class MmsDynamicReportShadowProductionAcceptancePolicy +{ + public static MmsDynamicReportProductionAcceptance BuildStrict( + MmsDynamicReportShadowVerificationEvidence evidence, + MmsDynamicReportShadowVerificationResult shadow, + bool controlRegressionPassed, + bool staticReportingRegressionPassed) + { + ArgumentNullException.ThrowIfNull(evidence); + ArgumentNullException.ThrowIfNull(shadow); + ArgumentException.ThrowIfNullOrWhiteSpace(evidence.EvidenceId); + if (!shadow.IsSuccess) + throw new InvalidOperationException("A failed G2.6 shadow cannot be converted into strict production acceptance evidence."); + + var baseAcceptance = MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance( + evidence, + shadow, + controlRegressionPassed, + staticReportingRegressionPassed); + + var qualityEvidenceObserved = HasPairedQualityEvidence(evidence); + var timestampEvidenceObserved = HasPairedTimestampEvidence(evidence); + + return new MmsDynamicReportProductionAcceptance + { + FieldEvidenceId = baseAcceptance.FieldEvidenceId, + ObservedAtUtc = baseAcceptance.ObservedAtUtc, + ControlRegressionPassed = baseAcceptance.ControlRegressionPassed, + StaticReportingRegressionPassed = baseAcceptance.StaticReportingRegressionPassed, + DynamicInformationReportRegressionPassed = baseAcceptance.DynamicInformationReportRegressionPassed, + PollingAuthorityGuardPassed = baseAcceptance.PollingAuthorityGuardPassed, + ReconnectRegressionPassed = baseAcceptance.ReconnectRegressionPassed, + QualityRegressionPassed = baseAcceptance.QualityRegressionPassed && + qualityEvidenceObserved && + timestampEvidenceObserved, + NoRepeatedMutationLoopPassed = baseAcceptance.NoRepeatedMutationLoopPassed + }; + } + + public static bool HasPairedQualityEvidence(MmsDynamicReportShadowVerificationEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + return evidence.ReportObservations.Any(report => + { + var reportQuality = Normalize(report.Quality); + if (reportQuality.Length == 0) + return false; + + return evidence.PollObservations.Any(poll => + poll.DataSetIndex == report.DataSetIndex && + SameReference(poll.MemberReference, report.MemberReference) && + Normalize(poll.Quality).Length > 0); + }); + } + + public static bool HasPairedTimestampEvidence(MmsDynamicReportShadowVerificationEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + return evidence.ReportObservations.Any(report => + report.DeviceTimestampUtc.HasValue && + evidence.PollObservations.Any(poll => + poll.DataSetIndex == report.DataSetIndex && + SameReference(poll.MemberReference, report.MemberReference) && + poll.DeviceTimestampUtc.HasValue)); + } + + private static bool SameReference(string? left, string? right) + => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeReference(string? reference) + => Normalize(reference).Replace('$', '.'); + + private static string Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowProductionAcceptancePolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowProductionAcceptancePolicyTests.cs new file mode 100644 index 0000000..02ece42 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowProductionAcceptancePolicyTests.cs @@ -0,0 +1,164 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsG26ShadowProductionAcceptancePolicyTests +{ + [Fact] + public void StrictProductionAcceptance_AllPassed_WhenPairedQualityAndTimestampEvidenceExist() + { + var evidence = Evidence(includeQuality: true, includeTimestamp: true); + var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate( + evidence, + Options(requireQuality: true, requireTimestamp: true)); + + Assert.True(shadow.IsSuccess, shadow.Summary); + + var acceptance = MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict( + evidence, + shadow, + controlRegressionPassed: true, + staticReportingRegressionPassed: true); + + Assert.True(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedQualityEvidence(evidence)); + Assert.True(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedTimestampEvidence(evidence)); + Assert.True(acceptance.QualityRegressionPassed); + Assert.True(acceptance.AllPassed); + } + + [Fact] + public void OptionalQualityShadow_CannotAccidentallyGrantProductionQualityGateWithoutEvidence() + { + var evidence = Evidence(includeQuality: false, includeTimestamp: false); + var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate( + evidence, + Options(requireQuality: false, requireTimestamp: false)); + + Assert.True(shadow.IsSuccess, shadow.Summary); + + var generic = MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance( + evidence, + shadow, + controlRegressionPassed: true, + staticReportingRegressionPassed: true); + var strict = MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict( + evidence, + shadow, + controlRegressionPassed: true, + staticReportingRegressionPassed: true); + + Assert.True(generic.QualityRegressionPassed); + Assert.False(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedQualityEvidence(evidence)); + Assert.False(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedTimestampEvidence(evidence)); + Assert.False(strict.QualityRegressionPassed); + Assert.False(strict.AllPassed); + } + + [Fact] + public void QualityWithoutTimestamp_RemainsProductionIneligible() + { + var evidence = Evidence(includeQuality: true, includeTimestamp: false); + var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate( + evidence, + Options(requireQuality: true, requireTimestamp: false)); + + Assert.True(shadow.IsSuccess, shadow.Summary); + var strict = MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict( + evidence, + shadow, + controlRegressionPassed: true, + staticReportingRegressionPassed: true); + + Assert.True(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedQualityEvidence(evidence)); + Assert.False(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedTimestampEvidence(evidence)); + Assert.False(strict.QualityRegressionPassed); + Assert.False(strict.AllPassed); + } + + [Fact] + public void FailedShadow_CannotBuildStrictProductionAcceptance() + { + var evidence = Evidence(includeQuality: true, includeTimestamp: true) with + { + ReportObservations = [] + }; + var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate( + evidence, + Options(requireQuality: true, requireTimestamp: true)); + + Assert.False(shadow.IsSuccess); + Assert.Throws(() => + MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict( + evidence, + shadow, + controlRegressionPassed: true, + staticReportingRegressionPassed: true)); + } + + private static MmsDynamicReportShadowVerificationEvidence Evidence(bool includeQuality, bool includeTimestamp) + { + var quality = includeQuality ? "good" : string.Empty; + DateTimeOffset? deviceTimestamp = includeTimestamp ? Time(100) : null; + return new MmsDynamicReportShadowVerificationEvidence + { + EvidenceId = "g2.6-strict-production-test", + ObservedAtUtc = Time(1000), + MemberReferences = [Member], + ReportObservations = + [ + new MmsDynamicReportShadowReportObservation + { + DataSetIndex = 0, + MemberReference = Member, + Value = "Open", + Quality = quality, + DeviceTimestampUtc = deviceTimestamp, + ReceivedAtUtc = Time(110), + SequenceNumber = 10 + } + ], + PollObservations = + [ + new MmsDynamicReportShadowPollObservation + { + DataSetIndex = 0, + MemberReference = Member, + Value = "Closed", + Quality = quality, + DeviceTimestampUtc = includeTimestamp ? Time(90) : null, + ReadAtUtc = Time(90) + }, + new MmsDynamicReportShadowPollObservation + { + DataSetIndex = 0, + MemberReference = Member, + Value = "Open", + Quality = quality, + DeviceTimestampUtc = deviceTimestamp, + ReadAtUtc = Time(120) + } + ], + ReconnectAttempts = 1, + SuccessfulReconnects = 1, + ReportResubscriptionsAfterReconnect = 1, + PollReferenceRecoveriesAfterReconnect = 1, + DynamicActivationAttempts = 2 + }; + } + + private static MmsDynamicReportShadowVerificationOptions Options(bool requireQuality, bool requireTimestamp) + => new() + { + MinimumReportEdges = 1, + MaximumReportToPollLag = TimeSpan.FromSeconds(5), + MaximumPollTransitionToReportLag = TimeSpan.FromSeconds(5), + MaximumDeviceTimestampDelta = TimeSpan.FromMilliseconds(250), + RequireQualityEvidence = requireQuality, + RequireDeviceTimestampEvidence = requireTimestamp, + RequireReconnectCycle = true, + MaximumDynamicActivationAttemptsPerAssociation = 1 + }; + + private const string Member = "LD0/LN0$ST$Pos$stVal"; + private static DateTimeOffset Time(int milliseconds) => DateTimeOffset.UnixEpoch.AddMilliseconds(milliseconds); +}