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
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
namespace AR.Iec61850.Mms;

/// <summary>
/// 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.
/// </summary>
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();
}
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(() =>
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);
}