From 2d9d015c09ed5b824f7ffa3a7f8202a1f0f1d2bb Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:18:01 +0700 Subject: [PATCH 01/39] Add conservative Sampled Values engineering scaling --- .../Measurements/SvEngineeringScaling.cs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs b/src/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs new file mode 100644 index 0000000..ce44038 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs @@ -0,0 +1,181 @@ +namespace AR.Iec61850.SampledValues.Measurements; + +/// +/// Describes how a decoded Sampled Values number is converted into an engineering value. +/// The source and confidence are deliberately explicit so unknown traffic is never labelled A or V by guesswork. +/// +public sealed record SvEngineeringScale +{ + public static SvEngineeringScale RawOnly(string reason) => new() + { + Multiplier = 1.0, + Unit = "count", + Source = SvEngineeringScaleSource.RawOnly, + Confidence = SvEngineeringScaleConfidence.Unknown, + Reason = reason + }; + + public double Multiplier { get; init; } = 1.0; + public double Offset { get; init; } + public string Unit { get; init; } = "count"; + public SvEngineeringScaleSource Source { get; init; } = SvEngineeringScaleSource.RawOnly; + public SvEngineeringScaleConfidence Confidence { get; init; } = SvEngineeringScaleConfidence.Unknown; + public string Reason { get; init; } = string.Empty; + public bool HasEngineeringUnit => Source != SvEngineeringScaleSource.RawOnly; + + public double Apply(double rawValue) => (rawValue * Multiplier) + Offset; +} + +public enum SvEngineeringScaleSource +{ + RawOnly, + + /// + /// Retained for evidence-document compatibility. Generic receive-side analysis no longer activates + /// engineering scaling from packet shape or observed rate alone. + /// + Legacy92LeStyleStructuralInference, + + SclBackedLegacy92LeStyle, + ManualOverride +} + +public enum SvEngineeringScaleConfidence +{ + Unknown, + Inferred, + SclBacked, + DeviceValidated +} + +/// +/// Evidence supplied to the scale resolver. This is intentionally vendor-neutral. +/// Product identity may be retained in an evidence report, but it is not a scaling rule. +/// +public sealed record SvEngineeringScaleEvidence +{ + public string Channel { get; init; } = string.Empty; + public string Kind { get; init; } = string.Empty; + public bool IsSclBound { get; init; } + public bool IsFixedFourCurrentFourVoltageLayout { get; init; } + public int AnalogChannelCount { get; init; } + public int PayloadBytesPerAsdu { get; init; } + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? ObservedSamplesPerSecond { get; init; } +} + +/// +/// Resolves conservative engineering scaling for an explicitly SCL-bound installed-base +/// 4-current/4-voltage protection layout. Packet shape, rate, product name, APPID, MAC address, +/// svID, and amplitude are never sufficient by themselves. +/// +public static class SvEngineeringScaleResolver +{ + private const double CurrentAmperesPerCount = 0.001; + private const double VoltageVoltsPerCount = 0.01; + private const double RateToleranceFraction = 0.02; + + public static SvEngineeringScale Resolve(SvEngineeringScaleEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + + var domain = ResolveDomain(evidence.Channel, evidence.Kind); + if (domain == SvMeasurementDomain.Unknown) + return SvEngineeringScale.RawOnly("The SCL-derived channel semantics do not prove a current or voltage domain."); + + if (!evidence.IsSclBound) + { + return SvEngineeringScale.RawOnly( + "No SCL dataset mapping is bound. Generic packet inspection preserves raw counts and does not infer engineering units."); + } + + var fixedLayout = evidence.IsFixedFourCurrentFourVoltageLayout && + evidence.AnalogChannelCount == 8 && + evidence.PayloadBytesPerAsdu == 64; + if (!fixedLayout) + { + return SvEngineeringScale.RawOnly( + "The SCL-mapped payload is not the fixed 4-current/4-voltage value-quality layout required by this scaling rule."); + } + + if (!HasProtectionRateEvidence(evidence)) + { + return SvEngineeringScale.RawOnly( + "The fixed SCL layout is visible, but declared or observed protection-rate evidence is insufficient for the installed-base scaling rule."); + } + + var reason = + "SCL dataset mapping, fixed 4-current/4-voltage structure, and protection-rate evidence support the installed-base 9-2LE-style scale."; + + return domain switch + { + SvMeasurementDomain.Current => new SvEngineeringScale + { + Multiplier = CurrentAmperesPerCount, + Unit = "A", + Source = SvEngineeringScaleSource.SclBackedLegacy92LeStyle, + Confidence = SvEngineeringScaleConfidence.SclBacked, + Reason = reason + }, + SvMeasurementDomain.Voltage => new SvEngineeringScale + { + Multiplier = VoltageVoltsPerCount, + Unit = "V", + Source = SvEngineeringScaleSource.SclBackedLegacy92LeStyle, + Confidence = SvEngineeringScaleConfidence.SclBacked, + Reason = reason + }, + _ => SvEngineeringScale.RawOnly("Unsupported measurement domain.") + }; + } + + public static SvMeasurementDomain ResolveDomain(string channel, string kind) + { + var normalizedKind = kind?.Trim() ?? string.Empty; + if (normalizedKind.Contains("voltage", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Voltage; + if (normalizedKind.Contains("current", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Current; + + var normalizedChannel = channel?.Trim() ?? string.Empty; + if (normalizedChannel.StartsWith("V", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("TVTR", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("VolSv", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Voltage; + if (normalizedChannel.StartsWith("I", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("TCTR", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("AmpSv", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Current; + + return SvMeasurementDomain.Unknown; + } + + private static bool HasProtectionRateEvidence(SvEngineeringScaleEvidence evidence) + { + if (evidence.DeclaredSampleMode == 0 && evidence.DeclaredSampleRate == 80) + return true; + + if (evidence.DeclaredSampleMode == 1 && + IsNearOneOf(evidence.DeclaredSampleRate, 4_000, 4_800)) + return true; + + return IsNearOneOf(evidence.ObservedSamplesPerSecond, 4_000, 4_800); + } + + private static bool IsNearOneOf(double? value, params double[] candidates) + { + if (!value.HasValue || value.Value <= 0) + return false; + + return candidates.Any(candidate => + Math.Abs(value.Value - candidate) <= candidate * RateToleranceFraction); + } +} + +public enum SvMeasurementDomain +{ + Unknown, + Current, + Voltage +} From ff629a75c7f908c56f5cc87898777171cfb70ed0 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:18:21 +0700 Subject: [PATCH 02/39] Add profile-neutral Sampled Values counter tracker --- .../Measurements/SvSampleCounterTracker.cs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs b/src/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs new file mode 100644 index 0000000..0f84c7b --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs @@ -0,0 +1,156 @@ +namespace AR.Iec61850.SampledValues.Measurements; + +public enum SvSampleCounterTransitionKind +{ + Initial, + Continuous, + NormalWrap, + Gap, + Duplicate, + OutOfOrder, + Restart +} + +public sealed record SvSampleCounterTransition +{ + public SvSampleCounterTransitionKind Kind { get; init; } + public ushort Actual { get; init; } + public ushort? Previous { get; init; } + public ushort? Expected { get; init; } + public ushort? Wrap { get; init; } + public int MissingSamples { get; init; } + public string Detail { get; init; } = string.Empty; + public bool IsAnomaly => Kind is SvSampleCounterTransitionKind.Gap or + SvSampleCounterTransitionKind.Duplicate or + SvSampleCounterTransitionKind.OutOfOrder; +} + +/// +/// Stateful, profile-neutral smpCnt tracker. A known modulus is preferred; unknown traffic falls back to the ushort modulus. +/// Explicit restart hints prevent a publisher restart from being misclassified as out-of-order traffic. +/// +public sealed class SvSampleCounterTracker +{ + private ushort? _last; + private ushort? _expected; + + public ushort? Last => _last; + public ushort? Expected => _expected; + + public void Reset() + { + _last = null; + _expected = null; + } + + public SvSampleCounterTransition Observe(ushort actual, ushort? wrap, bool restartHint = false) + { + if (restartHint && _last.HasValue) + { + var previous = _last; + _last = actual; + _expected = Next(actual, wrap); + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Restart, + Actual = actual, + Previous = previous, + Expected = null, + Wrap = wrap, + Detail = "The counter state was reset by trusted restart/configuration evidence." + }; + } + + if (!_last.HasValue) + { + _last = actual; + _expected = Next(actual, wrap); + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Initial, + Actual = actual, + Wrap = wrap, + Detail = "Initial sample counter observation." + }; + } + + var previousValue = _last.Value; + if (actual == previousValue) + { + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Duplicate, + Actual = actual, + Previous = previousValue, + Expected = _expected, + Wrap = wrap, + Detail = $"Duplicate smpCnt {actual}." + }; + } + + var expectedValue = _expected ?? Next(previousValue, wrap); + if (actual == expectedValue) + { + var wrapped = actual < previousValue; + _last = actual; + _expected = Next(actual, wrap); + return new SvSampleCounterTransition + { + Kind = wrapped ? SvSampleCounterTransitionKind.NormalWrap : SvSampleCounterTransitionKind.Continuous, + Actual = actual, + Previous = previousValue, + Expected = expectedValue, + Wrap = wrap, + Detail = wrapped ? "Normal smpCnt wrap." : "Continuous smpCnt transition." + }; + } + + var modulus = ResolveModulus(wrap); + var forward = DistanceForward(expectedValue, actual, modulus); + var backward = DistanceForward(actual, expectedValue, modulus); + var isForwardGap = forward > 0 && forward < backward; + + _last = actual; + _expected = Next(actual, wrap); + + if (isForwardGap) + { + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Gap, + Actual = actual, + Previous = previousValue, + Expected = expectedValue, + Wrap = wrap, + MissingSamples = forward, + Detail = $"smpCnt advanced from expected {expectedValue} to {actual}; {forward} sample(s) were not observed." + }; + } + + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.OutOfOrder, + Actual = actual, + Previous = previousValue, + Expected = expectedValue, + Wrap = wrap, + Detail = $"smpCnt {actual} is behind expected {expectedValue}." + }; + } + + private static ushort Next(ushort value, ushort? wrap) + { + if (wrap is > 1) + return (ushort)((value + 1) % wrap.Value); + return unchecked((ushort)(value + 1)); + } + + private static int ResolveModulus(ushort? wrap) + => wrap is > 1 ? wrap.Value : ushort.MaxValue + 1; + + private static int DistanceForward(ushort from, ushort to, int modulus) + { + var distance = ((int)to - from) % modulus; + return distance < 0 ? distance + modulus : distance; + } +} From 1af9eb7a13f05d6111a9a2b9ac951051f1635dca Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:18:54 +0700 Subject: [PATCH 03/39] Add evidence-driven Sampled Values timebase resolver --- .../Measurements/SvTimebaseResolver.cs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs b/src/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs new file mode 100644 index 0000000..824acf0 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs @@ -0,0 +1,181 @@ +namespace AR.Iec61850.SampledValues.Measurements; + +public sealed record SvTimebaseEvidence +{ + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? ObservedSamplesPerSecond { get; init; } + public bool IsFixedLegacyProtectionLayout { get; init; } + public double? TrustedNominalFrequencyHz { get; init; } +} + +public sealed record SvTimebaseResolution +{ + public double? NominalFrequencyHz { get; init; } + public int? SamplesPerCycle { get; init; } + public ushort? SampleCounterWrap { get; init; } + public SvTimebaseSource Source { get; init; } = SvTimebaseSource.Unknown; + public string Reason { get; init; } = string.Empty; + public bool IsResolved => NominalFrequencyHz.HasValue && SamplesPerCycle.HasValue; +} + +public enum SvTimebaseSource +{ + Unknown, + TrustedContext, + DeclaredSamplesPerPeriod, + DeclaredSamplesPerSecond, + ObservedLegacyProtectionRate +} + +/// +/// Resolves frequency, samples-per-cycle and sample-counter wrap without silently assuming 50 Hz. +/// +public static class SvTimebaseResolver +{ + private static readonly double[] NominalFrequencyCandidates = [50.0, 60.0]; + private const double FrequencyToleranceHz = 1.0; + private const double RateToleranceFraction = 0.02; + + public static SvTimebaseResolution Resolve(SvTimebaseEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + + if (evidence.TrustedNominalFrequencyHz is > 0 && + TryResolveSamplesPerCycle(evidence, evidence.TrustedNominalFrequencyHz.Value, out var trustedSamplesPerCycle, out var trustedWrap)) + { + return new SvTimebaseResolution + { + NominalFrequencyHz = evidence.TrustedNominalFrequencyHz, + SamplesPerCycle = trustedSamplesPerCycle, + SampleCounterWrap = trustedWrap, + Source = SvTimebaseSource.TrustedContext, + Reason = "Nominal frequency was supplied by trusted configuration context." + }; + } + + if (evidence.DeclaredSampleMode == 0 && evidence.DeclaredSampleRate is > 0) + { + var samplesPerCycle = evidence.DeclaredSampleRate.Value; + var estimatedFrequency = evidence.ObservedSamplesPerSecond.HasValue + ? SnapNominalFrequency(evidence.ObservedSamplesPerSecond.Value / samplesPerCycle) + : null; + var wrap = estimatedFrequency.HasValue + ? ToCounterWrap(samplesPerCycle * estimatedFrequency.Value) + : null; + + return new SvTimebaseResolution + { + NominalFrequencyHz = estimatedFrequency, + SamplesPerCycle = samplesPerCycle, + SampleCounterWrap = wrap, + Source = SvTimebaseSource.DeclaredSamplesPerPeriod, + Reason = estimatedFrequency.HasValue + ? "Samples-per-period was declared and nominal frequency was confirmed by the observed rate." + : "Samples-per-period was declared; nominal frequency remains unknown until rate evidence is available." + }; + } + + if (evidence.DeclaredSampleMode == 1 && evidence.DeclaredSampleRate is > 0) + { + var samplesPerSecond = evidence.DeclaredSampleRate.Value; + var legacy = ResolveLegacyProtectionRate(samplesPerSecond, evidence.IsFixedLegacyProtectionLayout); + if (legacy is not null) + return legacy with { Source = SvTimebaseSource.DeclaredSamplesPerSecond }; + + return new SvTimebaseResolution + { + SampleCounterWrap = ToCounterWrap(samplesPerSecond), + Source = SvTimebaseSource.DeclaredSamplesPerSecond, + Reason = "Samples-per-second was declared, but samples-per-cycle cannot be inferred safely for an unknown profile." + }; + } + + if (evidence.ObservedSamplesPerSecond is > 0) + { + var legacy = ResolveLegacyProtectionRate(evidence.ObservedSamplesPerSecond.Value, evidence.IsFixedLegacyProtectionLayout); + if (legacy is not null) + return legacy with { Source = SvTimebaseSource.ObservedLegacyProtectionRate }; + } + + return new SvTimebaseResolution + { + Source = SvTimebaseSource.Unknown, + Reason = "No trustworthy timebase could be resolved without making a hidden 50/60 Hz assumption." + }; + } + + private static SvTimebaseResolution? ResolveLegacyProtectionRate(double samplesPerSecond, bool fixedLegacyLayout) + { + if (!fixedLegacyLayout) + return null; + + foreach (var nominal in NominalFrequencyCandidates) + { + const int samplesPerCycle = 80; + var expected = nominal * samplesPerCycle; + if (Math.Abs(samplesPerSecond - expected) > expected * RateToleranceFraction) + continue; + + return new SvTimebaseResolution + { + NominalFrequencyHz = nominal, + SamplesPerCycle = samplesPerCycle, + SampleCounterWrap = ToCounterWrap(expected), + Reason = $"The fixed protection layout and observed/declared rate match {samplesPerCycle} samples/cycle at {nominal:0} Hz." + }; + } + + return null; + } + + private static bool TryResolveSamplesPerCycle( + SvTimebaseEvidence evidence, + double nominalFrequencyHz, + out int samplesPerCycle, + out ushort? wrap) + { + samplesPerCycle = 0; + wrap = null; + + if (evidence.DeclaredSampleMode == 0 && evidence.DeclaredSampleRate is > 0) + { + samplesPerCycle = evidence.DeclaredSampleRate.Value; + wrap = ToCounterWrap(samplesPerCycle * nominalFrequencyHz); + return true; + } + + var samplesPerSecond = evidence.DeclaredSampleMode == 1 && evidence.DeclaredSampleRate is > 0 + ? evidence.DeclaredSampleRate.Value + : evidence.ObservedSamplesPerSecond; + if (!samplesPerSecond.HasValue || samplesPerSecond.Value <= 0) + return false; + + var calculated = samplesPerSecond.Value / nominalFrequencyHz; + var rounded = (int)Math.Round(calculated); + if (rounded <= 0 || Math.Abs(calculated - rounded) > 0.05) + return false; + + samplesPerCycle = rounded; + wrap = ToCounterWrap(samplesPerSecond.Value); + return true; + } + + private static double? SnapNominalFrequency(double measured) + { + foreach (var nominal in NominalFrequencyCandidates) + { + if (Math.Abs(measured - nominal) <= FrequencyToleranceHz) + return nominal; + } + + return null; + } + + private static ushort? ToCounterWrap(double samplesPerSecond) + { + if (samplesPerSecond <= 1 || samplesPerSecond > ushort.MaxValue) + return null; + return (ushort)Math.Round(samplesPerSecond); + } +} From c6aab3e9e8f92d335ab93fd9526e6f4a700c7f99 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:19:14 +0700 Subject: [PATCH 04/39] Add explicit Sampled Values measurement ratio context --- .../Measurements/SvMeasurementRatioContext.cs | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Measurements/SvMeasurementRatioContext.cs diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementRatioContext.cs b/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementRatioContext.cs new file mode 100644 index 0000000..160951d --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementRatioContext.cs @@ -0,0 +1,135 @@ +namespace AR.Iec61850.SampledValues.Measurements; + +public enum SvMeasurementValueDomain +{ + Unknown, + RawCount, + PrimaryEngineering, + SecondaryEquivalent +} + +public enum SvRatioSource +{ + Unknown, + Scl, + DeviceConfiguration, + Manual, + DeviceValidated +} + +/// +/// Explicit instrument-transformer context. It is never inferred from vendor identity or nominal amplitude. +/// +public sealed record SvMeasurementRatio +{ + public double PrimaryNominal { get; init; } + public double SecondaryNominal { get; init; } + public string Unit { get; init; } = string.Empty; + public SvRatioSource Source { get; init; } = SvRatioSource.Unknown; + public string Reference { get; init; } = string.Empty; + + public bool IsValid => double.IsFinite(PrimaryNominal) && + double.IsFinite(SecondaryNominal) && + PrimaryNominal > 0 && + SecondaryNominal > 0; + + public double Ratio => IsValid ? PrimaryNominal / SecondaryNominal : double.NaN; + + public double PrimaryToSecondary(double primaryValue) + { + EnsureValid(); + return primaryValue * SecondaryNominal / PrimaryNominal; + } + + public double SecondaryToPrimary(double secondaryValue) + { + EnsureValid(); + return secondaryValue * PrimaryNominal / SecondaryNominal; + } + + private void EnsureValid() + { + if (!IsValid) + throw new InvalidOperationException("A positive primary and secondary nominal value is required for conversion."); + } +} + +public sealed record SvMeasurementDomainValue +{ + public double WireValue { get; init; } + public string Unit { get; init; } = string.Empty; + public SvMeasurementValueDomain WireDomain { get; init; } = SvMeasurementValueDomain.Unknown; + public double? PrimaryValue { get; init; } + public double? SecondaryEquivalentValue { get; init; } + public SvRatioSource RatioSource { get; init; } = SvRatioSource.Unknown; + public string RatioReference { get; init; } = string.Empty; + public string Diagnostic { get; init; } = string.Empty; +} + +/// +/// Produces primary and secondary-equivalent values without silently assuming a CT/VT ratio. +/// Installed-base 9-2LE engineering values are treated as primary only when the caller declares that domain. +/// +public static class SvMeasurementDomainResolver +{ + public static SvMeasurementDomainValue Resolve( + double wireValue, + string unit, + SvMeasurementValueDomain wireDomain, + SvMeasurementRatio? ratio) + { + if (!double.IsFinite(wireValue)) + throw new ArgumentOutOfRangeException(nameof(wireValue), "Measurement value must be finite."); + + if (wireDomain is SvMeasurementValueDomain.Unknown or SvMeasurementValueDomain.RawCount) + { + return new SvMeasurementDomainValue + { + WireValue = wireValue, + Unit = unit, + WireDomain = wireDomain, + Diagnostic = "Primary and secondary values are unavailable because the wire measurement domain is not established." + }; + } + + if (ratio is null || !ratio.IsValid) + { + return new SvMeasurementDomainValue + { + WireValue = wireValue, + Unit = unit, + WireDomain = wireDomain, + PrimaryValue = wireDomain == SvMeasurementValueDomain.PrimaryEngineering ? wireValue : null, + SecondaryEquivalentValue = wireDomain == SvMeasurementValueDomain.SecondaryEquivalent ? wireValue : null, + Diagnostic = "Only the declared wire-domain value is available; no verified CT/VT ratio was supplied." + }; + } + + return wireDomain switch + { + SvMeasurementValueDomain.PrimaryEngineering => new SvMeasurementDomainValue + { + WireValue = wireValue, + Unit = unit, + WireDomain = wireDomain, + PrimaryValue = wireValue, + SecondaryEquivalentValue = ratio.PrimaryToSecondary(wireValue), + RatioSource = ratio.Source, + RatioReference = ratio.Reference, + Diagnostic = "Secondary-equivalent value was calculated from explicit ratio context." + }, + SvMeasurementValueDomain.SecondaryEquivalent => new SvMeasurementDomainValue + { + WireValue = wireValue, + Unit = unit, + WireDomain = wireDomain, + PrimaryValue = ratio.SecondaryToPrimary(wireValue), + SecondaryEquivalentValue = wireValue, + RatioSource = ratio.Source, + RatioReference = ratio.Reference, + Diagnostic = "Primary value was calculated from explicit ratio context." + }, + _ => throw new ArgumentOutOfRangeException(nameof(wireDomain), wireDomain, "Unsupported measurement domain.") + }; + } +} From f6cfff9650fd4559e2b69a599bc151aa81700522 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:19:48 +0700 Subject: [PATCH 05/39] Add semantic Sampled Values quality analysis --- .../Measurements/SvQualityAnalysis.cs | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Measurements/SvQualityAnalysis.cs diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvQualityAnalysis.cs b/src/AR.Iec61850/SampledValues/Measurements/SvQualityAnalysis.cs new file mode 100644 index 0000000..5f15228 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvQualityAnalysis.cs @@ -0,0 +1,250 @@ +using System.Buffers.Binary; + +namespace AR.Iec61850.SampledValues.Measurements; + +public enum SvQualityValidity +{ + Good, + Reserved, + Invalid, + Questionable +} + +public enum SvQualitySeverity +{ + Good, + Information, + Warning, + Bad, + Unknown +} + +public enum SvQualityWordPlacement +{ + AllZero, + TwoByteWord, + HighWord, + LowWord, + Ambiguous +} + +/// +/// Semantic interpretation of the IEC 61850 Quality bit field carried beside a sampled value. +/// Bit 13 is treated as the installed-base 9-2LE Derived extension only when explicitly enabled. +/// +public sealed record SvQualityState +{ + public ushort Word { get; init; } + public SvQualityWordPlacement Placement { get; init; } + public SvQualityValidity Validity { get; init; } + public bool Overflow { get; init; } + public bool OutOfRange { get; init; } + public bool BadReference { get; init; } + public bool Oscillatory { get; init; } + public bool Failure { get; init; } + public bool OldData { get; init; } + public bool Inconsistent { get; init; } + public bool Inaccurate { get; init; } + public bool IsSubstituted { get; init; } + public bool Test { get; init; } + public bool OperatorBlocked { get; init; } + public bool Derived { get; init; } + public bool HasReservedBits { get; init; } + public bool IsEncodingAmbiguous { get; init; } + public SvQualitySeverity Severity { get; init; } + public string Summary { get; init; } = string.Empty; + public IReadOnlyList ActiveFlags { get; init; } = Array.Empty(); + + public bool IsGood => Severity == SvQualitySeverity.Good; + public bool IsStrictlyUsable => Validity == SvQualityValidity.Good && + !Failure && + !BadReference && + !OldData && + !Inconsistent && + !HasReservedBits && + !IsEncodingAmbiguous; +} + +public static class SvQualityDecoder +{ + private const ushort StandardMask = 0x1FFF; + private const ushort LegacyDerivedMask = 0x2000; + private const ushort ReservedMask = 0xC000; + + public static bool TryDecodeHex(string? rawHex, out SvQualityState state, bool allowLegacyDerived = true) + { + state = Unknown("Quality bytes are unavailable."); + if (string.IsNullOrWhiteSpace(rawHex)) + return false; + + try + { + var bytes = Convert.FromHexString(rawHex.Trim()); + state = DecodeNetworkBytes(bytes, allowLegacyDerived); + return true; + } + catch (FormatException) + { + state = Unknown("Quality bytes are not valid hexadecimal data."); + return false; + } + } + + public static SvQualityState DecodeNetworkBytes(ReadOnlySpan bytes, bool allowLegacyDerived = true) + { + if (bytes.Length == 2) + return DecodeWord(BinaryPrimitives.ReadUInt16BigEndian(bytes), SvQualityWordPlacement.TwoByteWord, allowLegacyDerived); + + if (bytes.Length != 4) + return Unknown($"Expected two or four quality bytes, got {bytes.Length}."); + + var high = BinaryPrimitives.ReadUInt16BigEndian(bytes[..2]); + var low = BinaryPrimitives.ReadUInt16BigEndian(bytes[2..]); + + if (high == 0 && low == 0) + return DecodeWord(0, SvQualityWordPlacement.AllZero, allowLegacyDerived); + + var highPlausible = IsPlausible(high, allowLegacyDerived); + var lowPlausible = IsPlausible(low, allowLegacyDerived); + + if (high != 0 && low == 0 && highPlausible) + return DecodeWord(high, SvQualityWordPlacement.HighWord, allowLegacyDerived); + if (low != 0 && high == 0 && lowPlausible) + return DecodeWord(low, SvQualityWordPlacement.LowWord, allowLegacyDerived); + if (highPlausible && !lowPlausible) + return DecodeWord(high, SvQualityWordPlacement.HighWord, allowLegacyDerived); + if (lowPlausible && !highPlausible) + return DecodeWord(low, SvQualityWordPlacement.LowWord, allowLegacyDerived); + + if (high == low && highPlausible) + return DecodeWord(high, SvQualityWordPlacement.HighWord, allowLegacyDerived); + + return new SvQualityState + { + Placement = SvQualityWordPlacement.Ambiguous, + Validity = SvQualityValidity.Reserved, + Severity = SvQualitySeverity.Unknown, + IsEncodingAmbiguous = true, + Summary = $"Quality placement ambiguous (high 0x{high:X4}, low 0x{low:X4})" + }; + } + + public static SvQualityState DecodeWord( + ushort word, + SvQualityWordPlacement placement = SvQualityWordPlacement.TwoByteWord, + bool allowLegacyDerived = true) + { + var validity = (word & 0x0003) switch + { + 0 => SvQualityValidity.Good, + 1 => SvQualityValidity.Reserved, + 2 => SvQualityValidity.Invalid, + _ => SvQualityValidity.Questionable + }; + + var flags = new List(); + AddFlag(flags, word, 2, "overflow"); + AddFlag(flags, word, 3, "out-of-range"); + AddFlag(flags, word, 4, "bad-reference"); + AddFlag(flags, word, 5, "oscillatory"); + AddFlag(flags, word, 6, "failure"); + AddFlag(flags, word, 7, "old-data"); + AddFlag(flags, word, 8, "inconsistent"); + AddFlag(flags, word, 9, "inaccurate"); + AddFlag(flags, word, 10, "substituted"); + AddFlag(flags, word, 11, "test"); + AddFlag(flags, word, 12, "operator-blocked"); + if (allowLegacyDerived) + AddFlag(flags, word, 13, "derived"); + + var hasReservedBits = (word & ReservedMask) != 0 || + (!allowLegacyDerived && (word & LegacyDerivedMask) != 0); + if (hasReservedBits) + flags.Add("reserved-bits"); + + var state = new SvQualityState + { + Word = word, + Placement = placement, + Validity = validity, + Overflow = IsSet(word, 2), + OutOfRange = IsSet(word, 3), + BadReference = IsSet(word, 4), + Oscillatory = IsSet(word, 5), + Failure = IsSet(word, 6), + OldData = IsSet(word, 7), + Inconsistent = IsSet(word, 8), + Inaccurate = IsSet(word, 9), + IsSubstituted = IsSet(word, 10), + Test = IsSet(word, 11), + OperatorBlocked = IsSet(word, 12), + Derived = allowLegacyDerived && IsSet(word, 13), + HasReservedBits = hasReservedBits, + ActiveFlags = flags.ToArray() + }; + + var severity = ResolveSeverity(state); + return state with + { + Severity = severity, + Summary = BuildSummary(state.Validity, state.ActiveFlags) + }; + } + + private static SvQualitySeverity ResolveSeverity(SvQualityState state) + { + if (state.HasReservedBits || + state.Validity is SvQualityValidity.Invalid or SvQualityValidity.Reserved || + state.Failure || + state.BadReference || + state.Inconsistent) + return SvQualitySeverity.Bad; + + if (state.Validity == SvQualityValidity.Questionable || + state.Overflow || + state.OutOfRange || + state.Oscillatory || + state.OldData || + state.Inaccurate || + state.IsSubstituted || + state.Test || + state.OperatorBlocked) + return SvQualitySeverity.Warning; + + if (state.Derived) + return SvQualitySeverity.Information; + + return SvQualitySeverity.Good; + } + + private static bool IsPlausible(ushort word, bool allowLegacyDerived) + { + var allowedMask = (ushort)(StandardMask | (allowLegacyDerived ? LegacyDerivedMask : 0)); + return (word & ~allowedMask) == 0; + } + + private static bool IsSet(ushort word, int bit) => (word & (1 << bit)) != 0; + + private static void AddFlag(ICollection flags, ushort word, int bit, string name) + { + if (IsSet(word, bit)) + flags.Add(name); + } + + private static string BuildSummary(SvQualityValidity validity, IReadOnlyCollection flags) + { + var validityText = validity.ToString(); + return flags.Count == 0 + ? validityText + : $"{validityText} · {string.Join(", ", flags)}"; + } + + private static SvQualityState Unknown(string reason) => new() + { + Placement = SvQualityWordPlacement.Ambiguous, + Validity = SvQualityValidity.Reserved, + Severity = SvQualitySeverity.Unknown, + IsEncodingAmbiguous = true, + Summary = reason + }; +} From 365b5db578d631c5fd619b5e3e0f9c7693caff24 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:20:15 +0700 Subject: [PATCH 06/39] Add versioned Sampled Values measurement context --- .../SvMeasurementContextDocument.cs | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Measurements/SvMeasurementContextDocument.cs diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementContextDocument.cs b/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementContextDocument.cs new file mode 100644 index 0000000..f9b85a3 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvMeasurementContextDocument.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AR.Iec61850.SampledValues.Measurements; + +/// +/// User-supplied measurement-domain context for one logical SV stream. +/// The stream key is the evidence identity used by a consumer, not a vendor name. +/// +public sealed record SvStreamMeasurementContext +{ + public string StreamKey { get; init; } = string.Empty; + public string SvId { get; init; } = string.Empty; + public SvMeasurementValueDomain WireDomain { get; init; } = SvMeasurementValueDomain.PrimaryEngineering; + public SvMeasurementValueDomain DisplayDomain { get; init; } = SvMeasurementValueDomain.PrimaryEngineering; + public SvMeasurementRatio? CurrentRatio { get; init; } + public SvMeasurementRatio? VoltageRatio { get; init; } + public string Notes { get; init; } = string.Empty; + public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow; + + public SvMeasurementRatio? ResolveRatio(string kindOrChannel) + { + var text = kindOrChannel?.Trim() ?? string.Empty; + if (text.Contains("voltage", StringComparison.OrdinalIgnoreCase) || + text.StartsWith("V", StringComparison.OrdinalIgnoreCase) || + text.Contains("TVTR", StringComparison.OrdinalIgnoreCase) || + text.Contains("VolSv", StringComparison.OrdinalIgnoreCase)) + return VoltageRatio; + + if (text.Contains("current", StringComparison.OrdinalIgnoreCase) || + text.StartsWith("I", StringComparison.OrdinalIgnoreCase) || + text.Contains("TCTR", StringComparison.OrdinalIgnoreCase) || + text.Contains("AmpSv", StringComparison.OrdinalIgnoreCase)) + return CurrentRatio; + + return null; + } + + public string Summary + { + get + { + var current = CurrentRatio?.IsValid == true + ? $"I {CurrentRatio.PrimaryNominal:0.###}/{CurrentRatio.SecondaryNominal:0.###} {CurrentRatio.Unit}" + : "I ratio unset"; + var voltage = VoltageRatio?.IsValid == true + ? $"V {VoltageRatio.PrimaryNominal:0.###}/{VoltageRatio.SecondaryNominal:0.###} {VoltageRatio.Unit}" + : "V ratio unset"; + return $"wire {WireDomain} → display {DisplayDomain} · {current} · {voltage}"; + } + } + + public IReadOnlyList Validate() + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(StreamKey)) + errors.Add("StreamKey is required."); + if (WireDomain is not (SvMeasurementValueDomain.PrimaryEngineering or SvMeasurementValueDomain.SecondaryEquivalent)) + errors.Add("WireDomain must be PrimaryEngineering or SecondaryEquivalent."); + if (DisplayDomain is not (SvMeasurementValueDomain.PrimaryEngineering or SvMeasurementValueDomain.SecondaryEquivalent)) + errors.Add("DisplayDomain must be PrimaryEngineering or SecondaryEquivalent."); + + ValidateRatio(CurrentRatio, "current", "A", errors); + ValidateRatio(VoltageRatio, "voltage", "V", errors); + return errors; + } + + private static void ValidateRatio( + SvMeasurementRatio? ratio, + string label, + string expectedUnit, + ICollection errors) + { + if (ratio is null) + return; + if (!ratio.IsValid) + errors.Add($"The {label} ratio requires positive finite primary and secondary nominal values."); + if (!string.Equals(ratio.Unit, expectedUnit, StringComparison.OrdinalIgnoreCase)) + errors.Add($"The {label} ratio unit must be {expectedUnit}."); + if (ratio.Source == SvRatioSource.Unknown) + errors.Add($"The {label} ratio source must be explicit."); + } +} + +public sealed record SvMeasurementContextDocument +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + public DateTimeOffset ExportedAt { get; init; } = DateTimeOffset.UtcNow; + public IReadOnlyList Streams { get; init; } + = Array.Empty(); + + public IReadOnlyList Validate() + { + var errors = new List(); + if (SchemaVersion != CurrentSchemaVersion) + errors.Add($"Unsupported measurement-context schema version {SchemaVersion}; expected {CurrentSchemaVersion}."); + + var duplicateKeys = Streams + .Where(item => !string.IsNullOrWhiteSpace(item.StreamKey)) + .GroupBy(item => item.StreamKey, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToArray(); + errors.AddRange(duplicateKeys.Select(key => $"Duplicate measurement context for stream key '{key}'.")); + + foreach (var stream in Streams) + { + var label = string.IsNullOrWhiteSpace(stream.SvId) ? stream.StreamKey : stream.SvId; + errors.AddRange(stream.Validate().Select(error => $"{label}: {error}")); + } + return errors; + } +} + +public static class SvMeasurementContextSerializer +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + public static string ToJson(SvMeasurementContextDocument document) + { + ArgumentNullException.ThrowIfNull(document); + EnsureValid(document); + return JsonSerializer.Serialize(document, Options); + } + + public static SvMeasurementContextDocument FromJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + throw new InvalidDataException("Measurement-context JSON is empty."); + + SvMeasurementContextDocument document; + try + { + document = JsonSerializer.Deserialize(json, Options) + ?? throw new InvalidDataException("Measurement-context JSON did not contain a document."); + } + catch (JsonException ex) + { + throw new InvalidDataException($"Measurement-context JSON is invalid: {ex.Message}", ex); + } + + EnsureValid(document); + return document; + } + + private static void EnsureValid(SvMeasurementContextDocument document) + { + var errors = document.Validate(); + if (errors.Count > 0) + throw new InvalidDataException("Measurement-context validation failed: " + string.Join(" ", errors)); + } +} From 0b0c37de9001b04aaa64f15671be0155499afe6e Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:20:42 +0700 Subject: [PATCH 07/39] Add generic Sampled Values payload inspector --- .../Analysis/SvGenericPayloadInspector.cs | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs diff --git a/src/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs b/src/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs new file mode 100644 index 0000000..6b201c7 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs @@ -0,0 +1,162 @@ +using System.Buffers.Binary; + +namespace AR.Iec61850.SampledValues.Analysis; + +/// +/// Describes only the structural position of a 32-bit word in seqOfData. +/// These roles do not assert channel semantics, engineering units, or IEC 61850 quality meaning. +/// +public enum SvGenericPayloadWordRole +{ + StandaloneWord, + FirstWordInEightByteGroup, + SecondWordInEightByteGroup +} + +/// +/// One four-byte big-endian word from an SV sample payload, exposed through multiple numeric views. +/// The views are representations of the same bytes and are not automatic semantic interpretations. +/// +public sealed record SvGenericPayloadWord +{ + public int Index { get; init; } + public int ByteOffset { get; init; } + public SvGenericPayloadWordRole StructuralRole { get; init; } + public byte[] RawBytes { get; init; } = []; + public string Hex { get; init; } = string.Empty; + public int SignedInt32 { get; init; } + public uint UnsignedInt32 { get; init; } + public float Float32 { get; init; } + public bool IsFiniteFloat32 => float.IsFinite(Float32); + + public string GenericLabel => $"Word {Index + 1}"; + public string OffsetLabel => $"+0x{ByteOffset:X2}"; +} + +/// +/// Vendor-neutral structural inspection of one ASDU seqOfData payload. +/// It deliberately preserves unknown semantics instead of inventing current/voltage channels. +/// +public sealed record SvGenericPayloadInspection +{ + public int PayloadLength { get; init; } + public int CompleteWordCount { get; init; } + public int TrailingByteCount { get; init; } + public bool IsFourByteAligned { get; init; } + public bool HasEightByteGroupShape { get; init; } + public IReadOnlyList Words { get; init; } + = Array.Empty(); + public byte[] TrailingBytes { get; init; } = []; + public IReadOnlyList Diagnostics { get; init; } + = Array.Empty(); + + public string Summary + { + get + { + if (PayloadLength == 0) + return "Empty seqOfData payload"; + + var grouping = HasEightByteGroupShape + ? $"{PayloadLength / 8} structural 8-byte group(s)" + : $"{CompleteWordCount} complete 32-bit word(s)"; + return TrailingByteCount == 0 + ? $"Raw generic inspection · {PayloadLength} bytes · {grouping}" + : $"Raw generic inspection · {PayloadLength} bytes · {grouping} · {TrailingByteCount} trailing byte(s)"; + } + } +} + +/// +/// Generic seqOfData inspector used when no trusted dataset layout is available. +/// It never labels words as phase channels, voltage, current, quality, primary, secondary, A, or V. +/// +public static class SvGenericPayloadInspector +{ + private const int WordBytes = 4; + private const int StructuralGroupBytes = 8; + + public static SvGenericPayloadInspection Inspect(ReadOnlyMemory payload) + { + var span = payload.Span; + var completeWordCount = span.Length / WordBytes; + var trailingByteCount = span.Length % WordBytes; + var hasEightByteGroupShape = span.Length >= StructuralGroupBytes && span.Length % StructuralGroupBytes == 0; + var words = new SvGenericPayloadWord[completeWordCount]; + + for (var index = 0; index < completeWordCount; index++) + { + var byteOffset = index * WordBytes; + var wordBytes = span.Slice(byteOffset, WordBytes); + var unsigned = BinaryPrimitives.ReadUInt32BigEndian(wordBytes); + words[index] = new SvGenericPayloadWord + { + Index = index, + ByteOffset = byteOffset, + StructuralRole = ResolveRole(index, hasEightByteGroupShape), + RawBytes = wordBytes.ToArray(), + Hex = Convert.ToHexString(wordBytes), + SignedInt32 = unchecked((int)unsigned), + UnsignedInt32 = unsigned, + Float32 = BitConverter.Int32BitsToSingle(unchecked((int)unsigned)) + }; + } + + var trailingBytes = trailingByteCount == 0 + ? Array.Empty() + : span[^trailingByteCount..].ToArray(); + var diagnostics = BuildDiagnostics(span.Length, completeWordCount, trailingByteCount, hasEightByteGroupShape); + + return new SvGenericPayloadInspection + { + PayloadLength = span.Length, + CompleteWordCount = completeWordCount, + TrailingByteCount = trailingByteCount, + IsFourByteAligned = trailingByteCount == 0, + HasEightByteGroupShape = hasEightByteGroupShape, + Words = words, + TrailingBytes = trailingBytes, + Diagnostics = diagnostics + }; + } + + private static SvGenericPayloadWordRole ResolveRole(int wordIndex, bool hasEightByteGroupShape) + { + if (!hasEightByteGroupShape) + return SvGenericPayloadWordRole.StandaloneWord; + return wordIndex % 2 == 0 + ? SvGenericPayloadWordRole.FirstWordInEightByteGroup + : SvGenericPayloadWordRole.SecondWordInEightByteGroup; + } + + private static IReadOnlyList BuildDiagnostics( + int payloadLength, + int completeWordCount, + int trailingByteCount, + bool hasEightByteGroupShape) + { + var diagnostics = new List(); + if (payloadLength == 0) + { + diagnostics.Add("seqOfData is empty."); + return diagnostics; + } + + diagnostics.Add( + $"Generic inspection exposed {completeWordCount} complete big-endian 32-bit word(s) without assigning channel names or engineering units."); + + if (hasEightByteGroupShape) + { + diagnostics.Add( + "The payload has an 8-byte grouping shape. This is structural evidence only; the second word is not treated as IEC 61850 quality until SCL or an explicit standard layout resolves it."); + } + + if (trailingByteCount > 0) + { + diagnostics.Add( + $"The payload contains {trailingByteCount} trailing byte(s) after the last complete 32-bit word; those bytes are preserved verbatim."); + } + + return diagnostics; + } +} From 74d8fe6c2e8a2e04cb1c7dc310ccc9af6d3b4fa1 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:21:02 +0700 Subject: [PATCH 08/39] Add generic Sampled Values ASDU inspector --- .../Analysis/SvGenericAsduInspector.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs diff --git a/src/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs b/src/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs new file mode 100644 index 0000000..da5ef74 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs @@ -0,0 +1,58 @@ +namespace AR.Iec61850.SampledValues.Analysis; + +/// +/// Generic, vendor-neutral view of one Sampled Values ASDU. +/// It separates fields observed on the wire from semantic dataset interpretation. +/// +public sealed record SvGenericAsduInspection +{ + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public ushort SampleCount { get; init; } + public uint ConfigurationRevision { get; init; } + public bool HasReferenceTime { get; init; } + public byte SampleSynchronization { get; init; } + public ushort? SampleRate { get; init; } + public ushort? SampleMode { get; init; } + public SvGenericPayloadInspection Payload { get; init; } = new(); + + public string MappingState => string.IsNullOrWhiteSpace(DataSetReference) + ? "Dataset reference not present · semantic mapping unresolved" + : "Dataset reference observed · import or bind SCL to resolve ordered semantics"; + + public string OptionalFieldSummary + { + get + { + var fields = new List(); + if (HasReferenceTime) + fields.Add("refrTm"); + if (SampleRate.HasValue) + fields.Add("smpRate"); + if (SampleMode.HasValue) + fields.Add("smpMod"); + return fields.Count == 0 ? "No optional ASDU fields observed" : string.Join(", ", fields); + } + } +} + +public static class SvGenericAsduInspector +{ + public static SvGenericAsduInspection Inspect(SampledValueAsdu asdu) + { + ArgumentNullException.ThrowIfNull(asdu); + + return new SvGenericAsduInspection + { + SvId = asdu.SvId, + DataSetReference = asdu.DataSetReference, + SampleCount = asdu.SampleCount, + ConfigurationRevision = asdu.ConfigurationRevision, + HasReferenceTime = asdu.ReferenceTime is not null, + SampleSynchronization = asdu.SampleSynchronization, + SampleRate = asdu.SampleRate, + SampleMode = asdu.SampleMode, + Payload = SvGenericPayloadInspector.Inspect(asdu.SamplePayload) + }; + } +} From 206ac9d207e0674163fddd2c7cd3ece229f1e399 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:21:55 +0700 Subject: [PATCH 09/39] Test unified Sampled Values analysis core --- .../SampledValues/SvAnalysisCoreTests.cs | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs new file mode 100644 index 0000000..92d9f52 --- /dev/null +++ b/tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs @@ -0,0 +1,207 @@ +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Analysis; +using AR.Iec61850.SampledValues.Measurements; + +namespace AR.Iec61850.Tests.SampledValues; + +public sealed class SvAnalysisCoreTests +{ + [Fact] + public void GenericPayloadInspectorPreservesRawRepresentationsWithoutSemantics() + { + var inspection = SvGenericPayloadInspector.Inspect(new byte[] + { + 0x00, 0x00, 0x03, 0xE8, + 0xFF, 0xFF, 0xFC, 0x18 + }); + + Assert.Equal(8, inspection.PayloadLength); + Assert.True(inspection.HasEightByteGroupShape); + Assert.Equal(2, inspection.Words.Count); + Assert.Equal(1000, inspection.Words[0].SignedInt32); + Assert.Equal(-1000, inspection.Words[1].SignedInt32); + Assert.Equal("Word 1", inspection.Words[0].GenericLabel); + Assert.Contains(inspection.Diagnostics, item => item.Contains("structural evidence only", StringComparison.Ordinal)); + } + + [Fact] + public void GenericPayloadInspectorPreservesTrailingBytes() + { + var inspection = SvGenericPayloadInspector.Inspect(new byte[] + { + 0x00, 0x00, 0x00, 0x01, + 0xAA, 0xBB + }); + + Assert.False(inspection.IsFourByteAligned); + Assert.Equal(1, inspection.CompleteWordCount); + Assert.Equal(2, inspection.TrailingByteCount); + Assert.Equal(new byte[] { 0xAA, 0xBB }, inspection.TrailingBytes); + } + + [Fact] + public void GenericAsduInspectorExposesWireFieldsButKeepsMappingUnresolved() + { + var asdu = new SampledValueAsdu + { + SvId = "MU01", + DataSetReference = "MU01/LLN0$Dataset1", + SampleCount = 37, + ConfigurationRevision = 4, + SampleSynchronization = 2, + SampleRate = 4000, + SampleMode = 1, + SamplePayload = new byte[8] + }; + + var inspection = SvGenericAsduInspector.Inspect(asdu); + + Assert.Equal("MU01", inspection.SvId); + Assert.Equal((ushort)37, inspection.SampleCount); + Assert.Contains("bind SCL", inspection.MappingState, StringComparison.OrdinalIgnoreCase); + Assert.Contains("smpRate", inspection.OptionalFieldSummary, StringComparison.Ordinal); + } + + [Fact] + public void ScalingNeverInfersEngineeringUnitsWithoutSclBinding() + { + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = "Ia", + Kind = "Current", + IsSclBound = false, + IsFixedFourCurrentFourVoltageLayout = true, + AnalogChannelCount = 8, + PayloadBytesPerAsdu = 64, + DeclaredSampleMode = 1, + DeclaredSampleRate = 4000 + }); + + Assert.Equal(SvEngineeringScaleSource.RawOnly, scale.Source); + Assert.Equal("count", scale.Unit); + Assert.Equal(1000, scale.Apply(1000)); + } + + [Fact] + public void ScalingRequiresSclLayoutAndProtectionRateEvidence() + { + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = "TCTR1/AmpSv.instMag.i", + Kind = "Current", + IsSclBound = true, + IsFixedFourCurrentFourVoltageLayout = true, + AnalogChannelCount = 8, + PayloadBytesPerAsdu = 64, + DeclaredSampleMode = 1, + DeclaredSampleRate = 4000 + }); + + Assert.Equal(SvEngineeringScaleSource.SclBackedLegacy92LeStyle, scale.Source); + Assert.Equal("A", scale.Unit); + Assert.Equal(1.0, scale.Apply(1000), 9); + } + + [Theory] + [InlineData(4000, 50)] + [InlineData(4800, 60)] + public void TimebaseResolverUsesEvidenceForLegacyProtectionRate(double rate, double frequency) + { + var result = SvTimebaseResolver.Resolve(new SvTimebaseEvidence + { + ObservedSamplesPerSecond = rate, + IsFixedLegacyProtectionLayout = true + }); + + Assert.Equal(frequency, result.NominalFrequencyHz); + Assert.Equal(80, result.SamplesPerCycle); + Assert.Equal((ushort)rate, result.SampleCounterWrap); + } + + [Fact] + public void TimebaseResolverDoesNotInventFrequencyForUnknownLayout() + { + var result = SvTimebaseResolver.Resolve(new SvTimebaseEvidence + { + DeclaredSampleMode = 1, + DeclaredSampleRate = 4000, + IsFixedLegacyProtectionLayout = false + }); + + Assert.Null(result.NominalFrequencyHz); + Assert.Null(result.SamplesPerCycle); + Assert.Equal((ushort)4000, result.SampleCounterWrap); + } + + [Fact] + public void CounterTrackerDistinguishesNormalWrapAndGap() + { + var tracker = new SvSampleCounterTracker(); + tracker.Observe(3998, 4000); + tracker.Observe(3999, 4000); + Assert.Equal(SvSampleCounterTransitionKind.NormalWrap, tracker.Observe(0, 4000).Kind); + + var gap = tracker.Observe(3, 4000); + Assert.Equal(SvSampleCounterTransitionKind.Gap, gap.Kind); + Assert.Equal(2, gap.MissingSamples); + } + + [Fact] + public void QualityDecoderReportsSemanticFlagsAndSeverity() + { + var state = SvQualityDecoder.DecodeWord(1 << 6); + + Assert.True(state.Failure); + Assert.Equal(SvQualityValidity.Good, state.Validity); + Assert.Equal(SvQualitySeverity.Bad, state.Severity); + Assert.Contains("failure", state.ActiveFlags); + } + + [Fact] + public void MeasurementRatioConvertsOnlyFromExplicitContext() + { + var ratio = new SvMeasurementRatio + { + PrimaryNominal = 1000, + SecondaryNominal = 1, + Unit = "A", + Source = SvRatioSource.DeviceConfiguration, + Reference = "reviewed setting" + }; + + var value = SvMeasurementDomainResolver.Resolve( + 500, + "A", + SvMeasurementValueDomain.PrimaryEngineering, + ratio); + + Assert.Equal(500, value.PrimaryValue); + Assert.Equal(0.5, value.SecondaryEquivalentValue, 9); + Assert.Equal(SvRatioSource.DeviceConfiguration, value.RatioSource); + } + + [Fact] + public void MeasurementContextJsonRoundTripsAndRejectsDuplicateKeys() + { + var context = new SvStreamMeasurementContext + { + StreamKey = "stream-1", + SvId = "MU01", + CurrentRatio = new SvMeasurementRatio + { + PrimaryNominal = 1000, + SecondaryNominal = 1, + Unit = "A", + Source = SvRatioSource.Manual + } + }; + var document = new SvMeasurementContextDocument { Streams = [context] }; + + var restored = SvMeasurementContextSerializer.FromJson( + SvMeasurementContextSerializer.ToJson(document)); + Assert.Equal("MU01", Assert.Single(restored.Streams).SvId); + + var duplicate = document with { Streams = [context, context] }; + Assert.Throws(() => SvMeasurementContextSerializer.ToJson(duplicate)); + } +} From 8d60cd614b82b1c1836341bdc1209b4c2b99ef43 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:22:35 +0700 Subject: [PATCH 10/39] Document Sampled Values engine ownership boundary --- docs/SAMPLED_VALUES_CORE_OWNERSHIP.md | 61 +++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/SAMPLED_VALUES_CORE_OWNERSHIP.md diff --git a/docs/SAMPLED_VALUES_CORE_OWNERSHIP.md b/docs/SAMPLED_VALUES_CORE_OWNERSHIP.md new file mode 100644 index 0000000..58e93ef --- /dev/null +++ b/docs/SAMPLED_VALUES_CORE_OWNERSHIP.md @@ -0,0 +1,61 @@ +# Sampled Values core ownership + +## Decision + +`ARIEC61850` is the single source of truth for reusable IEC 61850 protocol, Sampled Values decoding, engineering analysis, measurement-domain conversion, diagnostics, and evidence contracts. + +Derived applications such as ARSVIN Publisher and ArSubsv Subscriber may provide WPF presentation, commands, file dialogs, adapter selection, orchestration, and user-facing formatting. They must not fork or silently modify protocol and measurement behavior in application repositories. + +## Core imported from the ARSVIN application repository + +The following reusable components were present in the embedded `ARSVIN.Engine` copy but absent from `ARIEC61850/main` during the July 2026 ownership audit: + +- evidence-driven engineering scaling; +- profile-neutral `smpCnt` transition tracking; +- timebase resolution without a hidden 50/60 Hz assumption; +- explicit CT/VT ratio and primary/secondary-domain conversion; +- semantic Sampled Values quality decoding; +- versioned stream measurement-context JSON; +- generic ASDU inspection; +- generic `seqOfData` word inspection with preserved trailing bytes. + +These components are vendor-neutral. Manufacturer or product identity must never select a parser, dataset order, scaling rule, quality interpretation, timebase, or health result. + +## Ownership boundary + +### ARIEC61850 + +- Ethernet, VLAN, APPID, BER, APDU, and ASDU codecs; +- Sampled Values frame parsing and building; +- SCL parsing and ordered FCDA/DataSet mapping; +- generic raw payload inspection; +- quality semantics; +- continuity, timebase, scaling, CT/VT, waveform, RMS, phasor, noise-floor, and signal-validity analysis; +- protocol, stream, configuration, and measurement health contracts; +- PCAP and Npcap transport abstractions; +- reusable evidence and comparison models; +- deterministic protocol and engineering tests. + +### Derived applications + +- WPF controls and windows; +- ViewModels and commands; +- selected-stream state and UI refresh policy; +- adapter and file-selection workflows; +- plot rendering and display formatting; +- application settings, branding, and packaging; +- application smoke and presentation tests. + +## Integration contract + +Local application development uses sibling repositories: + +```text +/ +├── ARIEC61850/ +└── arsvin/ +``` + +Application CI must pin and checkout a reviewed ARIEC61850 commit or branch as a sibling. It must not build against a moving, unrecorded engine revision. + +The migration is intentionally staged. Existing embedded source can remain temporarily for comparison, but it must be removed from active project references. New reusable IEC 61850 logic belongs here first. From 7923a282461633252d6377968040b9b6bc24dd95 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:35:26 +0700 Subject: [PATCH 11/39] Fix nullable measurement assertions in SV core tests --- tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs index 92d9f52..eca55d3 100644 --- a/tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs +++ b/tests/AR.Iec61850.Tests/SampledValues/SvAnalysisCoreTests.cs @@ -175,8 +175,8 @@ public void MeasurementRatioConvertsOnlyFromExplicitContext() SvMeasurementValueDomain.PrimaryEngineering, ratio); - Assert.Equal(500, value.PrimaryValue); - Assert.Equal(0.5, value.SecondaryEquivalentValue, 9); + Assert.Equal(500.0, Assert.IsType(value.PrimaryValue)); + Assert.Equal(0.5, Assert.IsType(value.SecondaryEquivalentValue), 9); Assert.Equal(SvRatioSource.DeviceConfiguration, value.RatioSource); } From 09f2822fe04f8eca909c2625d249f51b92117c12 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:02:55 +0700 Subject: [PATCH 12/39] Move Sampled Values publisher quality encoding into core --- .../SampledValues/SampledValueQuality.cs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/SampledValueQuality.cs diff --git a/src/AR.Iec61850/SampledValues/SampledValueQuality.cs b/src/AR.Iec61850/SampledValues/SampledValueQuality.cs new file mode 100644 index 0000000..b40bc10 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampledValueQuality.cs @@ -0,0 +1,85 @@ +using System.Buffers.Binary; + +namespace AR.Iec61850.SampledValues; + +/// +/// Compact IEC 61850 quality bit helper used by Sampled Values payload publishers. +/// It exposes the common simulation controls while preserving the standardized network encoding. +/// +public readonly record struct SampledValueQuality( + SampledValueValidity Validity, + bool Overflow = false, + bool OutOfRange = false, + bool BadReference = false, + bool Oscillatory = false, + bool Failure = false, + bool OldData = false, + bool Inconsistent = false, + bool Inaccurate = false, + bool Test = false, + bool OperatorBlocked = false) +{ + public static SampledValueQuality Good { get; } = new(SampledValueValidity.Good); + public static SampledValueQuality Invalid { get; } = new(SampledValueValidity.Invalid); + public static SampledValueQuality Questionable { get; } = new(SampledValueValidity.Questionable); + public static SampledValueQuality TestGood { get; } = new(SampledValueValidity.Good, Test: true); + public static SampledValueQuality OldDataGood { get; } = new(SampledValueValidity.Good, OldData: true); + public static SampledValueQuality OperatorBlockedGood { get; } = new(SampledValueValidity.Good, OperatorBlocked: true); + + public uint ToUInt32() + { + var value = (uint)Validity; + if (Overflow) value |= 1u << 2; + if (OutOfRange) value |= 1u << 3; + if (BadReference) value |= 1u << 4; + if (Oscillatory) value |= 1u << 5; + if (Failure) value |= 1u << 6; + if (OldData) value |= 1u << 7; + if (Inconsistent) value |= 1u << 8; + if (Inaccurate) value |= 1u << 9; + if (Test) value |= 1u << 11; + if (OperatorBlocked) value |= 1u << 12; + return value; + } + + public byte[] ToBytes(int width = 4) + { + if (width <= 0) + return []; + + Span encoded = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(encoded, ToUInt32()); + + if (width == 4) + return encoded.ToArray(); + + var result = new byte[width]; + encoded[..Math.Min(4, width)].CopyTo(result); + return result; + } + + public static SampledValueQuality FromUInt32(uint encoded) + { + var validity = (SampledValueValidity)(encoded & 0x03); + return new SampledValueQuality( + validity, + Overflow: (encoded & (1u << 2)) != 0, + OutOfRange: (encoded & (1u << 3)) != 0, + BadReference: (encoded & (1u << 4)) != 0, + Oscillatory: (encoded & (1u << 5)) != 0, + Failure: (encoded & (1u << 6)) != 0, + OldData: (encoded & (1u << 7)) != 0, + Inconsistent: (encoded & (1u << 8)) != 0, + Inaccurate: (encoded & (1u << 9)) != 0, + Test: (encoded & (1u << 11)) != 0, + OperatorBlocked: (encoded & (1u << 12)) != 0); + } +} + +public enum SampledValueValidity : uint +{ + Good = 0, + Invalid = 1, + Reserved = 2, + Questionable = 3 +} From 8858e520360fbe0a05cbb0a11b1308809ad8926e Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:03:41 +0700 Subject: [PATCH 13/39] Move Sampled Values publisher evidence contracts into core --- .../SampledValuesPublisherEvidenceReport.cs | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/SampledValuesPublisherEvidenceReport.cs diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPublisherEvidenceReport.cs b/src/AR.Iec61850/SampledValues/SampledValuesPublisherEvidenceReport.cs new file mode 100644 index 0000000..b0d7b91 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampledValuesPublisherEvidenceReport.cs @@ -0,0 +1,160 @@ +using System.Globalization; +using System.Text; + +namespace AR.Iec61850.SampledValues; + +/// +/// One diagnostic item recorded by a Sampled Values publisher preflight or runtime workflow. +/// +public sealed record SampledValuesEvidenceFinding( + string Severity, + string Area, + string Message, + string Detail); + +/// +/// Evidence describing one configured Sampled Values publisher stream. +/// This records transmitter intent and local calculations; it does not prove remote subscription. +/// +public sealed record SampledValuesEvidenceStream( + string SlotName, + bool IsEnabled, + string ControlBlockReference, + string SvId, + string DataSetReference, + string AppId, + string SourceMac, + string DestinationMac, + string Vlan, + double SampleRateHz, + double PublicationRateHz, + ushort NoAsdu, + int PayloadBytesPerAsdu, + int EstimatedEthernetBytes, + double EstimatedBandwidthBitsPerSecond, + string SignalSource, + string Quality, + string SyncMode, + string Status, + IReadOnlyList Findings); + +/// +/// Reusable transmitter-side evidence contract for a Sampled Values publisher application. +/// +public sealed record SampledValuesPublisherEvidenceReport( + string ToolName, + string ToolVersion, + DateTimeOffset CreatedAt, + string SclPath, + string Adapter, + string Mode, + string TxTiming, + string SafetyBoundary, + IReadOnlyList Streams, + IReadOnlyList GlobalFindings); + +public static class SampledValuesPublisherEvidenceReportWriter +{ + public static string ToMarkdown(SampledValuesPublisherEvidenceReport report) + { + ArgumentNullException.ThrowIfNull(report); + + var builder = new StringBuilder(); + builder.AppendLine("# Sampled Values Publisher Evidence"); + builder.AppendLine(); + AppendField(builder, "Tool", $"{report.ToolName} {report.ToolVersion}".Trim()); + AppendField(builder, "Created", report.CreatedAt.ToString("O", CultureInfo.InvariantCulture)); + AppendField(builder, "SCL", EmptyAsDash(report.SclPath)); + AppendField(builder, "Adapter", EmptyAsDash(report.Adapter)); + AppendField(builder, "Mode", EmptyAsDash(report.Mode)); + AppendField(builder, "TX timing", EmptyAsDash(report.TxTiming)); + AppendField(builder, "Boundary", EmptyAsDash(report.SafetyBoundary)); + builder.AppendLine(); + + builder.AppendLine("## Streams"); + builder.AppendLine(); + if (report.Streams.Count == 0) + { + builder.AppendLine("No publisher streams were enabled."); + builder.AppendLine(); + } + else + { + foreach (var stream in report.Streams) + AppendStream(builder, stream); + } + + builder.AppendLine("## Global findings"); + builder.AppendLine(); + AppendFindings(builder, report.GlobalFindings); + + return builder.ToString(); + } + + private static void AppendStream(StringBuilder builder, SampledValuesEvidenceStream stream) + { + builder.Append("### ").AppendLine(EmptyAsDash(stream.SlotName)); + builder.AppendLine(); + AppendField(builder, "Enabled", stream.IsEnabled ? "yes" : "no"); + AppendField(builder, "Status", EmptyAsDash(stream.Status)); + AppendField(builder, "Control block", EmptyAsDash(stream.ControlBlockReference)); + AppendField(builder, "svID", EmptyAsDash(stream.SvId)); + AppendField(builder, "DataSet", EmptyAsDash(stream.DataSetReference)); + AppendField(builder, "APPID", EmptyAsDash(stream.AppId)); + AppendField(builder, "Source MAC", EmptyAsDash(stream.SourceMac)); + AppendField(builder, "Destination MAC", EmptyAsDash(stream.DestinationMac)); + AppendField(builder, "VLAN", EmptyAsDash(stream.Vlan)); + AppendField(builder, "Sample rate", $"{stream.SampleRateHz:0.###} sample/s"); + AppendField(builder, "Publication rate", $"{stream.PublicationRateHz:0.###} frame/s"); + AppendField(builder, "nofASDU", stream.NoAsdu.ToString(CultureInfo.InvariantCulture)); + AppendField(builder, "Payload", $"{stream.PayloadBytesPerAsdu} byte/ASDU"); + AppendField(builder, "Estimated Ethernet frame", stream.EstimatedEthernetBytes > 0 ? $"{stream.EstimatedEthernetBytes} byte" : "-"); + AppendField(builder, "Estimated bandwidth", stream.EstimatedBandwidthBitsPerSecond > 0 + ? $"{stream.EstimatedBandwidthBitsPerSecond / 1_000_000.0:0.###} Mbit/s" + : "-"); + AppendField(builder, "Signal source", EmptyAsDash(stream.SignalSource)); + AppendField(builder, "Quality", EmptyAsDash(stream.Quality)); + AppendField(builder, "Synchronization", EmptyAsDash(stream.SyncMode)); + builder.AppendLine(); + builder.AppendLine("#### Findings"); + builder.AppendLine(); + AppendFindings(builder, stream.Findings); + } + + private static void AppendFindings( + StringBuilder builder, + IReadOnlyList findings) + { + if (findings.Count == 0) + { + builder.AppendLine("- None."); + builder.AppendLine(); + return; + } + + foreach (var finding in findings) + { + builder.Append("- **") + .Append(EscapeInline(EmptyAsDash(finding.Severity))) + .Append("** · ") + .Append(EscapeInline(EmptyAsDash(finding.Area))) + .Append(" · ") + .Append(EscapeInline(EmptyAsDash(finding.Message))); + if (!string.IsNullOrWhiteSpace(finding.Detail)) + builder.Append(" — ").Append(EscapeInline(finding.Detail)); + builder.AppendLine(); + } + builder.AppendLine(); + } + + private static void AppendField(StringBuilder builder, string label, string value) + => builder.Append("- **").Append(label).Append(":** ").AppendLine(EscapeInline(value)); + + private static string EmptyAsDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + + private static string EscapeInline(string value) + => value.Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal) + .Replace("|", "\\|", StringComparison.Ordinal); +} From e5b476c92b0f75a953b67cdfc88e8b016af13439 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:04:37 +0700 Subject: [PATCH 14/39] Move transmitter timing health monitor into core --- .../TimeSync/Health/TxTimingHealth.cs | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/AR.Iec61850/TimeSync/Health/TxTimingHealth.cs diff --git a/src/AR.Iec61850/TimeSync/Health/TxTimingHealth.cs b/src/AR.Iec61850/TimeSync/Health/TxTimingHealth.cs new file mode 100644 index 0000000..19298e2 --- /dev/null +++ b/src/AR.Iec61850/TimeSync/Health/TxTimingHealth.cs @@ -0,0 +1,180 @@ +using System.Diagnostics; + +namespace AR.Iec61850.TimeSync.Health; + +public enum TxTimingHealthStatus +{ + Idle, + Good, + Warning, + Bad +} + +/// +/// Immutable transmitter timing evidence. Values describe the local process that attempted +/// to send frames; they do not prove deterministic wire timing or remote reception. +/// +public sealed record TxTimingHealthSnapshot +{ + public TxTimingHealthStatus Status { get; init; } = TxTimingHealthStatus.Idle; + public double TargetFramesPerSecond { get; init; } + public double ActualFramesPerSecond { get; init; } + public long FrameCount { get; init; } + public double AverageAbsJitterMicroseconds { get; init; } + public double MaxAbsJitterMicroseconds { get; init; } + public long LateFrameCount { get; init; } + public long MissedScheduleCount { get; init; } + public double AverageSendDurationMicroseconds { get; init; } + public double MaxSendDurationMicroseconds { get; init; } + public double MaxLateByMicroseconds { get; init; } + public string Detail { get; init; } = string.Empty; +} + +/// +/// Thread-safe local scheduling and send-duration monitor for a best-effort publisher. +/// The monitor uses Stopwatch ticks supplied by the caller and keeps cumulative evidence. +/// +public sealed class TxTimingHealth +{ + private readonly object _gate = new(); + private readonly double _targetFramesPerSecond; + private readonly double _intervalMicroseconds; + private long? _firstScheduledTicks; + private long _frameCount; + private double _sumAbsJitterMicroseconds; + private double _maxAbsJitterMicroseconds; + private long _lateFrameCount; + private long _missedScheduleCount; + private double _sumSendDurationMicroseconds; + private double _maxSendDurationMicroseconds; + private double _maxLateByMicroseconds; + + public TxTimingHealth(double targetFramesPerSecond) + { + if (!double.IsFinite(targetFramesPerSecond) || targetFramesPerSecond <= 0) + throw new ArgumentOutOfRangeException(nameof(targetFramesPerSecond), "Target frame rate must be positive and finite."); + + _targetFramesPerSecond = targetFramesPerSecond; + _intervalMicroseconds = 1_000_000.0 / targetFramesPerSecond; + } + + public void Record(long scheduledTicks, long sendStartTicks, long sendEndTicks) + { + if (sendEndTicks < sendStartTicks) + throw new ArgumentOutOfRangeException(nameof(sendEndTicks), "Send end must not precede send start."); + + var jitterMicroseconds = ToMicroseconds(sendStartTicks - scheduledTicks); + var absJitterMicroseconds = Math.Abs(jitterMicroseconds); + var sendDurationMicroseconds = ToMicroseconds(sendEndTicks - sendStartTicks); + var lateByMicroseconds = Math.Max(0, jitterMicroseconds); + + lock (_gate) + { + _firstScheduledTicks ??= scheduledTicks; + _frameCount++; + _sumAbsJitterMicroseconds += absJitterMicroseconds; + _maxAbsJitterMicroseconds = Math.Max(_maxAbsJitterMicroseconds, absJitterMicroseconds); + _sumSendDurationMicroseconds += sendDurationMicroseconds; + _maxSendDurationMicroseconds = Math.Max(_maxSendDurationMicroseconds, sendDurationMicroseconds); + _maxLateByMicroseconds = Math.Max(_maxLateByMicroseconds, lateByMicroseconds); + + if (lateByMicroseconds > _intervalMicroseconds * 0.10) + _lateFrameCount++; + + if (lateByMicroseconds >= _intervalMicroseconds) + { + var missed = (long)Math.Floor(lateByMicroseconds / _intervalMicroseconds); + _missedScheduleCount += Math.Max(1, missed); + } + } + } + + public TxTimingHealthSnapshot Snapshot(long nowTicks) + { + lock (_gate) + { + if (_frameCount == 0 || !_firstScheduledTicks.HasValue) + { + return new TxTimingHealthSnapshot + { + TargetFramesPerSecond = _targetFramesPerSecond, + Detail = "No transmission timing samples have been recorded." + }; + } + + var elapsedSeconds = Math.Max( + 1.0 / _targetFramesPerSecond, + (nowTicks - _firstScheduledTicks.Value) / (double)Stopwatch.Frequency); + var actualFramesPerSecond = _frameCount / elapsedSeconds; + var averageJitter = _sumAbsJitterMicroseconds / _frameCount; + var averageSend = _sumSendDurationMicroseconds / _frameCount; + var status = ResolveStatus( + elapsedSeconds, + actualFramesPerSecond, + averageJitter, + _maxAbsJitterMicroseconds, + _lateFrameCount, + _missedScheduleCount, + averageSend, + _maxSendDurationMicroseconds); + + return new TxTimingHealthSnapshot + { + Status = status, + TargetFramesPerSecond = _targetFramesPerSecond, + ActualFramesPerSecond = actualFramesPerSecond, + FrameCount = _frameCount, + AverageAbsJitterMicroseconds = averageJitter, + MaxAbsJitterMicroseconds = _maxAbsJitterMicroseconds, + LateFrameCount = _lateFrameCount, + MissedScheduleCount = _missedScheduleCount, + AverageSendDurationMicroseconds = averageSend, + MaxSendDurationMicroseconds = _maxSendDurationMicroseconds, + MaxLateByMicroseconds = _maxLateByMicroseconds, + Detail = BuildDetail(status, actualFramesPerSecond) + }; + } + } + + private TxTimingHealthStatus ResolveStatus( + double elapsedSeconds, + double actualFramesPerSecond, + double averageJitterMicroseconds, + double maxJitterMicroseconds, + long lateFrameCount, + long missedScheduleCount, + double averageSendMicroseconds, + double maxSendMicroseconds) + { + var rateRatio = actualFramesPerSecond / _targetFramesPerSecond; + var hasMatureRateWindow = elapsedSeconds >= Math.Max(0.25, 8.0 / _targetFramesPerSecond); + + if (missedScheduleCount > 0 || + maxJitterMicroseconds >= _intervalMicroseconds || + maxSendMicroseconds >= _intervalMicroseconds || + (hasMatureRateWindow && rateRatio < 0.80)) + return TxTimingHealthStatus.Bad; + + if (lateFrameCount > 0 || + averageJitterMicroseconds > _intervalMicroseconds * 0.10 || + maxJitterMicroseconds > _intervalMicroseconds * 0.25 || + averageSendMicroseconds > _intervalMicroseconds * 0.25 || + maxSendMicroseconds > _intervalMicroseconds * 0.50 || + (hasMatureRateWindow && rateRatio < 0.95)) + return TxTimingHealthStatus.Warning; + + return TxTimingHealthStatus.Good; + } + + private string BuildDetail(TxTimingHealthStatus status, double actualFramesPerSecond) + => status switch + { + TxTimingHealthStatus.Good => "Local publisher scheduling and send duration are within the engineering monitor thresholds.", + TxTimingHealthStatus.Warning => $"Local publisher timing requires review; actual rate is {actualFramesPerSecond:0.###}/{_targetFramesPerSecond:0.###} frame/s.", + TxTimingHealthStatus.Bad => $"Local publisher timing missed a schedule or exceeded one target frame interval; actual rate is {actualFramesPerSecond:0.###}/{_targetFramesPerSecond:0.###} frame/s.", + _ => "No transmission timing samples have been recorded." + }; + + private static double ToMicroseconds(long stopwatchTicks) + => stopwatchTicks * 1_000_000.0 / Stopwatch.Frequency; +} From b51bee0c87d9a16b22f84dd41cff6d756e8e4332 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:05:31 +0700 Subject: [PATCH 15/39] Test publisher quality evidence and timing core contracts --- .../SampledValues/SvPublisherSupportTests.cs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs new file mode 100644 index 0000000..4379aa7 --- /dev/null +++ b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs @@ -0,0 +1,111 @@ +using System.Diagnostics; +using AR.Iec61850.SampledValues; +using AR.Iec61850.TimeSync.Health; + +namespace AR.Iec61850.Tests.SampledValues; + +public sealed class SvPublisherSupportTests +{ + [Fact] + public void PublisherQualityRoundTripsNetworkBitField() + { + var quality = new SampledValueQuality( + SampledValueValidity.Questionable, + Overflow: true, + OldData: true, + Test: true, + OperatorBlocked: true); + + var restored = SampledValueQuality.FromUInt32(quality.ToUInt32()); + + Assert.Equal(quality, restored); + Assert.Equal(new byte[] { 0x00, 0x00, 0x18, 0x87 }, quality.ToBytes()); + } + + [Fact] + public void PublisherEvidenceWriterRecordsStreamAndSafetyBoundary() + { + var report = new SampledValuesPublisherEvidenceReport( + ToolName: "Test Publisher", + ToolVersion: "1.0.0", + CreatedAt: new DateTimeOffset(2026, 7, 23, 0, 0, 0, TimeSpan.Zero), + SclPath: "station.scd", + Adapter: "Lab NIC", + Mode: "dry-run", + TxTiming: "TX Timing: GOOD", + SafetyBoundary: "TX-side evidence only", + Streams: + [ + new SampledValuesEvidenceStream( + SlotName: "Publisher 1", + IsEnabled: true, + ControlBlockReference: "IED1/LLN0.SMV1", + SvId: "MU01", + DataSetReference: "IED1/LLN0$DS1", + AppId: "0x4000", + SourceMac: "02:00:00:00:00:01", + DestinationMac: "01:0C:CD:04:00:01", + Vlan: "VID=1/PCP=4", + SampleRateHz: 4000, + PublicationRateHz: 4000, + NoAsdu: 1, + PayloadBytesPerAsdu: 64, + EstimatedEthernetBytes: 126, + EstimatedBandwidthBitsPerSecond: 4_032_000, + SignalSource: "Manual phasor", + Quality: "good", + SyncMode: "LocalCompatibility", + Status: "ready", + Findings: []) + ], + GlobalFindings: []); + + var markdown = SampledValuesPublisherEvidenceReportWriter.ToMarkdown(report); + + Assert.Contains("# Sampled Values Publisher Evidence", markdown, StringComparison.Ordinal); + Assert.Contains("MU01", markdown, StringComparison.Ordinal); + Assert.Contains("TX-side evidence only", markdown, StringComparison.Ordinal); + Assert.Contains("4.032 Mbit/s", markdown, StringComparison.Ordinal); + } + + [Fact] + public void TxTimingHealthReportsGoodForOnScheduleFrames() + { + const double rate = 1000; + var monitor = new TxTimingHealth(rate); + var intervalTicks = (long)Math.Round(Stopwatch.Frequency / rate); + var start = Stopwatch.GetTimestamp(); + + for (var index = 0; index < 100; index++) + { + var scheduled = start + (index * intervalTicks); + monitor.Record(scheduled, scheduled, scheduled + ToTicks(25)); + } + + var snapshot = monitor.Snapshot(start + (100 * intervalTicks)); + + Assert.Equal(TxTimingHealthStatus.Good, snapshot.Status); + Assert.Equal(100, snapshot.FrameCount); + Assert.InRange(snapshot.ActualFramesPerSecond, 990, 1010); + Assert.Equal(0, snapshot.MissedScheduleCount); + } + + [Fact] + public void TxTimingHealthReportsBadWhenOneIntervalIsMissed() + { + const double rate = 1000; + var monitor = new TxTimingHealth(rate); + var intervalTicks = (long)Math.Round(Stopwatch.Frequency / rate); + var start = Stopwatch.GetTimestamp(); + + monitor.Record(start, start + intervalTicks + ToTicks(100), start + intervalTicks + ToTicks(150)); + var snapshot = monitor.Snapshot(start + (2 * intervalTicks)); + + Assert.Equal(TxTimingHealthStatus.Bad, snapshot.Status); + Assert.True(snapshot.MissedScheduleCount >= 1); + Assert.True(snapshot.MaxLateByMicroseconds > 1000); + } + + private static long ToTicks(double microseconds) + => (long)Math.Round(microseconds * Stopwatch.Frequency / 1_000_000.0); +} From f068b83089dc0b6831bfa8aa09b81dcb2d8b1380 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:09:00 +0700 Subject: [PATCH 16/39] Correct IEC 61850 quality validity encoding --- .../SampledValues/Measurements/SvQualityAnalysis.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvQualityAnalysis.cs b/src/AR.Iec61850/SampledValues/Measurements/SvQualityAnalysis.cs index 5f15228..784f4b0 100644 --- a/src/AR.Iec61850/SampledValues/Measurements/SvQualityAnalysis.cs +++ b/src/AR.Iec61850/SampledValues/Measurements/SvQualityAnalysis.cs @@ -5,8 +5,8 @@ namespace AR.Iec61850.SampledValues.Measurements; public enum SvQualityValidity { Good, - Reserved, Invalid, + Reserved, Questionable } @@ -134,11 +134,13 @@ public static SvQualityState DecodeWord( SvQualityWordPlacement placement = SvQualityWordPlacement.TwoByteWord, bool allowLegacyDerived = true) { + // IEC 61850 Quality validity is encoded as good=00, invalid=01, + // reserved=10 and questionable=11. var validity = (word & 0x0003) switch { 0 => SvQualityValidity.Good, - 1 => SvQualityValidity.Reserved, - 2 => SvQualityValidity.Invalid, + 1 => SvQualityValidity.Invalid, + 2 => SvQualityValidity.Reserved, _ => SvQualityValidity.Questionable }; From 095d493546fa533f3f9e64c61f379b193f06fbc4 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:09:39 +0700 Subject: [PATCH 17/39] Test IEC quality validity mapping --- .../SampledValues/SvPublisherSupportTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs index 4379aa7..3b955d6 100644 --- a/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs +++ b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Measurements; using AR.Iec61850.TimeSync.Health; namespace AR.Iec61850.Tests.SampledValues; @@ -22,6 +23,14 @@ public void PublisherQualityRoundTripsNetworkBitField() Assert.Equal(new byte[] { 0x00, 0x00, 0x18, 0x87 }, quality.ToBytes()); } + [Theory] + [InlineData(0, SvQualityValidity.Good)] + [InlineData(1, SvQualityValidity.Invalid)] + [InlineData(2, SvQualityValidity.Reserved)] + [InlineData(3, SvQualityValidity.Questionable)] + public void SemanticQualityDecoderUsesIecValidityEncoding(ushort word, SvQualityValidity expected) + => Assert.Equal(expected, SvQualityDecoder.DecodeWord(word).Validity); + [Fact] public void PublisherEvidenceWriterRecordsStreamAndSafetyBoundary() { From bc951ba59ed0dd50d531e5f97c6819156675feec Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:11:49 +0700 Subject: [PATCH 18/39] Move Sampled Values counter policy into core --- .../SampledValues/SampleCounterPolicy.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/SampleCounterPolicy.cs diff --git a/src/AR.Iec61850/SampledValues/SampleCounterPolicy.cs b/src/AR.Iec61850/SampledValues/SampleCounterPolicy.cs new file mode 100644 index 0000000..cfcd6de --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampleCounterPolicy.cs @@ -0,0 +1,38 @@ +namespace AR.Iec61850.SampledValues; + +public enum SampleCounterMode +{ + FreeRun, + SecondAligned +} + +public static class SampleCounterPolicy +{ + public static ushort InitialSampleCount(DateTimeOffset timestamp, double sampleRateHz, ushort? wrap, SampleCounterMode mode) + { + if (mode == SampleCounterMode.FreeRun || sampleRateHz <= 0) + return 0; + + var samplesPerSecond = wrap is > 1 ? wrap.Value : sampleRateHz; + if (samplesPerSecond <= 0) + return 0; + + var seconds = timestamp.TimeOfDay.TotalSeconds; + var fraction = seconds - Math.Floor(seconds); + var sample = (long)Math.Floor(fraction * samplesPerSecond); + var modulo = wrap is > 1 ? wrap.Value : ushort.MaxValue + 1L; + sample %= modulo; + if (sample < 0) + sample += modulo; + return (ushort)sample; + } + + public static ushort Increment(ushort current, ushort? wrap, int step = 1) + { + if (step <= 0) + return current; + + var modulo = wrap is > 1 ? wrap.Value : ushort.MaxValue + 1; + return (ushort)((current + step) % modulo); + } +} From cfdd451d8d6e4fadf09d967987a2b9c88eed33cb Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:12:05 +0700 Subject: [PATCH 19/39] Move generated Sampled Values PCAP export into core --- .../SampledValues/SampledValuesPcapExporter.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs b/src/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs new file mode 100644 index 0000000..6211cf5 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs @@ -0,0 +1,13 @@ +using AR.Iec61850.Capture; + +namespace AR.Iec61850.SampledValues; + +public static class SampledValuesPcapExporter +{ + public static void WriteGeneratedFrames(string filePath, IEnumerable<(DateTimeOffset Timestamp, byte[] Frame)> frames) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + ArgumentNullException.ThrowIfNull(frames); + PcapWriter.WriteAll(filePath, frames.Select(frame => new PcapPacket(frame.Timestamp, frame.Frame))); + } +} From 9e91cd80b347704d0e63b4161f9214b314833e5a Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:12:29 +0700 Subject: [PATCH 20/39] Move Sampled Values frame preview into core --- .../SampledValuesFramePreview.cs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs diff --git a/src/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs b/src/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs new file mode 100644 index 0000000..5e93ce7 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs @@ -0,0 +1,69 @@ +using AR.Iec61850.Ethernet; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.SampledValues; + +public sealed record SampledValuesFramePreview( + string ControlBlockReference, + string SvId, + string DataSetReference, + ushort AppId, + MacAddress Destination, + VlanTag? Vlan, + ushort NoAsdu, + int PayloadBytesPerAsdu, + double SampleRateHz, + double PublicationRateHz, + int EstimatedEthernetBytes, + double EstimatedBandwidthBitsPerSecond) +{ + public string Summary => + $"{ControlBlockReference}: svID={SvId}, APPID=0x{AppId:X4}, dst={Destination}, " + + $"{(Vlan is null ? "untagged" : $"VID={Vlan.Value.VlanId}/PCP={Vlan.Value.PriorityCodePoint}")}, " + + $"nofASDU={NoAsdu}, sample={SampleRateHz:0.###} fps, publish={PublicationRateHz:0.###} fps, " + + $"payload={PayloadBytesPerAsdu} B/ASDU, frame≈{EstimatedEthernetBytes} B, bw≈{EstimatedBandwidthBitsPerSecond / 1000.0:0.###} kbps"; + + public static SampledValuesFramePreview FromStream(SclSampledValuesStream stream, double sampleRateHz) + { + ArgumentNullException.ThrowIfNull(stream); + var profile = SampledValuesPublisherProfile.Create(stream); + return FromProfile(profile, sampleRateHz); + } + + public static SampledValuesFramePreview FromProfile(SampledValuesPublisherProfile profile, double sampleRateHz) + { + ArgumentNullException.ThrowIfNull(profile); + var noAsdu = profile.AsduPerFrame; + var publicationRateHz = SampledValuesPublisherProfile.ResolvePublicationRate(sampleRateHz, noAsdu); + var estimatedBytes = EstimateEthernetFrameBytes(profile, noAsdu); + return new SampledValuesFramePreview( + profile.Stream.ControlBlockReference, + profile.Stream.SvId, + profile.Stream.DataSetReference, + profile.AppId, + profile.Destination, + profile.Vlan, + noAsdu, + profile.PayloadLayout.PayloadByteLength, + sampleRateHz, + publicationRateHz, + estimatedBytes, + estimatedBytes * 8.0 * publicationRateHz); + } + + public static int EstimateEthernetFrameBytes(SampledValuesPublisherProfile profile, ushort? noAsduOverride = null) + { + ArgumentNullException.ThrowIfNull(profile); + var noAsdu = noAsduOverride ?? profile.AsduPerFrame; + var payloads = Enumerable.Range(0, noAsdu) + .Select(_ => new byte[profile.PayloadLayout.PayloadByteLength]) + .ToArray(); + var frame = profile.BuildEthernetFrame( + MacAddress.Parse("02:00:00:00:00:01"), + sampleCount: 0, + samplePayloads: payloads, + referenceTime: null, + sampleSynchronization: 2); + return frame.Length; + } +} From fd3708b2e3ce67f5e6c98ff47401d68cbd06a148 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:13:02 +0700 Subject: [PATCH 21/39] Move Sampled Values publisher validation into core --- .../SampledValuesPublisherValidation.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs b/src/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs new file mode 100644 index 0000000..10c6c54 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs @@ -0,0 +1,97 @@ +using AR.Iec61850.Ethernet; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.SampledValues; + +public enum SampledValuesPublisherValidationSeverity +{ + Info, + Warning, + Error +} + +public sealed record SampledValuesPublisherValidationFinding( + SampledValuesPublisherValidationSeverity Severity, + string Code, + string Message, + string Detail = ""); + +public sealed class SampledValuesPublisherValidationReport +{ + public SampledValuesPublisherValidationReport(IReadOnlyList findings) + { + Findings = findings; + } + + public IReadOnlyList Findings { get; } + public bool HasErrors => Findings.Any(f => f.Severity == SampledValuesPublisherValidationSeverity.Error); + public int ErrorCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Error); + public int WarningCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Warning); + public int InfoCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Info); +} + +public static class SampledValuesPublisherValidator +{ + public static SampledValuesPublisherValidationReport Validate(SclSampledValuesStream stream) + { + ArgumentNullException.ThrowIfNull(stream); + var findings = new List(); + Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_STREAM", stream.ControlBlockReference, $"svID={Text(stream.SvId)}, datSet={Text(stream.DataSetReference)}"); + + if (!stream.Address.AppId.HasValue) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_APPID_MISSING", "APPID is missing.", "Add Communication/SubNetwork/ConnectedAP/SMV/Address/P type=APPID."); + else if (stream.Address.AppId.Value == 0) + Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_APPID_ZERO", "APPID is 0x0000.", "Use the APPID expected by the subscriber unless this is intentional."); + if (!stream.Address.DestinationMac.HasValue) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_MISSING", "Destination MAC is missing or invalid.", stream.Address.DestinationMacText); + else + ValidateDestinationMac(stream.Address.DestinationMac.Value, findings); + + if (string.IsNullOrWhiteSpace(stream.SvId)) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_ID_MISSING", "svID/smvID is missing."); + if (string.IsNullOrWhiteSpace(stream.DataSetReference) || stream.Entries.Count == 0) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DATASET_MISSING", "DataSet cannot be resolved.", stream.DataSetName); + + if (stream.ConfigurationRevision == 0) + Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_CONFREV_ZERO", "confRev is 0.", "Most engineering files use a positive configuration revision."); + if (stream.SampleRate == 0) + Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_SMPRATE_MISSING", "smpRate is missing or 0.", "The operator must select an explicit sample rate in the publisher application."); + + var noAsdu = stream.NoAsdu == 0 ? (ushort)1 : stream.NoAsdu; + if (noAsdu > SampledValuesPublisherProfile.MaxAsduPerFrame) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_NOFASDU_UNSUPPORTED", $"nofASDU={noAsdu} is above the supported limit.", $"This publisher supports 1..{SampledValuesPublisherProfile.MaxAsduPerFrame} ASDU(s) per frame."); + else if (noAsdu > 1) + Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_NOFASDU_PACKING", $"nofASDU={noAsdu} ASDU(s) per Ethernet frame.", "The publisher will pack sequential samples into one SavPdu."); + + var layout = SampledValuesPayloadLayout.FromDataSet(stream.Entries); + if (!layout.IsFullySupported) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_PAYLOAD_UNSUPPORTED", "Unsupported SV payload layout.", string.Join("; ", layout.UnsupportedElements.Select(x => $"{x.SignalReference} bType={x.BType}"))); + else + Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_PAYLOAD_LAYOUT", "Payload layout supported.", $"entries={stream.Entries.Count}, payload={layout.PayloadByteLength} bytes per ASDU."); + + return new SampledValuesPublisherValidationReport(findings); + } + + private static void ValidateDestinationMac(MacAddress mac, List findings) + { + var bytes = mac.ToArray(); + if (bytes.All(value => value == 0)) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_ZERO", "Destination MAC is all zeros.", mac.ToString()); + else if (bytes.All(value => value == 0xFF)) + Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_BROADCAST", "Destination MAC should not be broadcast for SV.", mac.ToString()); + else if ((bytes[0] & 0x01) == 0) + Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_DEST_MAC_UNICAST", "Destination MAC is not multicast.", mac.ToString()); + else if (!(bytes[0] == 0x01 && bytes[1] == 0x0C && bytes[2] == 0xCD && bytes[3] == 0x04)) + Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_DEST_MAC_RANGE", "Destination MAC is multicast but outside the common SV multicast range.", mac.ToString()); + } + + private static void Add( + List findings, + SampledValuesPublisherValidationSeverity severity, + string code, + string message, + string detail = "") + => findings.Add(new SampledValuesPublisherValidationFinding(severity, code, message, detail)); + + private static string Text(string value) => string.IsNullOrWhiteSpace(value) ? "-" : value; +} From e3acf508df5d5ef3dc23f1b03a9071160c7727c5 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:13:48 +0700 Subject: [PATCH 22/39] Unify multi-ASDU publisher profile behavior in core --- .../SampledValuesPublisherProfile.cs | 132 ++++++++++++++---- 1 file changed, 101 insertions(+), 31 deletions(-) diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs b/src/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs index 61e7df7..9820ea8 100644 --- a/src/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs +++ b/src/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs @@ -6,6 +6,8 @@ namespace AR.Iec61850.SampledValues; public sealed class SampledValuesPublisherProfile { + public const ushort MaxAsduPerFrame = 8; + private SampledValuesPublisherProfile( SclSampledValuesStream stream, ushort appId, @@ -25,6 +27,7 @@ private SampledValuesPublisherProfile( public VlanTag? Vlan { get; } public SampledValuesPayloadLayout PayloadLayout { get; } public IReadOnlyList Entries => Stream.Entries; + public ushort AsduPerFrame => ResolveAsduPerFrame(Stream); public static IReadOnlyList CreateMany(SclDocument document) { @@ -51,32 +54,59 @@ public SampledValuesFrame CreateFrame( ushort sampleCount, ReadOnlySpan samplePayload, Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2) + byte sampleSynchronization = 2, + ushort? sampleCounterWrap = null) + { + if (AsduPerFrame != 1) + throw new InvalidOperationException($"SV {Stream.ControlBlockReference} declares nofASDU={AsduPerFrame}. Use the multi-ASDU CreateFrame overload."); + + return CreateFrame( + source, + sampleCount, + new[] { samplePayload.ToArray() }, + referenceTime, + sampleSynchronization, + sampleCounterWrap); + } + + public SampledValuesFrame CreateFrame( + MacAddress source, + ushort sampleCount, + IReadOnlyList samplePayloads, + Iec61850UtcTime? referenceTime = null, + byte sampleSynchronization = 2, + ushort? sampleCounterWrap = null) { + ArgumentNullException.ThrowIfNull(samplePayloads); + ValidateAsduPayloadBatch(samplePayloads); + + if (sampleCounterWrap is 1) + throw new ArgumentOutOfRangeException(nameof(sampleCounterWrap), "SV sample counter wrap must be greater than 1 when supplied."); + + var asdus = new List(samplePayloads.Count); + for (var index = 0; index < samplePayloads.Count; index++) + { + asdus.Add(new SampledValueAsdu + { + SvId = Stream.SvId, + DataSetReference = Stream.DataSetReference, + SampleCount = SampleCounterPolicy.Increment(sampleCount, sampleCounterWrap, index), + ConfigurationRevision = Stream.ConfigurationRevision, + ReferenceTime = referenceTime, + SampleSynchronization = sampleSynchronization, + SampleRate = Stream.SampleRate == 0 ? null : Stream.SampleRate, + SampleMode = TryMapSampleMode(Stream.SampleMode), + SamplePayload = samplePayloads[index].ToArray() + }); + } + return new SampledValuesFrame { Destination = Destination, Source = source, Vlan = Vlan, AppId = AppId, - Pdu = new SampledValuesPdu - { - Asdus = - [ - new SampledValueAsdu - { - SvId = Stream.SvId, - DataSetReference = Stream.DataSetReference, - SampleCount = sampleCount, - ConfigurationRevision = Stream.ConfigurationRevision, - ReferenceTime = referenceTime, - SampleSynchronization = sampleSynchronization, - SampleRate = Stream.SampleRate == 0 ? null : Stream.SampleRate, - SampleMode = TryMapSampleMode(Stream.SampleMode), - SamplePayload = samplePayload.ToArray() - } - ] - } + Pdu = new SampledValuesPdu { Asdus = asdus } }; } @@ -85,10 +115,20 @@ public byte[] BuildEthernetFrame( ushort sampleCount, ReadOnlySpan samplePayload, Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2) - { - return SampledValuesFrameBuilder.BuildEthernetFrame(CreateFrame(source, sampleCount, samplePayload, referenceTime, sampleSynchronization)); - } + byte sampleSynchronization = 2, + ushort? sampleCounterWrap = null) + => SampledValuesFrameBuilder.BuildEthernetFrame( + CreateFrame(source, sampleCount, samplePayload, referenceTime, sampleSynchronization, sampleCounterWrap)); + + public byte[] BuildEthernetFrame( + MacAddress source, + ushort sampleCount, + IReadOnlyList samplePayloads, + Iec61850UtcTime? referenceTime = null, + byte sampleSynchronization = 2, + ushort? sampleCounterWrap = null) + => SampledValuesFrameBuilder.BuildEthernetFrame( + CreateFrame(source, sampleCount, samplePayloads, referenceTime, sampleSynchronization, sampleCounterWrap)); public byte[] BuildPayload(IReadOnlyList values) => SampledValuesPayloadBuilder.BuildPayload(PayloadLayout, values); @@ -100,8 +140,9 @@ public byte[] BuildDemoPayload( long sampleIndex, double sampleRateHz, double nominalHz, - Iec61850UtcTime? timestamp = null) - => SampledValuesPayloadBuilder.BuildDemoPayload(PayloadLayout, sampleIndex, sampleRateHz, nominalHz, timestamp); + Iec61850UtcTime? timestamp = null, + SampledValueQuality? quality = null) + => SampledValuesPayloadBuilder.BuildDemoPayload(PayloadLayout, sampleIndex, sampleRateHz, nominalHz, timestamp, quality); public ushort? ResolveSampleCounterWrap(double nominalFrequencyHz) { @@ -122,26 +163,55 @@ public byte[] BuildDemoPayload( return (ushort)Math.Round(samplesPerSecond); } - private static SampledValuesPublisherProfile Create(SclSampledValuesStream stream) + public static SampledValuesPublisherProfile Create(SclSampledValuesStream stream) { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.Address.AppId.HasValue) throw new SclProfileException($"SV {stream.ControlBlockReference} has no valid APPID in SCL Communication/SMV."); - if (!stream.Address.DestinationMac.HasValue) throw new SclProfileException($"SV {stream.ControlBlockReference} has no valid destination MAC in SCL Communication/SMV."); - if (string.IsNullOrWhiteSpace(stream.SvId)) throw new SclProfileException($"SV {stream.ControlBlockReference} has no svID/smvID."); - if (string.IsNullOrWhiteSpace(stream.DataSetReference) || stream.Entries.Count == 0) throw new SclProfileException($"SV {stream.ControlBlockReference} has no resolved DataSet entries."); - if (stream.NoAsdu != 1) - throw new SclProfileException($"SV {stream.ControlBlockReference} declares nofASDU={stream.NoAsdu}. This publisher currently supports exactly one ASDU per frame."); + var noAsdu = ResolveAsduPerFrame(stream); + if (noAsdu > MaxAsduPerFrame) + throw new SclProfileException($"SV {stream.ControlBlockReference} declares nofASDU={stream.NoAsdu}. This publisher supports up to {MaxAsduPerFrame} ASDUs per frame."); return new SampledValuesPublisherProfile(stream, stream.Address.AppId.Value, stream.Address.DestinationMac.Value, stream.Address.ToVlanTag()); } + public static ushort ResolveAsduPerFrame(SclSampledValuesStream stream) + { + ArgumentNullException.ThrowIfNull(stream); + return stream.NoAsdu == 0 ? (ushort)1 : stream.NoAsdu; + } + + public static double ResolvePublicationRate(double sampleRateHz, ushort noAsdu) + { + if (!double.IsFinite(sampleRateHz) || sampleRateHz <= 0) + throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Sample rate must be greater than 0 and finite."); + if (noAsdu == 0) + throw new ArgumentOutOfRangeException(nameof(noAsdu), "nofASDU must be greater than 0."); + + return sampleRateHz / noAsdu; + } + + private void ValidateAsduPayloadBatch(IReadOnlyList samplePayloads) + { + var expected = AsduPerFrame; + if (samplePayloads.Count != expected) + throw new ArgumentException($"SV {Stream.ControlBlockReference} expects nofASDU={expected}, got {samplePayloads.Count} payload(s).", nameof(samplePayloads)); + + for (var index = 0; index < samplePayloads.Count; index++) + { + if (samplePayloads[index].Length != PayloadLayout.PayloadByteLength) + throw new ArgumentException($"SV ASDU payload {index} has {samplePayloads[index].Length} byte(s), expected {PayloadLayout.PayloadByteLength}.", nameof(samplePayloads)); + } + } + private static ushort? TryMapSampleMode(string sampleMode) { if (string.IsNullOrWhiteSpace(sampleMode)) From 96296216bec3041851d5d50734116234c3a1a30d Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:15:13 +0700 Subject: [PATCH 23/39] Unify quality-aware Sampled Values payload generation --- .../SampledValuesPayloadBuilder.cs | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs b/src/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs index 05276da..35e7dd3 100644 --- a/src/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs +++ b/src/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs @@ -19,16 +19,19 @@ public static byte[] BuildPayload(SampledValuesPayloadLayout layout, IReadOnlyLi throw new ArgumentException($"SV payload value count mismatch. Expected {layout.Elements.Count}, got {values.Count}.", nameof(values)); var payload = new byte[layout.PayloadByteLength]; - for (var i = 0; i < layout.Elements.Count; i++) + for (var index = 0; index < layout.Elements.Count; index++) { - var element = layout.Elements[i]; - WriteValue(payload.AsSpan(element.Offset, element.Width), element, values[i]); + var element = layout.Elements[index]; + WriteValue(payload.AsSpan(element.Offset, element.Width), element, values[index]); } return payload; } - public static byte[] BuildDefaultPayload(SampledValuesPayloadLayout layout, Iec61850UtcTime? timestamp = null) + public static byte[] BuildDefaultPayload( + SampledValuesPayloadLayout layout, + Iec61850UtcTime? timestamp = null, + SampledValueQuality? quality = null) { ArgumentNullException.ThrowIfNull(layout); @@ -38,8 +41,12 @@ public static byte[] BuildDefaultPayload(SampledValuesPayloadLayout layout, Iec6 var payload = new byte[layout.PayloadByteLength]; foreach (var element in layout.Elements) { + var destination = payload.AsSpan(element.Offset, element.Width); if (element.Kind == SampledValuePayloadElementKind.Timestamp && timestamp is { } time) - BerWriter.EncodeUtcTime(time.Value, time.Quality).CopyTo(payload.AsSpan(element.Offset, element.Width)); + BerWriter.EncodeUtcTime(time.Value, time.Quality).CopyTo(destination); + + if (element.Kind == SampledValuePayloadElementKind.Quality) + (quality ?? SampledValueQuality.Good).ToBytes(element.Width).CopyTo(destination); } return payload; @@ -50,25 +57,30 @@ public static byte[] BuildDemoPayload( long sampleIndex, double sampleRateHz, double nominalHz, - Iec61850UtcTime? timestamp = null) + Iec61850UtcTime? timestamp = null, + SampledValueQuality? quality = null) { ArgumentNullException.ThrowIfNull(layout); if (!layout.IsFullySupported) throw new InvalidOperationException(BuildUnsupportedLayoutMessage(layout)); - if (sampleRateHz <= 0) - throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Sample rate must be greater than 0."); - - if (nominalHz <= 0) - throw new ArgumentOutOfRangeException(nameof(nominalHz), "Nominal frequency must be greater than 0."); + if (!double.IsFinite(sampleRateHz) || sampleRateHz <= 0) + throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Sample rate must be greater than 0 and finite."); + if (!double.IsFinite(nominalHz) || nominalHz <= 0) + throw new ArgumentOutOfRangeException(nameof(nominalHz), "Nominal frequency must be greater than 0 and finite."); var payload = new byte[layout.PayloadByteLength]; foreach (var element in layout.Elements) { var destination = payload.AsSpan(element.Offset, element.Width); - if (element.Kind == SampledValuePayloadElementKind.Quality || - element.Kind == SampledValuePayloadElementKind.BitString || + if (element.Kind == SampledValuePayloadElementKind.Quality) + { + (quality ?? SampledValueQuality.Good).ToBytes(element.Width).CopyTo(destination); + continue; + } + + if (element.Kind == SampledValuePayloadElementKind.BitString || element.Kind == SampledValuePayloadElementKind.EntryTime) { continue; @@ -216,7 +228,6 @@ private static void CopyRawPayloadBytes(Span destination, MmsDataValue val if (raw.Length > destination.Length) throw new ArgumentException($"SV raw value has {raw.Length} bytes but the payload slot has {destination.Length} bytes."); - raw.CopyTo(destination); } @@ -239,13 +250,13 @@ private static ulong ToUInt64(MmsDataValue value) }; private static float ToSingle(MmsDataValue value) - => value.Kind == MmsDataKind.FloatingPoint && value.Value is float f - ? f + => value.Kind == MmsDataKind.FloatingPoint && value.Value is float number + ? number : Convert.ToSingle(value.Value, CultureInfo.InvariantCulture); private static double ToDouble(MmsDataValue value) - => value.Kind == MmsDataKind.FloatingPoint && value.Value is float f - ? f + => value.Kind == MmsDataKind.FloatingPoint && value.Value is float number + ? number : Convert.ToDouble(value.Value, CultureInfo.InvariantCulture); private static long ComputeDemoValue(SampledValuePayloadElement element, long sampleIndex, double sampleRateHz, double nominalHz) From d197f170f486abcf6ebdd231149f6a3e783b3ccc Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:20:06 +0700 Subject: [PATCH 24/39] Preserve ARIEC61850 test diagnostics in CI --- .github/workflows/dotnet-ci.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index bcdf77a..98dc9b1 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -54,4 +54,23 @@ jobs: run: dotnet build .\source\ARIEC61850.sln -c Release --no-restore - name: Test - run: dotnet test .\source\ARIEC61850.sln -c Release --no-build --verbosity normal + shell: pwsh + run: | + New-Item -ItemType Directory -Path artifacts/test-results -Force | Out-Null + dotnet test .\source\ARIEC61850.sln ` + -c Release ` + --no-build ` + --verbosity normal ` + --logger "trx;LogFileName=ARIEC61850.Tests.trx" ` + --results-directory artifacts/test-results 2>&1 | + Tee-Object -FilePath artifacts/test-results/dotnet-test.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload test diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ariec61850-test-diagnostics + path: artifacts/test-results + if-no-files-found: warn + retention-days: 14 From a85ffdddb0b8b5c51248ea16f609db63d74dbe67 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:27:07 +0700 Subject: [PATCH 25/39] Move reusable SV profile evidence models into core --- .../SampledValues/Profiles/SvProfileModels.cs | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs b/src/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs new file mode 100644 index 0000000..70eed0a --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs @@ -0,0 +1,238 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +public enum SvSamplingBasis +{ + Unspecified, + SamplesPerCycle, + SamplesPerSecond, + Custom +} + +public enum SvProfileEvidenceStatus +{ + ResearchCandidate, + ImplementedGeneric, + VerifiedStandard, + VerifiedCapture, + VerifiedLab +} + +public enum SvProfileConfidence +{ + Unknown, + Possible, + Likely, + Confirmed, + Conflict +} + +public enum SvProfileEvidenceOutcome +{ + Match, + Conflict, + Unknown +} + +public enum SvFactSource +{ + Unknown, + WireObserved, + CaptureCalculated, + SclDerived, + TrustedContext, + ProfileInferred +} + +public sealed record SvProfileSourceEvidence( + string SourceId, + string Description, + SvProfileEvidenceStatus Status); + +public sealed record SvDatasetElementSignature +{ + public string BType { get; init; } = string.Empty; + public string Cdc { get; init; } = string.Empty; + public bool IsQuality { get; init; } + public bool IsTimestamp { get; init; } + + public string NormalizedBType => Normalize(BType); + public string NormalizedCdc => Normalize(Cdc); + + private static string Normalize(string value) + => new((value ?? string.Empty) + .Where(char.IsLetterOrDigit) + .Select(char.ToUpperInvariant) + .ToArray()); +} + +public sealed record SvCounterTransitionSummary +{ + public int SequentialCount { get; init; } + public int GapCount { get; init; } + public int DuplicateCount { get; init; } + public int OutOfOrderOrResetCount { get; init; } + public int ConfirmedWrapCount { get; init; } +} + +public sealed record SvObservedStreamFacts +{ + public ushort? EtherType { get; init; } + public ushort? AppId { get; init; } + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { get; init; } + public byte? VlanPriority { get; init; } + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public uint? ConfigurationRevision { get; init; } + public int? AsduPerFrame { get; init; } + public int? PayloadBytesPerAsdu { get; init; } + public double? ObservedFramesPerSecond { get; init; } + public double? ObservedSamplesPerSecond { get; init; } + public int? ObservedCounterWrap { get; init; } + public SvCounterTransitionSummary CounterTransitions { get; init; } = new(); + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? NominalFrequencyHz { get; init; } + public IReadOnlyList DataSetSignature { get; init; } + = Array.Empty(); + public IReadOnlyDictionary Provenance { get; init; } + = new Dictionary(StringComparer.Ordinal); + public int ObservationCount { get; init; } + public DateTimeOffset? FirstTimestamp { get; init; } + public DateTimeOffset? LastTimestamp { get; init; } + public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); +} + +public sealed record SvProfileDefinition +{ + public string Id { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string Family { get; init; } = string.Empty; + public SvSamplingBasis SamplingBasis { get; init; } + public ushort? ExpectedEtherType { get; init; } + public IReadOnlyList AllowedAsduPerFrame { get; init; } = Array.Empty(); + public int? ExpectedPayloadBytesPerAsdu { get; init; } + public int? ExpectedDataSetElementCount { get; init; } + public IReadOnlyList ExpectedDataSetSignature { get; init; } + = Array.Empty(); + public double? ExpectedSamplesPerCycle { get; init; } + public double? ExpectedSamplesPerSecond { get; init; } + public IReadOnlyList AllowedNominalFrequenciesHz { get; init; } = Array.Empty(); + public int? ExpectedCounterWrap { get; init; } + public double RateTolerancePercent { get; init; } = 1.0; + public SvProfileEvidenceStatus EvidenceStatus { get; init; } = SvProfileEvidenceStatus.ResearchCandidate; + public IReadOnlyList Sources { get; init; } + = Array.Empty(); + + public void Validate() + { + if (string.IsNullOrWhiteSpace(Id)) + throw new InvalidOperationException("SV profile definition requires a stable ID."); + if (string.IsNullOrWhiteSpace(DisplayName)) + throw new InvalidOperationException($"SV profile '{Id}' requires a display name."); + if (Sources.Count == 0) + throw new InvalidOperationException($"SV profile '{Id}' requires at least one evidence source."); + if (Sources.Any(source => string.IsNullOrWhiteSpace(source.SourceId) || string.IsNullOrWhiteSpace(source.Description))) + throw new InvalidOperationException($"SV profile '{Id}' contains incomplete evidence-source metadata."); + if (RateTolerancePercent < 0 || RateTolerancePercent > 100) + throw new InvalidOperationException($"SV profile '{Id}' has an invalid rate tolerance."); + if (AllowedAsduPerFrame.Any(value => value <= 0)) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid ASDU-per-frame value."); + if (AllowedAsduPerFrame.Count != AllowedAsduPerFrame.Distinct().Count()) + throw new InvalidOperationException($"SV profile '{Id}' contains duplicate ASDU-per-frame values."); + if (ExpectedPayloadBytesPerAsdu is <= 0) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid payload length."); + if (ExpectedDataSetElementCount is < 0) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid dataset element count."); + if (ExpectedDataSetElementCount.HasValue && + ExpectedDataSetSignature.Count > 0 && + ExpectedDataSetElementCount.Value != ExpectedDataSetSignature.Count) + throw new InvalidOperationException($"SV profile '{Id}' dataset count does not match its ordered signature."); + if (ExpectedCounterWrap is <= 1) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid sample-counter wrap."); + if (AllowedNominalFrequenciesHz.Any(value => value <= 0 || double.IsNaN(value) || double.IsInfinity(value))) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid nominal frequency."); + if (ExpectedSamplesPerCycle is <= 0 || ExpectedSamplesPerSecond is <= 0) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid sampling expectation."); + if (ExpectedSamplesPerCycle.HasValue && ExpectedSamplesPerSecond.HasValue) + throw new InvalidOperationException($"SV profile '{Id}' cannot define both samples-per-cycle and samples-per-second expectations."); + + switch (SamplingBasis) + { + case SvSamplingBasis.SamplesPerCycle when !ExpectedSamplesPerCycle.HasValue: + throw new InvalidOperationException($"SV profile '{Id}' requires a samples-per-cycle expectation."); + case SvSamplingBasis.SamplesPerSecond when !ExpectedSamplesPerSecond.HasValue: + throw new InvalidOperationException($"SV profile '{Id}' requires a samples-per-second expectation."); + case SvSamplingBasis.SamplesPerCycle when ExpectedSamplesPerSecond.HasValue: + case SvSamplingBasis.SamplesPerSecond when ExpectedSamplesPerCycle.HasValue: + throw new InvalidOperationException($"SV profile '{Id}' sampling expectation conflicts with its sampling basis."); + } + } +} + +public sealed record SvProfileMatchEvidence( + string Field, + SvProfileEvidenceOutcome Outcome, + int Weight, + string Expected, + string Observed, + string Message); + +public sealed record SvProfileDetectionResult +{ + private SvProfileConfidence _rawConfidence; + + public SvProfileDefinition Profile { get; init; } = new(); + public SvProfileConfidence RawConfidence + { + get => _rawConfidence; + init => _rawConfidence = value; + } + public SvProfileConfidence Confidence + { + get => SvProfileConfidencePolicy.ApplyEvidenceMaturityCeiling(_rawConfidence, Profile.EvidenceStatus); + init => _rawConfidence = value; + } + public double ScorePercent { get; init; } + public int MatchedWeight { get; init; } + public int ConflictWeight { get; init; } + public int EvaluatedWeight { get; init; } + public IReadOnlyList Evidence { get; init; } + = Array.Empty(); + + public bool HasConflicts => Evidence.Any(item => item.Outcome == SvProfileEvidenceOutcome.Conflict); +} + +public static class SvProfileConfidencePolicy +{ + public static SvProfileConfidence ApplyEvidenceMaturityCeiling( + SvProfileConfidence confidence, + SvProfileEvidenceStatus evidenceStatus) + { + if (confidence is SvProfileConfidence.Unknown or SvProfileConfidence.Conflict) + return confidence; + + var ceiling = evidenceStatus switch + { + SvProfileEvidenceStatus.ResearchCandidate => SvProfileConfidence.Possible, + SvProfileEvidenceStatus.ImplementedGeneric => SvProfileConfidence.Likely, + SvProfileEvidenceStatus.VerifiedStandard or + SvProfileEvidenceStatus.VerifiedCapture or + SvProfileEvidenceStatus.VerifiedLab => SvProfileConfidence.Confirmed, + _ => SvProfileConfidence.Possible + }; + + return Rank(confidence) <= Rank(ceiling) ? confidence : ceiling; + } + + private static int Rank(SvProfileConfidence confidence) + => confidence switch + { + SvProfileConfidence.Unknown => 0, + SvProfileConfidence.Possible => 1, + SvProfileConfidence.Likely => 2, + SvProfileConfidence.Confirmed => 3, + SvProfileConfidence.Conflict => 4, + _ => 0 + }; +} From 1d754121e52fcc478616b422c4ec15ef1fc7a77b Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:28:07 +0700 Subject: [PATCH 26/39] Move SV observation accumulator into core --- .../Profiles/SvObservationAccumulator.cs | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs b/src/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs new file mode 100644 index 0000000..d42c4d2 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs @@ -0,0 +1,225 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +public sealed record SvFrameObservation +{ + public DateTimeOffset Timestamp { get; init; } + public ushort EtherType { get; init; } + public ushort AppId { get; init; } + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { get; init; } + public byte? VlanPriority { get; init; } + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public uint ConfigurationRevision { get; init; } + public int PayloadBytesPerAsdu { get; init; } + public IReadOnlyList SampleCounts { get; init; } = Array.Empty(); + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? NominalFrequencyHz { get; init; } + public IReadOnlyList DataSetSignature { get; init; } + = Array.Empty(); + + public int AsduPerFrame => SampleCounts.Count; + + public void Validate() + { + if (SampleCounts.Count == 0) + throw new ArgumentException("An SV frame observation requires at least one ASDU sample counter."); + if (PayloadBytesPerAsdu <= 0) + throw new ArgumentOutOfRangeException(nameof(PayloadBytesPerAsdu)); + } +} + +public sealed class SvObservationAccumulator +{ + public const int DefaultMaximumObservations = 4096; + public static readonly TimeSpan DefaultMaximumAge = TimeSpan.FromSeconds(5); + + private readonly object _gate = new(); + private readonly Queue _observations = new(); + private readonly int _maximumObservations; + private readonly TimeSpan _maximumAge; + + public SvObservationAccumulator(int maximumObservations = DefaultMaximumObservations, TimeSpan? maximumAge = null) + { + if (maximumObservations < 2) + throw new ArgumentOutOfRangeException(nameof(maximumObservations), "At least two observations are required for rate analysis."); + _maximumAge = maximumAge ?? DefaultMaximumAge; + if (_maximumAge <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(maximumAge), "Observation age must be greater than zero."); + _maximumObservations = maximumObservations; + } + + public int Count { get { lock (_gate) return _observations.Count; } } + public int MaximumObservations => _maximumObservations; + public TimeSpan MaximumAge => _maximumAge; + + public void Add(SvFrameObservation observation) + { + ArgumentNullException.ThrowIfNull(observation); + observation.Validate(); + lock (_gate) + { + _observations.Enqueue(observation); + TrimWindow(observation.Timestamp); + } + } + + public void Clear() { lock (_gate) _observations.Clear(); } + + public SvObservedStreamFacts BuildFacts() + { + SvFrameObservation[] snapshot; + lock (_gate) snapshot = _observations.ToArray(); + if (snapshot.Length == 0) + return new SvObservedStreamFacts(); + + var ordered = snapshot.OrderBy(item => item.Timestamp).ToArray(); + var diagnostics = new List(); + var first = ordered[0]; + var last = ordered[^1]; + var durationSeconds = (last.Timestamp - first.Timestamp).TotalSeconds; + double? framesPerSecond = null; + double? samplesPerSecond = null; + if (ordered.Length >= 2 && durationSeconds > 0) + { + framesPerSecond = (ordered.Length - 1) / durationSeconds; + samplesPerSecond = framesPerSecond * ordered.Average(item => item.AsduPerFrame); + } + else + { + diagnostics.Add("At least two timestamped frames are required to estimate stream rate."); + } + + var counter = AnalyzeCounters(ordered, diagnostics); + return new SvObservedStreamFacts + { + EtherType = StableValue(ordered.Select(item => item.EtherType), "EtherType", diagnostics), + AppId = StableValue(ordered.Select(item => item.AppId), "APPID", diagnostics), + DestinationMac = StableString(ordered.Select(item => item.DestinationMac), "destination MAC", diagnostics), + VlanId = StableNullableValue(ordered.Select(item => item.VlanId), "VLAN ID", diagnostics), + VlanPriority = StableNullableValue(ordered.Select(item => item.VlanPriority), "VLAN priority", diagnostics), + SvId = StableString(ordered.Select(item => item.SvId), "svID", diagnostics), + DataSetReference = StableString(ordered.Select(item => item.DataSetReference), "dataset reference", diagnostics), + ConfigurationRevision = StableValue(ordered.Select(item => item.ConfigurationRevision), "confRev", diagnostics), + AsduPerFrame = StableValue(ordered.Select(item => item.AsduPerFrame), "ASDU count", diagnostics), + PayloadBytesPerAsdu = StableValue(ordered.Select(item => item.PayloadBytesPerAsdu), "payload length", diagnostics), + ObservedFramesPerSecond = framesPerSecond, + ObservedSamplesPerSecond = samplesPerSecond, + ObservedCounterWrap = counter.Wrap, + CounterTransitions = counter.Summary, + DeclaredSampleRate = StableNullableValue(ordered.Select(item => item.DeclaredSampleRate), "declared sample rate", diagnostics), + DeclaredSampleMode = StableNullableValue(ordered.Select(item => item.DeclaredSampleMode), "declared sample mode", diagnostics), + NominalFrequencyHz = StableNullableValue(ordered.Select(item => item.NominalFrequencyHz), "nominal frequency", diagnostics), + DataSetSignature = StableSignature(ordered.Select(item => item.DataSetSignature), diagnostics), + Provenance = BuildProvenance(), + ObservationCount = ordered.Length, + FirstTimestamp = first.Timestamp, + LastTimestamp = last.Timestamp, + Diagnostics = diagnostics + }; + } + + private void TrimWindow(DateTimeOffset newestTimestamp) + { + while (_observations.Count > _maximumObservations) _observations.Dequeue(); + var oldestAllowed = newestTimestamp - _maximumAge; + while (_observations.Count > 0 && _observations.Peek().Timestamp < oldestAllowed) _observations.Dequeue(); + } + + private static (int? Wrap, SvCounterTransitionSummary Summary) AnalyzeCounters(IReadOnlyList observations, List diagnostics) + { + var sampleCounts = observations.SelectMany(item => item.SampleCounts).ToArray(); + if (sampleCounts.Length < 2) + return (null, new SvCounterTransitionSummary()); + + var sequential = 0; var gaps = 0; var duplicates = 0; var outOfOrderOrReset = 0; var confirmedWraps = 0; + var wrapCandidates = new HashSet(); + for (var index = 1; index < sampleCounts.Length; index++) + { + var previous = sampleCounts[index - 1]; + var current = sampleCounts[index]; + if (current == previous) { duplicates++; continue; } + if (current == previous + 1) { sequential++; continue; } + if (current > previous + 1) { gaps++; continue; } + var recovery = index + 1 < sampleCounts.Length && sampleCounts[index + 1] == current + 1; + if (current == 0 && recovery && previous > 1) { confirmedWraps++; wrapCandidates.Add(previous + 1); continue; } + outOfOrderOrReset++; + } + + int? wrap = wrapCandidates.Count == 1 ? wrapCandidates.Single() : null; + if (wrapCandidates.Count > 1) diagnostics.Add($"Multiple sample-counter wrap candidates were observed: {string.Join(", ", wrapCandidates.Order())}."); + if (gaps > 0) diagnostics.Add($"Observed {gaps} forward sample-counter gap transition(s)."); + if (duplicates > 0) diagnostics.Add($"Observed {duplicates} duplicate sample-counter transition(s)."); + if (outOfOrderOrReset > 0) diagnostics.Add($"Observed {outOfOrderOrReset} out-of-order or reset transition(s); these were not classified as wraps."); + return (wrap, new SvCounterTransitionSummary + { + SequentialCount = sequential, + GapCount = gaps, + DuplicateCount = duplicates, + OutOfOrderOrResetCount = outOfOrderOrReset, + ConfirmedWrapCount = confirmedWraps + }); + } + + private static IReadOnlyDictionary BuildProvenance() + => new Dictionary(StringComparer.Ordinal) + { + [nameof(SvObservedStreamFacts.EtherType)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.AppId)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DestinationMac)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.VlanId)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.VlanPriority)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.SvId)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DataSetReference)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.ConfigurationRevision)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.AsduPerFrame)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.PayloadBytesPerAsdu)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DeclaredSampleRate)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DeclaredSampleMode)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.ObservedFramesPerSecond)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.ObservedSamplesPerSecond)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.ObservedCounterWrap)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.CounterTransitions)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.NominalFrequencyHz)] = SvFactSource.TrustedContext, + [nameof(SvObservedStreamFacts.DataSetSignature)] = SvFactSource.SclDerived + }; + + private static T? StableValue(IEnumerable values, string field, List diagnostics) where T : struct + { + var distinct = values.Distinct().ToArray(); + if (distinct.Length == 1) return distinct[0]; + diagnostics.Add($"Observed {field} changed within the analysis window."); + return null; + } + + private static T? StableNullableValue(IEnumerable values, string field, List diagnostics) where T : struct + { + var distinct = values.Distinct().ToArray(); + if (distinct.Length == 1) return distinct[0]; + diagnostics.Add($"Observed {field} changed within the analysis window."); + return null; + } + + private static string StableString(IEnumerable values, string field, List diagnostics) + { + var distinct = values.Select(value => value ?? string.Empty).Distinct(StringComparer.Ordinal).ToArray(); + if (distinct.Length == 1) return distinct[0]; + diagnostics.Add($"Observed {field} changed within the analysis window."); + return string.Empty; + } + + private static IReadOnlyList StableSignature(IEnumerable> signatures, List diagnostics) + { + var materialized = signatures.ToArray(); + var normalized = materialized.Select(signature => signature.Select(ToKey).ToArray()).ToArray(); + if (normalized.Length == 0 || normalized.All(signature => signature.Length == 0)) return Array.Empty(); + var first = normalized[0]; + if (normalized.All(signature => signature.SequenceEqual(first, StringComparer.Ordinal))) return materialized[0].ToArray(); + diagnostics.Add("Observed dataset signature changed within the analysis window."); + return Array.Empty(); + } + + private static string ToKey(SvDatasetElementSignature element) + => $"{element.NormalizedBType}|{element.NormalizedCdc}|{element.IsQuality}|{element.IsTimestamp}"; +} From 7359153e7712b1fd8c82e6707240e92114c42c0d Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:28:43 +0700 Subject: [PATCH 27/39] Move SV configuration comparison contracts into core --- .../Profiles/SvConfigurationComparer.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs b/src/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs new file mode 100644 index 0000000..e4affc3 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs @@ -0,0 +1,97 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +public enum SvComparisonMode { Strict, Compatible } +public enum SvConfigurationFindingSeverity { Info, Warning, Error } + +public sealed record SvExpectedStreamConfiguration +{ + public ushort? EtherType { get; init; } + public ushort? AppId { get; init; } + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { get; init; } + public byte? VlanPriority { get; init; } + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public uint? ConfigurationRevision { get; init; } + public int? AsduPerFrame { get; init; } + public int? PayloadBytesPerAsdu { get; init; } + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public IReadOnlyList DataSetSignature { get; init; } = Array.Empty(); +} + +public sealed record SvConfigurationFinding( + SvConfigurationFindingSeverity Severity, + string Code, + string Field, + string Expected, + string Observed, + string Message); + +public sealed record SvConfigurationComparisonResult +{ + public SvComparisonMode Mode { get; init; } + public IReadOnlyList Findings { get; init; } = Array.Empty(); + public bool HasBlockingErrors => Findings.Any(item => item.Severity == SvConfigurationFindingSeverity.Error); + public int InfoCount => Findings.Count(item => item.Severity == SvConfigurationFindingSeverity.Info); + public int ErrorCount => Findings.Count(item => item.Severity == SvConfigurationFindingSeverity.Error); + public int WarningCount => Findings.Count(item => item.Severity == SvConfigurationFindingSeverity.Warning); + public bool IsExactMatch => Findings.Count == 0; + public string Summary => IsExactMatch ? "Exact" : ErrorCount > 0 ? CountText(ErrorCount, "error") : WarningCount > 0 ? CountText(WarningCount, "warning") : CountText(InfoCount, "info"); + private static string CountText(int count, string label) => $"{count} {label}{(count == 1 ? string.Empty : "s")}"; +} + +public sealed class SvConfigurationComparer +{ + public SvConfigurationComparisonResult Compare(SvExpectedStreamConfiguration expected, SvObservedStreamFacts observed, SvComparisonMode mode) + { + ArgumentNullException.ThrowIfNull(expected); + ArgumentNullException.ThrowIfNull(observed); + var findings = new List(); + CompareNullable("SV_ETHERTYPE", "EtherType", expected.EtherType, observed.EtherType, value => $"0x{value:X4}", mode, findings); + CompareNullable("SV_APPID", "APPID", expected.AppId, observed.AppId, value => $"0x{value:X4}", mode, findings); + CompareText("SV_DEST_MAC", "Destination MAC", expected.DestinationMac, observed.DestinationMac, NormalizeMacAddress, mode, findings); + CompareNullable("SV_VLAN_ID", "VLAN ID", expected.VlanId, observed.VlanId, value => value.ToString(), mode, findings); + CompareNullable("SV_VLAN_PRIORITY", "VLAN priority", expected.VlanPriority, observed.VlanPriority, value => value.ToString(), mode, findings); + CompareText("SV_ID", "svID", expected.SvId, observed.SvId, NormalizeIdentifier, mode, findings); + CompareText("SV_DATASET", "Dataset reference", expected.DataSetReference, observed.DataSetReference, NormalizeIdentifier, mode, findings); + CompareNullable("SV_CONFREV", "confRev", expected.ConfigurationRevision, observed.ConfigurationRevision, value => value.ToString(), mode, findings); + CompareNullable("SV_ASDU_COUNT", "ASDU per frame", expected.AsduPerFrame, observed.AsduPerFrame, value => value.ToString(), mode, findings); + CompareNullable("SV_PAYLOAD_LENGTH", "Payload bytes per ASDU", expected.PayloadBytesPerAsdu, observed.PayloadBytesPerAsdu, value => value.ToString(), mode, findings); + CompareNullable("SV_SAMPLE_RATE", "Declared sample rate", expected.DeclaredSampleRate, observed.DeclaredSampleRate, value => value.ToString(), mode, findings); + CompareNullable("SV_SAMPLE_MODE", "Declared sample mode", expected.DeclaredSampleMode, observed.DeclaredSampleMode, value => value.ToString(), mode, findings); + CompareSignature(expected.DataSetSignature, observed.DataSetSignature, mode, findings); + return new SvConfigurationComparisonResult { Mode = mode, Findings = findings }; + } + + private static void CompareText(string code, string field, string expected, string observed, Func normalize, SvComparisonMode mode, List findings) + { + if (string.IsNullOrWhiteSpace(expected)) return; + if (string.IsNullOrWhiteSpace(observed)) { findings.Add(Missing(code, field, expected, mode)); return; } + if (!string.Equals(normalize(expected), normalize(observed), StringComparison.Ordinal)) findings.Add(Mismatch(code, field, expected, observed, mode)); + } + + private static void CompareNullable(string code, string field, T? expected, T? observed, Func format, SvComparisonMode mode, List findings) where T : struct, IEquatable + { + if (!expected.HasValue) return; + if (!observed.HasValue) { findings.Add(Missing(code, field, format(expected.Value), mode)); return; } + if (!expected.Value.Equals(observed.Value)) findings.Add(Mismatch(code, field, format(expected.Value), format(observed.Value), mode)); + } + + private static void CompareSignature(IReadOnlyList expected, IReadOnlyList observed, SvComparisonMode mode, List findings) + { + if (expected.Count == 0) return; + if (observed.Count == 0) { findings.Add(Missing("SV_DATASET_SIGNATURE", "Dataset signature", SignatureText(expected), mode)); return; } + if (!expected.Select(ToKey).SequenceEqual(observed.Select(ToKey), StringComparer.Ordinal)) findings.Add(Mismatch("SV_DATASET_SIGNATURE", "Dataset signature", SignatureText(expected), SignatureText(observed), mode)); + } + + private static SvConfigurationFinding Missing(string code, string field, string expected, SvComparisonMode mode) + => new(Severity(mode), $"{code}_MISSING", field, expected, "-", $"Observed traffic does not expose the expected {field}. Capture and decoding remain active."); + private static SvConfigurationFinding Mismatch(string code, string field, string expected, string observed, SvComparisonMode mode) + => new(Severity(mode), $"{code}_MISMATCH", field, expected, observed, $"Configured {field} differs from observed traffic. Capture and decoding remain active."); + private static SvConfigurationFindingSeverity Severity(SvComparisonMode mode) => mode == SvComparisonMode.Strict ? SvConfigurationFindingSeverity.Error : SvConfigurationFindingSeverity.Warning; + private static string NormalizeMacAddress(string value) => new((value ?? string.Empty).Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray()); + private static string NormalizeIdentifier(string value) => (value ?? string.Empty).Trim(); + private static string SignatureText(IReadOnlyList signature) => string.Join(", ", signature.Select(item => item.NormalizedBType)); + private static string ToKey(SvDatasetElementSignature item) => $"{item.NormalizedBType}|{item.NormalizedCdc}|{item.IsQuality}|{item.IsTimestamp}"; +} From ee33d49875eaa62082764a166ad0a4890722c2b6 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:28:59 +0700 Subject: [PATCH 28/39] Move SCL expected stream configuration factory into core --- .../SvExpectedStreamConfigurationFactory.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs b/src/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs new file mode 100644 index 0000000..0c08d14 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvExpectedStreamConfigurationFactory.cs @@ -0,0 +1,39 @@ +using AR.Iec61850.Scl; + +namespace AR.Iec61850.SampledValues.Profiles; + +public static class SvExpectedStreamConfigurationFactory +{ + public static SvExpectedStreamConfiguration Create(SampledValuesPublisherProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + return new SvExpectedStreamConfiguration + { + EtherType = 0x88BA, + AppId = profile.AppId, + DestinationMac = profile.Destination.ToString(), + VlanId = profile.Vlan?.VlanId, + VlanPriority = profile.Vlan?.PriorityCodePoint, + SvId = profile.Stream.SvId, + DataSetReference = profile.Stream.DataSetReference, + ConfigurationRevision = profile.Stream.ConfigurationRevision, + AsduPerFrame = profile.AsduPerFrame, + PayloadBytesPerAsdu = profile.PayloadLayout.PayloadByteLength, + DeclaredSampleRate = profile.Stream.SampleRate == 0 ? null : profile.Stream.SampleRate, + DeclaredSampleMode = MapSampleMode(profile.Stream.SampleMode), + DataSetSignature = profile.Entries.Select(ToSignature).ToArray() + }; + } + + private static ushort? MapSampleMode(string sampleMode) + => string.IsNullOrWhiteSpace(sampleMode) ? null : sampleMode.Trim() switch + { + "SmpPerPeriod" => 0, + "SmpPerSec" => 1, + "SecPerSmp" => 2, + _ => null + }; + + private static SvDatasetElementSignature ToSignature(SclDataSetEntry entry) + => new() { BType = entry.BType, Cdc = entry.Cdc, IsQuality = entry.IsQuality, IsTimestamp = entry.IsTimestamp }; +} From 37db0fa9516f69b7ba3953eddc44a29c05c27043 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:29:56 +0700 Subject: [PATCH 29/39] Move vendor-neutral SV evidence detector into core --- .../Profiles/SvProfileDetector.cs | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Profiles/SvProfileDetector.cs diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvProfileDetector.cs b/src/AR.Iec61850/SampledValues/Profiles/SvProfileDetector.cs new file mode 100644 index 0000000..10594e1 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvProfileDetector.cs @@ -0,0 +1,154 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +/// +/// Evidence-weighted, vendor-neutral profile detector. It evaluates only observable wire, +/// SCL, capture-rate, and trusted-context facts; manufacturer identity is never an input. +/// +public sealed class SvProfileDetector +{ + private const int EtherTypeWeight = 5; + private const int AsduWeight = 12; + private const int PayloadWeight = 18; + private const int DataSetCountWeight = 12; + private const int DataSetSignatureWeight = 25; + private const int SamplingRateWeight = 25; + private const int NominalFrequencyWeight = 8; + private const int CounterWrapWeight = 15; + + public IReadOnlyList Detect(SvObservedStreamFacts facts, IEnumerable profiles) + { + ArgumentNullException.ThrowIfNull(facts); + ArgumentNullException.ThrowIfNull(profiles); + return profiles.Select(profile => Evaluate(facts, profile)) + .OrderByDescending(result => Rank(result.Confidence)) + .ThenByDescending(result => result.ScorePercent) + .ThenByDescending(result => result.EvaluatedWeight) + .ThenBy(result => result.Profile.DisplayName, StringComparer.Ordinal) + .ToArray(); + } + + public SvProfileDetectionResult? DetectBest(SvObservedStreamFacts facts, IEnumerable profiles) + => Detect(facts, profiles).FirstOrDefault(); + + public SvProfileDetectionResult Evaluate(SvObservedStreamFacts facts, SvProfileDefinition profile) + { + ArgumentNullException.ThrowIfNull(facts); + ArgumentNullException.ThrowIfNull(profile); + profile.Validate(); + var evidence = new List(); + Compare("EtherType", profile.ExpectedEtherType, facts.EtherType, EtherTypeWeight, value => $"0x{value:X4}", evidence); + CompareAllowed("ASDU per frame", profile.AllowedAsduPerFrame, facts.AsduPerFrame, AsduWeight, evidence); + Compare("Payload bytes per ASDU", profile.ExpectedPayloadBytesPerAsdu, facts.PayloadBytesPerAsdu, PayloadWeight, value => value.ToString(), evidence); + Compare("Dataset element count", profile.ExpectedDataSetElementCount, facts.DataSetSignature.Count > 0 ? facts.DataSetSignature.Count : null, DataSetCountWeight, value => value.ToString(), evidence); + CompareSignature(profile.ExpectedDataSetSignature, facts.DataSetSignature, evidence); + CompareSampling(profile, facts, evidence); + CompareAllowedDouble("Nominal frequency", profile.AllowedNominalFrequenciesHz, facts.NominalFrequencyHz, NominalFrequencyWeight, profile.RateTolerancePercent, evidence); + Compare("Sample-counter wrap", profile.ExpectedCounterWrap, facts.ObservedCounterWrap, CounterWrapWeight, value => value.ToString(), evidence); + + var evaluated = evidence.Where(item => item.Outcome != SvProfileEvidenceOutcome.Unknown).Sum(item => item.Weight); + var matched = evidence.Where(item => item.Outcome == SvProfileEvidenceOutcome.Match).Sum(item => item.Weight); + var conflict = evidence.Where(item => item.Outcome == SvProfileEvidenceOutcome.Conflict).Sum(item => item.Weight); + var score = evaluated == 0 ? 0 : Math.Round((double)matched / evaluated * 100, 2); + var raw = ResolveRawConfidence(score, matched, conflict, evaluated, + Match(evidence, "Dataset signature"), + Match(evidence, "Observed samples per second") || Match(evidence, "Samples per cycle")); + return new SvProfileDetectionResult + { + Profile = profile, + RawConfidence = raw, + ScorePercent = score, + MatchedWeight = matched, + ConflictWeight = conflict, + EvaluatedWeight = evaluated, + Evidence = evidence + }; + } + + private static SvProfileConfidence ResolveRawConfidence(double score, int matched, int conflict, int evaluated, bool signature, bool sampling) + { + if (evaluated == 0) return SvProfileConfidence.Unknown; + if (conflict >= matched && conflict > 0) return SvProfileConfidence.Conflict; + if (conflict == 0 && score >= 90 && evaluated >= 70 && signature && sampling) return SvProfileConfidence.Confirmed; + if (score >= 70 && evaluated >= 40) return SvProfileConfidence.Likely; + if (score >= 45 && evaluated >= 15) return SvProfileConfidence.Possible; + return conflict > 0 ? SvProfileConfidence.Conflict : SvProfileConfidence.Unknown; + } + + private static void CompareSampling(SvProfileDefinition profile, SvObservedStreamFacts facts, List evidence) + { + if (profile.SamplingBasis == SvSamplingBasis.SamplesPerSecond && profile.ExpectedSamplesPerSecond.HasValue) + CompareApproximate("Observed samples per second", profile.ExpectedSamplesPerSecond.Value, facts.ObservedSamplesPerSecond, SamplingRateWeight, profile.RateTolerancePercent, evidence); + else if (profile.SamplingBasis == SvSamplingBasis.SamplesPerCycle && profile.ExpectedSamplesPerCycle.HasValue) + { + if (!facts.ObservedSamplesPerSecond.HasValue || !facts.NominalFrequencyHz.HasValue) + evidence.Add(new("Samples per cycle", SvProfileEvidenceOutcome.Unknown, SamplingRateWeight, profile.ExpectedSamplesPerCycle.Value.ToString("0.###"), "-", "Observed rate and nominal frequency are both required.")); + else + CompareApproximate("Samples per cycle", profile.ExpectedSamplesPerCycle.Value, facts.ObservedSamplesPerSecond.Value / facts.NominalFrequencyHz.Value, SamplingRateWeight, profile.RateTolerancePercent, evidence); + } + } + + private static void CompareSignature(IReadOnlyList expected, IReadOnlyList observed, List evidence) + { + if (expected.Count == 0) return; + if (observed.Count == 0) + { + evidence.Add(new("Dataset signature", SvProfileEvidenceOutcome.Unknown, DataSetSignatureWeight, Signature(expected), "-", "No dataset signature is available for this observation window.")); + return; + } + var matches = expected.Select(Key).SequenceEqual(observed.Select(Key), StringComparer.Ordinal); + evidence.Add(new("Dataset signature", matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, DataSetSignatureWeight, + Signature(expected), Signature(observed), matches ? "Dataset element order and types match the profile definition." : "Dataset element order or types conflict with the profile definition.")); + } + + private static void CompareAllowed(string field, IReadOnlyList allowed, int? observed, int weight, List evidence) + { + if (allowed.Count == 0) return; + if (!observed.HasValue) { evidence.Add(new(field, SvProfileEvidenceOutcome.Unknown, weight, string.Join("/", allowed), "-", $"Observed {field} is unavailable.")); return; } + var matches = allowed.Contains(observed.Value); + evidence.Add(new(field, matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, weight, string.Join("/", allowed), observed.Value.ToString(), matches ? $"Observed {field} is allowed." : $"Observed {field} is not allowed by the profile definition.")); + } + + private static void CompareAllowedDouble(string field, IReadOnlyList allowed, double? observed, int weight, double tolerance, List evidence) + { + if (allowed.Count == 0) return; + if (!observed.HasValue) { evidence.Add(new(field, SvProfileEvidenceOutcome.Unknown, weight, string.Join("/", allowed), "-", $"Observed {field} is unavailable.")); return; } + var matches = allowed.Any(value => Within(value, observed.Value, tolerance)); + evidence.Add(new(field, matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, weight, string.Join("/", allowed), observed.Value.ToString("0.###"), matches ? $"Observed {field} is allowed." : $"Observed {field} conflicts with the allowed values.")); + } + + private static void CompareApproximate(string field, double expected, double? observed, int weight, double tolerance, List evidence) + { + if (!observed.HasValue) { evidence.Add(new(field, SvProfileEvidenceOutcome.Unknown, weight, expected.ToString("0.###"), "-", $"Observed {field} is unavailable.")); return; } + var matches = Within(expected, observed.Value, tolerance); + evidence.Add(new(field, matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, weight, expected.ToString("0.###"), observed.Value.ToString("0.###"), matches ? $"Observed {field} is within {tolerance:0.###}% tolerance." : $"Observed {field} is outside {tolerance:0.###}% tolerance.")); + } + + private static void Compare(string field, T? expected, T? observed, int weight, Func format, List evidence) where T : struct, IEquatable + { + if (!expected.HasValue) return; + if (!observed.HasValue) { evidence.Add(new(field, SvProfileEvidenceOutcome.Unknown, weight, format(expected.Value), "-", $"Observed {field} is unavailable.")); return; } + var matches = expected.Value.Equals(observed.Value); + evidence.Add(new(field, matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, weight, format(expected.Value), format(observed.Value), matches ? $"Observed {field} matches." : $"Observed {field} conflicts with the profile definition.")); + } + + private static bool Within(double expected, double observed, double tolerance) => expected == 0 ? observed == 0 : Math.Abs(observed - expected) / Math.Abs(expected) * 100 <= tolerance; + private static bool Match(IEnumerable evidence, string field) => evidence.Any(item => item.Field == field && item.Outcome == SvProfileEvidenceOutcome.Match); + private static string Signature(IReadOnlyList value) => string.Join(", ", value.Select(element => element.NormalizedBType)); + private static string Key(SvDatasetElementSignature element) => $"{element.NormalizedBType}|{element.NormalizedCdc}|{element.IsQuality}|{element.IsTimestamp}"; + private static int Rank(SvProfileConfidence value) => value switch { SvProfileConfidence.Confirmed => 4, SvProfileConfidence.Likely => 3, SvProfileConfidence.Possible => 2, SvProfileConfidence.Unknown => 1, _ => 0 }; +} + +public static class SvProfileCatalog +{ + public static SvProfileDefinition GenericSclLayer2 { get; } = new() + { + Id = "generic-scl-layer2", + DisplayName = "Generic SCL-driven Layer-2 SV", + Family = "Generic Layer-2 SV", + SamplingBasis = SvSamplingBasis.Custom, + ExpectedEtherType = 0x88BA, + EvidenceStatus = SvProfileEvidenceStatus.ImplementedGeneric, + Sources = [new SvProfileSourceEvidence("ariec61850-engine", "Generic Layer-2 SV mechanisms implemented by the shared engine without a profile-specific conformance claim.", SvProfileEvidenceStatus.ImplementedGeneric)] + }; + public static IReadOnlyList BuiltIn { get; } = [GenericSclLayer2]; +} From 138ded12b8d1994ef1210e688dabc5562ada0a9f Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:30:44 +0700 Subject: [PATCH 30/39] Move unified live and PCAP SV observation manager into core --- .../Profiles/SvStreamObservationManager.cs | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs diff --git a/src/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs new file mode 100644 index 0000000..0f70e6d --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs @@ -0,0 +1,184 @@ +using System.Collections.Concurrent; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.SampledValues.Profiles; + +public enum SvObservationInputKind { Unknown, LiveCapture, PcapReplay } + +public sealed record SvObservedStreamKey +{ + public string SourceMac { get; init; } = string.Empty; + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { get; init; } + public ushort AppId { get; init; } + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public string Id => $"SV|{AppId:X4}|{SourceMac}|{DestinationMac}|{VlanId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"}|{SvId}|{DataSetReference}"; + + public static SvObservedStreamKey FromFrame(SampledValuesFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + var first = frame.Pdu.Asdus.FirstOrDefault() ?? throw new ArgumentException("An observed SV frame requires at least one ASDU.", nameof(frame)); + return new SvObservedStreamKey + { + SourceMac = frame.Source.ToString(), DestinationMac = frame.Destination.ToString(), VlanId = frame.Vlan?.VlanId, + AppId = frame.AppId, SvId = first.SvId, DataSetReference = first.DataSetReference + }; + } +} + +public sealed record SvStreamObservationSnapshot +{ + public SvObservedStreamKey Key { get; init; } = new(); + public SvObservedStreamFacts Facts { get; init; } = new(); + public IReadOnlyList InputKinds { get; init; } = Array.Empty(); + public SvObservationInputKind LastInputKind { get; init; } + public bool IsBoundToScl { get; init; } + public string ControlBlockReference { get; init; } = string.Empty; + public SvExpectedStreamConfiguration? ExpectedConfiguration { get; init; } + public SvConfigurationComparisonResult? ConfigurationComparison { get; init; } + public SvProfileDetectionResult? ProfileDetection { get; init; } + public string ConfigurationMatchSummary => ConfigurationComparison?.Summary ?? "Not configured"; + public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); +} + +public sealed class SvStreamObservationManager +{ + public const int DefaultMaximumObservations = 12_288; + public static readonly TimeSpan DefaultMaximumAge = TimeSpan.FromSeconds(2); + + private sealed class StreamState + { + private readonly object _gate = new(); + private readonly HashSet _inputKinds = []; + private readonly Queue _diagnostics = new(); + private SvExpectedStreamConfiguration? _expected; + private SvComparisonMode _mode = SvComparisonMode.Compatible; + public StreamState(int maximum, TimeSpan age) => Accumulator = new SvObservationAccumulator(maximum, age); + public SvObservationAccumulator Accumulator { get; } + public SvObservationInputKind LastInputKind { get; private set; } + public bool IsBoundToScl { get; private set; } + public string ControlBlockReference { get; private set; } = string.Empty; + + public void Add(SvFrameObservation observation, SvObservationInputKind inputKind, SampledValuesPublisherProfile? profile, SvComparisonMode mode, IEnumerable diagnostics) + { + Accumulator.Add(observation); + lock (_gate) + { + LastInputKind = inputKind; + _inputKinds.Add(inputKind); + if (profile is not null) + { + IsBoundToScl = true; + ControlBlockReference = profile.Stream.ControlBlockReference; + _expected = SvExpectedStreamConfigurationFactory.Create(profile); + _mode = mode; + } + foreach (var diagnostic in diagnostics.Where(item => !string.IsNullOrWhiteSpace(item))) + { + if (_diagnostics.Contains(diagnostic, StringComparer.Ordinal)) continue; + _diagnostics.Enqueue(diagnostic); + while (_diagnostics.Count > 16) _diagnostics.Dequeue(); + } + } + } + + public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key) + { + var facts = Accumulator.BuildFacts(); + lock (_gate) + { + var comparison = _expected is null ? null : new SvConfigurationComparer().Compare(_expected, facts, _mode); + return new SvStreamObservationSnapshot + { + Key = key, + Facts = facts, + InputKinds = _inputKinds.OrderBy(item => item).ToArray(), + LastInputKind = LastInputKind, + IsBoundToScl = IsBoundToScl, + ControlBlockReference = ControlBlockReference, + ExpectedConfiguration = _expected, + ConfigurationComparison = comparison, + ProfileDetection = new SvProfileDetector().DetectBest(facts, SvProfileCatalog.BuiltIn), + Diagnostics = facts.Diagnostics.Concat(_diagnostics).Distinct(StringComparer.Ordinal).ToArray() + }; + } + } + } + + private readonly ConcurrentDictionary _streams = new(); + private readonly int _maximum; + private readonly TimeSpan _age; + + public SvStreamObservationManager(int maximumObservations = DefaultMaximumObservations, TimeSpan? maximumAge = null) + { + if (maximumObservations < 2) throw new ArgumentOutOfRangeException(nameof(maximumObservations)); + _age = maximumAge ?? DefaultMaximumAge; + if (_age <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(maximumAge)); + _maximum = maximumObservations; + } + + public int Count => _streams.Count; + + public bool TryObserve(DateTimeOffset timestamp, SampledValuesFrame frame, SvObservationInputKind inputKind, SampledValuesPublisherProfile? profile, out SvStreamObservationSnapshot snapshot, double? nominalFrequencyHz = null, SvComparisonMode comparisonMode = SvComparisonMode.Compatible) + { + ArgumentNullException.ThrowIfNull(frame); + snapshot = new(); + var asdus = frame.Pdu.Asdus; + var first = asdus.FirstOrDefault(); + if (first is null || first.SamplePayload.Length <= 0) return false; + + var key = SvObservedStreamKey.FromFrame(frame); + var diagnostics = ValidateFrameConsistency(asdus).ToList(); + var boundProfile = ValidateProfileBinding(frame, profile, diagnostics); + var signature = boundProfile?.Entries.Select(ToSignature).ToArray() ?? Array.Empty(); + var payloadLengths = asdus.Select(item => item.SamplePayload.Length).Distinct().ToArray(); + var observation = new SvFrameObservation + { + Timestamp = timestamp, EtherType = 0x88BA, AppId = frame.AppId, DestinationMac = frame.Destination.ToString(), + VlanId = frame.Vlan?.VlanId, VlanPriority = frame.Vlan?.PriorityCodePoint, SvId = first.SvId, + DataSetReference = first.DataSetReference, ConfigurationRevision = first.ConfigurationRevision, + PayloadBytesPerAsdu = payloadLengths.Length == 1 ? payloadLengths[0] : first.SamplePayload.Length, + SampleCounts = asdus.Select(item => item.SampleCount).ToArray(), + DeclaredSampleRate = StableNullable(asdus.Select(item => item.SampleRate)), + DeclaredSampleMode = StableNullable(asdus.Select(item => item.SampleMode)), + NominalFrequencyHz = nominalFrequencyHz, DataSetSignature = signature + }; + var state = _streams.GetOrAdd(key, _ => new StreamState(_maximum, _age)); + state.Add(observation, inputKind, boundProfile, comparisonMode, diagnostics); + snapshot = state.Snapshot(key); + return true; + } + + public bool TryGetSnapshot(SvObservedStreamKey key, out SvStreamObservationSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(key); + if (_streams.TryGetValue(key, out var state)) { snapshot = state.Snapshot(key); return true; } + snapshot = new(); return false; + } + + public IReadOnlyList SnapshotAll() + => _streams.Select(pair => pair.Value.Snapshot(pair.Key)).OrderBy(item => item.Key.AppId).ThenBy(item => item.Key.SvId, StringComparer.Ordinal).ThenBy(item => item.Key.DataSetReference, StringComparer.Ordinal).ToArray(); + public void Clear() => _streams.Clear(); + + private static SampledValuesPublisherProfile? ValidateProfileBinding(SampledValuesFrame frame, SampledValuesPublisherProfile? profile, ICollection diagnostics) + { + if (profile is null) return null; + if (profile.AppId == frame.AppId && string.Equals(profile.Destination.ToString(), frame.Destination.ToString(), StringComparison.OrdinalIgnoreCase) && profile.Vlan?.VlanId == frame.Vlan?.VlanId) return profile; + diagnostics.Add($"Rejected SCL candidate {profile.Stream.ControlBlockReference}: APPID, destination MAC, and VLAN must identify the same configured stream before comparison."); + return null; + } + + private static IReadOnlyList ValidateFrameConsistency(IReadOnlyList asdus) + { + var diagnostics = new List(); var first = asdus[0]; + if (asdus.Any(item => item.SvId != first.SvId)) diagnostics.Add("ASDUs inside one Ethernet frame expose different svID values."); + if (asdus.Any(item => item.DataSetReference != first.DataSetReference)) diagnostics.Add("ASDUs inside one Ethernet frame expose different dataset references."); + if (asdus.Any(item => item.ConfigurationRevision != first.ConfigurationRevision)) diagnostics.Add("ASDUs inside one Ethernet frame expose different confRev values."); + if (asdus.Select(item => item.SamplePayload.Length).Distinct().Count() > 1) diagnostics.Add("ASDUs inside one Ethernet frame expose different payload lengths."); + return diagnostics; + } + + private static SvDatasetElementSignature ToSignature(SclDataSetEntry entry) => new() { BType = entry.BType, Cdc = entry.Cdc, IsQuality = entry.IsQuality, IsTimestamp = entry.IsTimestamp }; + private static ushort? StableNullable(IEnumerable values) { var distinct = values.Distinct().ToArray(); return distinct.Length == 1 ? distinct[0] : null; } +} From ffe3aa9dfb60211a751a86a226854a619e996f16 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:32:00 +0700 Subject: [PATCH 31/39] Move Subscriber evidence report contracts and serialization into core --- .../Reporting/SvSubscriberEvidenceReport.cs | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs diff --git a/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs b/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs new file mode 100644 index 0000000..b248e8d --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceReport.cs @@ -0,0 +1,173 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using AR.Iec61850.SampledValues.Profiles; + +namespace AR.Iec61850.SampledValues.Reporting; + +public sealed record SvSubscriberEvidenceReport +{ + public const string CurrentSchemaVersion = "arsvin.sv-subscriber-evidence/v1"; + public string SchemaVersion { get; init; } = CurrentSchemaVersion; + public DateTimeOffset GeneratedAt { get; init; } + public SvSubscriberSoftwareEvidence Software { get; init; } = new(); + public SvSubscriberCaptureEvidence Capture { get; init; } = new(); + public SvSubscriberSummaryEvidence Summary { get; init; } = new(); + public IReadOnlyList Streams { get; init; } = Array.Empty(); + + public void Validate() + { + if (SchemaVersion != CurrentSchemaVersion) throw new InvalidOperationException($"Unsupported SV report schema '{SchemaVersion}'."); + if (GeneratedAt == default) throw new InvalidOperationException("SV report requires a generation timestamp."); + if (string.IsNullOrWhiteSpace(Software.Product)) throw new InvalidOperationException("SV report requires a product name."); + if (Summary.StreamCount != Streams.Count) throw new InvalidOperationException("SV report summary stream count does not match the stream evidence collection."); + if (Streams.Any(stream => string.IsNullOrWhiteSpace(stream.Key))) throw new InvalidOperationException("Every SV report stream requires a stable key."); + if (Streams.Select(stream => stream.Key).Distinct(StringComparer.Ordinal).Count() != Streams.Count) throw new InvalidOperationException("SV report stream keys must be unique."); + } +} + +public sealed record SvSubscriberSoftwareEvidence { public string Product { get; init; } = string.Empty; public string Version { get; init; } = string.Empty; public string InformationalVersion { get; init; } = string.Empty; public string Commit { get; init; } = string.Empty; public string Repository { get; init; } = string.Empty; } +public sealed record SvSubscriberCaptureEvidence { public string Source { get; init; } = "Unknown"; public string SclPath { get; init; } = string.Empty; public string Adapter { get; init; } = string.Empty; public string Filter { get; init; } = string.Empty; public DateTimeOffset? StartedAt { get; init; } public DateTimeOffset EndedAt { get; init; } public double DurationSeconds { get; init; } public long RawFrames { get; init; } public long SvFrames { get; init; } public long ParseErrors { get; init; } public long DroppedByFilter { get; init; } } +public sealed record SvSubscriberSummaryEvidence { public string Health { get; init; } = "IDLE"; public int StreamCount { get; init; } public int RuntimeIssueCount { get; init; } public int ConfigurationFindingCount { get; init; } } + +public sealed record SvSubscriberStreamEvidence +{ + public string Key { get; init; } = string.Empty; + public string Health { get; init; } = "IDLE"; + public string HealthDetail { get; init; } = string.Empty; + public SvSubscriberStreamIdentityEvidence Identity { get; init; } = new(); + public SvSubscriberRuntimeEvidence Runtime { get; init; } = new(); + public SvSubscriberObservationEvidence Observation { get; init; } = new(); + public IReadOnlyList Phasors { get; init; } = Array.Empty(); + public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); +} + +public sealed record SvSubscriberStreamIdentityEvidence +{ + public ushort AppId { get; init; } + public string SourceMac { get; init; } = string.Empty; + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { get; init; } + public byte? VlanPriority { get; init; } + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public uint? ConfigurationRevision { get; init; } + public int AsduPerFrame { get; init; } + public ushort? LastSampleCount { get; init; } + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public byte? SampleSynchronization { get; init; } +} + +public sealed record SvSubscriberRuntimeEvidence +{ + public long FrameCount { get; init; } + public long AsduCount { get; init; } + public double ActualFramesPerSecond { get; init; } + public double AverageFrameGapMilliseconds { get; init; } + public double MaximumFrameGapMilliseconds { get; init; } + public int SequenceGapCount { get; init; } + public int DuplicateCount { get; init; } + public int OutOfOrderCount { get; init; } + public int PayloadIssueCount { get; init; } + public int SclMismatchCount { get; init; } + public bool IsWaveformWindowReady { get; init; } + public string LayoutBinding { get; init; } = string.Empty; + public string QualitySummary { get; init; } = string.Empty; + public string CursorSummary { get; init; } = string.Empty; + public string LastSeen { get; init; } = string.Empty; +} + +public sealed record SvSubscriberObservationEvidence +{ + public IReadOnlyList InputKinds { get; init; } = Array.Empty(); + public SvObservationInputKind LastInputKind { get; init; } + public bool IsBoundToScl { get; init; } + public string ControlBlockReference { get; init; } = string.Empty; + public int WindowFrames { get; init; } + public int WindowSamples { get; init; } + public double WindowDurationSeconds { get; init; } + public DateTimeOffset? FirstTimestamp { get; init; } + public DateTimeOffset? LastTimestamp { get; init; } + public SvObservedStreamFacts Facts { get; init; } = new(); + public IReadOnlyDictionary FactProvenance { get; init; } = new Dictionary(StringComparer.Ordinal); + public SvProfileDetectionResult? ProfileDetection { get; init; } + public SvExpectedStreamConfiguration? ExpectedConfiguration { get; init; } + public SvConfigurationComparisonResult? ConfigurationComparison { get; init; } + public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); +} + +public sealed record SvSubscriberPhasorEvidence { public string Channel { get; init; } = string.Empty; public string Kind { get; init; } = string.Empty; public double Rms { get; init; } public double Peak { get; init; } public double AngleDegrees { get; init; } } + +public static class SvSubscriberEvidenceReportSerializer +{ + private static readonly JsonSerializerOptions Options = CreateOptions(); + + public static string ToJson(SvSubscriberEvidenceReport report) { ArgumentNullException.ThrowIfNull(report); report.Validate(); return JsonSerializer.Serialize(report, Options); } + public static SvSubscriberEvidenceReport FromJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("SV report JSON cannot be empty.", nameof(json)); + var report = JsonSerializer.Deserialize(json, Options) ?? throw new InvalidDataException("SV report JSON did not contain a report document."); + report.Validate(); return report; + } + + public static string ToMarkdown(SvSubscriberEvidenceReport report) + { + ArgumentNullException.ThrowIfNull(report); report.Validate(); + var b = new StringBuilder(); + b.AppendLine("# ARSVIN Subscriber Evidence Report").AppendLine(); + b.AppendLine("> Receiver-side engineering evidence. This document is not a formal IEC 61850 conformance certificate.").AppendLine(); + b.AppendLine("## Report metadata").AppendLine(); + Table(b, ("Schema", report.SchemaVersion), ("Generated", report.GeneratedAt.ToString("O", CultureInfo.InvariantCulture)), ("Product", report.Software.Product), ("Version", report.Software.Version), ("Informational version", report.Software.InformationalVersion), ("Commit", report.Software.Commit), ("Repository", report.Software.Repository), ("Capture source", report.Capture.Source), ("SCL", Empty(report.Capture.SclPath, "not loaded")), ("Adapter", Empty(report.Capture.Adapter)), ("Filter", Empty(report.Capture.Filter, "none"))); + b.AppendLine("## Summary").AppendLine(); + Table(b, ("Health", report.Summary.Health), ("Raw frames", report.Capture.RawFrames.ToString("N0", CultureInfo.InvariantCulture)), ("SV frames", report.Capture.SvFrames.ToString("N0", CultureInfo.InvariantCulture)), ("Streams", report.Summary.StreamCount.ToString(CultureInfo.InvariantCulture)), ("Runtime issues", report.Summary.RuntimeIssueCount.ToString(CultureInfo.InvariantCulture)), ("Configuration findings", report.Summary.ConfigurationFindingCount.ToString(CultureInfo.InvariantCulture))); + b.AppendLine("## Streams").AppendLine(); + foreach (var stream in report.Streams) AppendStream(b, stream); + return b.ToString(); + } + + private static void AppendStream(StringBuilder b, SvSubscriberStreamEvidence stream) + { + b.Append("## 0x").Append(stream.Identity.AppId.ToString("X4", CultureInfo.InvariantCulture)).Append(" — ").AppendLine(Empty(stream.Identity.SvId)).AppendLine(); + Table(b, ("Stream key", stream.Key), ("Health", stream.Health), ("Health detail", stream.HealthDetail), ("Source MAC", stream.Identity.SourceMac), ("Destination MAC", stream.Identity.DestinationMac), ("Dataset", stream.Identity.DataSetReference), ("confRev", Value(stream.Identity.ConfigurationRevision)), ("nofASDU", stream.Identity.AsduPerFrame.ToString(CultureInfo.InvariantCulture)), ("SCL binding", stream.Observation.IsBoundToScl ? Empty(stream.Observation.ControlBlockReference) : "not bound")); + b.AppendLine("### Observed facts and provenance").AppendLine(); + b.AppendLine("| Fact | Value | Source |").AppendLine("|---|---|---|"); + foreach (var item in stream.Observation.FactProvenance.OrderBy(item => item.Key, StringComparer.Ordinal)) + b.Append("| ").Append(Cell(item.Key)).Append(" | ").Append(Cell(FactValue(stream.Observation.Facts, item.Key))).Append(" | ").Append(Cell(item.Value.ToString())).AppendLine(" |"); + b.AppendLine(); + b.AppendLine("### Expected SCL configuration").AppendLine(); + if (stream.Observation.ExpectedConfiguration is { } expected) Table(b, ("APPID", Value(expected.AppId)), ("Destination MAC", expected.DestinationMac), ("svID", expected.SvId), ("Dataset", expected.DataSetReference), ("confRev", Value(expected.ConfigurationRevision)), ("ASDU/frame", Value(expected.AsduPerFrame))); + else b.AppendLine("Not configured.").AppendLine(); + b.AppendLine("### Configuration comparison").AppendLine(); + var comparison = stream.Observation.ConfigurationComparison; + if (comparison is null) b.AppendLine("Not configured.").AppendLine(); else { b.AppendLine(comparison.Summary).AppendLine(); foreach (var finding in comparison.Findings) b.Append("- **").Append(finding.Code).Append("** · ").Append(finding.Field).Append(" · ").AppendLine(finding.Message); b.AppendLine(); } + b.AppendLine("### Profile detection evidence").AppendLine(); + var detection = stream.Observation.ProfileDetection; + if (detection is null) b.AppendLine("Unknown.").AppendLine(); else { b.Append("- Profile: ").AppendLine(detection.Profile.DisplayName); b.Append("- Confidence: ").AppendLine(detection.Confidence.ToString()); foreach (var evidence in detection.Evidence) b.Append("- ").Append(evidence.Field).Append(" · ").Append(evidence.Outcome).Append(" · ").AppendLine(evidence.Message); b.AppendLine(); } + b.AppendLine("### Phasors").AppendLine(); + if (stream.Phasors.Count == 0) b.AppendLine("None.").AppendLine(); else { b.AppendLine("| Channel | Kind | RMS | Peak | Angle |").AppendLine("|---|---|---:|---:|---:|"); foreach (var p in stream.Phasors) b.Append("| ").Append(Cell(p.Channel)).Append(" | ").Append(Cell(p.Kind)).Append(" | ").Append(p.Rms.ToString("0.###", CultureInfo.InvariantCulture)).Append(" | ").Append(p.Peak.ToString("0.###", CultureInfo.InvariantCulture)).Append(" | ").Append(p.AngleDegrees.ToString("0.###", CultureInfo.InvariantCulture)).AppendLine(" |"); b.AppendLine(); } + } + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); return options; + } + private static void Table(StringBuilder b, params (string Key, string Value)[] rows) { b.AppendLine("| Field | Value |").AppendLine("|---|---|"); foreach (var row in rows) b.Append("| ").Append(Cell(row.Key)).Append(" | ").Append(Cell(row.Value)).AppendLine(" |"); b.AppendLine(); } + private static string Empty(string? value, string fallback = "-") => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + private static string Value(T? value) where T : struct => value?.ToString() ?? "-"; + private static string Cell(string? value) => Empty(value).Replace("|", "\\|", StringComparison.Ordinal).Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal); + private static string FactValue(SvObservedStreamFacts facts, string name) => name switch + { + nameof(SvObservedStreamFacts.AppId) => Value(facts.AppId), nameof(SvObservedStreamFacts.EtherType) => Value(facts.EtherType), nameof(SvObservedStreamFacts.DestinationMac) => facts.DestinationMac, + nameof(SvObservedStreamFacts.VlanId) => Value(facts.VlanId), nameof(SvObservedStreamFacts.VlanPriority) => Value(facts.VlanPriority), nameof(SvObservedStreamFacts.SvId) => facts.SvId, + nameof(SvObservedStreamFacts.DataSetReference) => facts.DataSetReference, nameof(SvObservedStreamFacts.ConfigurationRevision) => Value(facts.ConfigurationRevision), + nameof(SvObservedStreamFacts.AsduPerFrame) => Value(facts.AsduPerFrame), nameof(SvObservedStreamFacts.PayloadBytesPerAsdu) => Value(facts.PayloadBytesPerAsdu), + nameof(SvObservedStreamFacts.ObservedFramesPerSecond) => facts.ObservedFramesPerSecond?.ToString("0.###", CultureInfo.InvariantCulture) ?? "-", + nameof(SvObservedStreamFacts.ObservedSamplesPerSecond) => facts.ObservedSamplesPerSecond?.ToString("0.###", CultureInfo.InvariantCulture) ?? "-", + nameof(SvObservedStreamFacts.ObservedCounterWrap) => Value(facts.ObservedCounterWrap), nameof(SvObservedStreamFacts.DeclaredSampleRate) => Value(facts.DeclaredSampleRate), + nameof(SvObservedStreamFacts.DeclaredSampleMode) => Value(facts.DeclaredSampleMode), nameof(SvObservedStreamFacts.NominalFrequencyHz) => facts.NominalFrequencyHz?.ToString("0.###", CultureInfo.InvariantCulture) ?? "-", + nameof(SvObservedStreamFacts.DataSetSignature) => string.Join(", ", facts.DataSetSignature.Select(item => item.NormalizedBType)), _ => "-" + }; +} From 3fd45b5d34cfd37d0bf1aaf5eb03b0ff882392dd Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:33:10 +0700 Subject: [PATCH 32/39] Move Subscriber evidence comparison contracts into core --- .../SvSubscriberEvidenceComparison.cs | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs diff --git a/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs b/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs new file mode 100644 index 0000000..ec7fd3b --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Reporting/SvSubscriberEvidenceComparison.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AR.Iec61850.SampledValues.Reporting; + +public enum SvEvidenceChangeKind { Added, Removed, Changed, Unchanged } +public enum SvEvidenceChangeSeverity { Info, Warning, Error } + +public sealed record SvSubscriberEvidenceComparison +{ + public const string CurrentSchemaVersion = "arsvin.sv-subscriber-evidence-comparison/v1"; + public string SchemaVersion { get; init; } = CurrentSchemaVersion; + public DateTimeOffset GeneratedAt { get; init; } + public SvEvidenceReportReference Baseline { get; init; } = new(); + public SvEvidenceReportReference Candidate { get; init; } = new(); + public SvEvidenceComparisonSummary Summary { get; init; } = new(); + public IReadOnlyList ReportChanges { get; init; } = Array.Empty(); + public IReadOnlyList Streams { get; init; } = Array.Empty(); + + public void Validate() + { + if (SchemaVersion != CurrentSchemaVersion) throw new InvalidOperationException($"Unsupported SV comparison schema '{SchemaVersion}'."); + if (GeneratedAt == default) throw new InvalidOperationException("SV comparison requires a generation timestamp."); + if (string.IsNullOrWhiteSpace(Baseline.SchemaVersion) || string.IsNullOrWhiteSpace(Candidate.SchemaVersion)) throw new InvalidOperationException("SV comparison requires baseline and candidate schema metadata."); + if (Streams.Any(stream => string.IsNullOrWhiteSpace(stream.ComparisonKey))) throw new InvalidOperationException("Every stream comparison requires a stable comparison key."); + if (Streams.Select(stream => stream.ComparisonKey).Distinct(StringComparer.Ordinal).Count() != Streams.Count) throw new InvalidOperationException("SV comparison keys must be unique."); + var classified = Summary.AddedStreamCount + Summary.RemovedStreamCount + Summary.ChangedStreamCount + Summary.UnchangedStreamCount; + if (classified != Streams.Count) throw new InvalidOperationException("SV comparison summary does not match the stream collection."); + } +} + +public sealed record SvEvidenceReportReference { public string SchemaVersion { get; init; } = string.Empty; public DateTimeOffset GeneratedAt { get; init; } public string Product { get; init; } = string.Empty; public string Version { get; init; } = string.Empty; public string Commit { get; init; } = string.Empty; public string CaptureSource { get; init; } = string.Empty; public string Health { get; init; } = string.Empty; public int StreamCount { get; init; } } +public sealed record SvEvidenceComparisonSummary { public int BaselineStreamCount { get; init; } public int CandidateStreamCount { get; init; } public int AddedStreamCount { get; init; } public int RemovedStreamCount { get; init; } public int ChangedStreamCount { get; init; } public int UnchangedStreamCount { get; init; } public int InfoChangeCount { get; init; } public int WarningChangeCount { get; init; } public int ErrorChangeCount { get; init; } public bool HasRegressions => WarningChangeCount > 0 || ErrorChangeCount > 0; } +public sealed record SvSubscriberStreamComparison { public string ComparisonKey { get; init; } = string.Empty; public string LogicalStreamKey { get; init; } = string.Empty; public SvEvidenceChangeKind Kind { get; init; } public SvEvidenceChangeSeverity Severity { get; init; } public string BaselineStreamKey { get; init; } = string.Empty; public string CandidateStreamKey { get; init; } = string.Empty; public SvSubscriberStreamIdentityEvidence Identity { get; init; } = new(); public IReadOnlyList Changes { get; init; } = Array.Empty(); } +public sealed record SvEvidenceFieldChange { public string Category { get; init; } = string.Empty; public string Field { get; init; } = string.Empty; public SvEvidenceChangeSeverity Severity { get; init; } public string Baseline { get; init; } = string.Empty; public string Candidate { get; init; } = string.Empty; public string Message { get; init; } = string.Empty; } + +public sealed class SvSubscriberEvidenceComparator +{ + private const double RateTolerancePercent = 1.0; + + public SvSubscriberEvidenceComparison Compare(SvSubscriberEvidenceReport baseline, SvSubscriberEvidenceReport candidate, DateTimeOffset generatedAt) + { + ArgumentNullException.ThrowIfNull(baseline); ArgumentNullException.ThrowIfNull(candidate); baseline.Validate(); candidate.Validate(); + if (generatedAt == default) throw new ArgumentException("Comparison requires a generation timestamp.", nameof(generatedAt)); + var reportChanges = new List(); + Text(reportChanges, "Software", "Version", baseline.Software.Version, candidate.Software.Version, SvEvidenceChangeSeverity.Info, "Software version changed."); + Text(reportChanges, "Software", "Commit", baseline.Software.Commit, candidate.Software.Commit, SvEvidenceChangeSeverity.Info, "Build commit changed."); + Health(reportChanges, "Report", baseline.Summary.Health, candidate.Summary.Health); + var streams = CompareStreams(baseline.Streams, candidate.Streams); + var all = reportChanges.Concat(streams.SelectMany(stream => stream.Changes)).ToArray(); + var result = new SvSubscriberEvidenceComparison + { + GeneratedAt = generatedAt, + Baseline = Reference(baseline), Candidate = Reference(candidate), ReportChanges = reportChanges, Streams = streams, + Summary = new SvEvidenceComparisonSummary + { + BaselineStreamCount = baseline.Streams.Count, CandidateStreamCount = candidate.Streams.Count, + AddedStreamCount = streams.Count(x => x.Kind == SvEvidenceChangeKind.Added), RemovedStreamCount = streams.Count(x => x.Kind == SvEvidenceChangeKind.Removed), + ChangedStreamCount = streams.Count(x => x.Kind == SvEvidenceChangeKind.Changed), UnchangedStreamCount = streams.Count(x => x.Kind == SvEvidenceChangeKind.Unchanged), + InfoChangeCount = all.Count(x => x.Severity == SvEvidenceChangeSeverity.Info), WarningChangeCount = all.Count(x => x.Severity == SvEvidenceChangeSeverity.Warning), ErrorChangeCount = all.Count(x => x.Severity == SvEvidenceChangeSeverity.Error) + } + }; + result.Validate(); return result; + } + + private static IReadOnlyList CompareStreams(IReadOnlyList baseline, IReadOnlyList candidate) + { + var output = new List(); var used = new HashSet(StringComparer.Ordinal); + foreach (var source in baseline) + { + var target = candidate.FirstOrDefault(item => item.Key == source.Key) ?? candidate.FirstOrDefault(item => !used.Contains(item.Key) && LogicalKey(item) == LogicalKey(source)); + if (target is null) { output.Add(Removed(source)); continue; } + used.Add(target.Key); output.Add(Pair(source, target)); + } + foreach (var target in candidate.Where(item => !used.Contains(item.Key))) output.Add(Added(target)); + return output.OrderBy(item => item.Identity.AppId).ThenBy(item => item.Identity.SvId, StringComparer.Ordinal).ThenBy(item => item.Kind).ToArray(); + } + + private static SvSubscriberStreamComparison Pair(SvSubscriberStreamEvidence baseline, SvSubscriberStreamEvidence candidate) + { + var changes = new List(); + Health(changes, "Stream", baseline.Health, candidate.Health); + Text(changes, "Identity", "Source MAC", baseline.Identity.SourceMac, candidate.Identity.SourceMac, SvEvidenceChangeSeverity.Info, "Publisher source MAC changed while logical identity remained stable."); + Issue(changes, "Sequence gaps", baseline.Runtime.SequenceGapCount, candidate.Runtime.SequenceGapCount, SvEvidenceChangeSeverity.Warning); + Issue(changes, "Duplicates", baseline.Runtime.DuplicateCount, candidate.Runtime.DuplicateCount, SvEvidenceChangeSeverity.Warning); + Issue(changes, "Out-of-order", baseline.Runtime.OutOfOrderCount, candidate.Runtime.OutOfOrderCount, SvEvidenceChangeSeverity.Error); + Issue(changes, "Payload issues", baseline.Runtime.PayloadIssueCount, candidate.Runtime.PayloadIssueCount, SvEvidenceChangeSeverity.Error); + Rate(changes, "Observed samples/s", baseline.Observation.Facts.ObservedSamplesPerSecond, candidate.Observation.Facts.ObservedSamplesPerSecond); + return new SvSubscriberStreamComparison + { + ComparisonKey = $"PAIR|{baseline.Key}|{candidate.Key}", LogicalStreamKey = LogicalKey(candidate), + Kind = changes.Count == 0 ? SvEvidenceChangeKind.Unchanged : SvEvidenceChangeKind.Changed, + Severity = changes.Select(change => change.Severity).DefaultIfEmpty(SvEvidenceChangeSeverity.Info).Max(), + BaselineStreamKey = baseline.Key, CandidateStreamKey = candidate.Key, Identity = candidate.Identity, Changes = changes + }; + } + + private static SvSubscriberStreamComparison Added(SvSubscriberStreamEvidence stream) => new() { ComparisonKey = $"ADDED|{stream.Key}", LogicalStreamKey = LogicalKey(stream), Kind = SvEvidenceChangeKind.Added, Severity = SvEvidenceChangeSeverity.Info, CandidateStreamKey = stream.Key, Identity = stream.Identity, Changes = [Change("Stream", "Presence", SvEvidenceChangeSeverity.Info, "absent", "present", "Logical stream was added.")] }; + private static SvSubscriberStreamComparison Removed(SvSubscriberStreamEvidence stream) => new() { ComparisonKey = $"REMOVED|{stream.Key}", LogicalStreamKey = LogicalKey(stream), Kind = SvEvidenceChangeKind.Removed, Severity = SvEvidenceChangeSeverity.Error, BaselineStreamKey = stream.Key, Identity = stream.Identity, Changes = [Change("Stream", "Presence", SvEvidenceChangeSeverity.Error, "present", "absent", "Logical stream is missing from the candidate evidence.")] }; + private static string LogicalKey(SvSubscriberStreamEvidence stream) => $"{stream.Identity.AppId:X4}|{stream.Identity.DestinationMac}|{stream.Identity.VlanId}|{stream.Identity.SvId}|{stream.Identity.DataSetReference}"; + private static SvEvidenceReportReference Reference(SvSubscriberEvidenceReport report) => new() { SchemaVersion = report.SchemaVersion, GeneratedAt = report.GeneratedAt, Product = report.Software.Product, Version = report.Software.Version, Commit = report.Software.Commit, CaptureSource = report.Capture.Source, Health = report.Summary.Health, StreamCount = report.Streams.Count }; + + private static void Health(ICollection changes, string category, string baseline, string candidate) + { + if (string.Equals(baseline, candidate, StringComparison.OrdinalIgnoreCase)) return; + var rank = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["IDLE"] = 0, ["GOOD"] = 1, ["WARN"] = 2, ["BAD"] = 3, ["ERROR"] = 4 }; + var severity = rank.GetValueOrDefault(candidate) > rank.GetValueOrDefault(baseline) ? SvEvidenceChangeSeverity.Error : SvEvidenceChangeSeverity.Info; + changes.Add(Change(category, "Health", severity, baseline, candidate, severity == SvEvidenceChangeSeverity.Error ? "Health regressed." : "Health changed.")); + } + private static void Text(ICollection changes, string category, string field, string baseline, string candidate, SvEvidenceChangeSeverity severity, string message) { if (!string.Equals(baseline ?? string.Empty, candidate ?? string.Empty, StringComparison.Ordinal)) changes.Add(Change(category, field, severity, baseline, candidate, message)); } + private static void Issue(ICollection changes, string field, int baseline, int candidate, SvEvidenceChangeSeverity severity) { if (baseline != candidate) changes.Add(Change("Runtime", field, candidate > baseline ? severity : SvEvidenceChangeSeverity.Info, baseline.ToString(CultureInfo.InvariantCulture), candidate.ToString(CultureInfo.InvariantCulture), candidate > baseline ? $"{field} increased." : $"{field} decreased.")); } + private static void Rate(ICollection changes, string field, double? baseline, double? candidate) { if (!baseline.HasValue || !candidate.HasValue) return; var tolerance = Math.Abs(baseline.Value) * RateTolerancePercent / 100.0; if (Math.Abs(candidate.Value - baseline.Value) > tolerance) changes.Add(Change("Rate", field, SvEvidenceChangeSeverity.Warning, baseline.Value.ToString("0.###", CultureInfo.InvariantCulture), candidate.Value.ToString("0.###", CultureInfo.InvariantCulture), "Observed rate moved outside the comparison tolerance.")); } + private static SvEvidenceFieldChange Change(string category, string field, SvEvidenceChangeSeverity severity, string? baseline, string? candidate, string message) => new() { Category = category, Field = field, Severity = severity, Baseline = baseline ?? string.Empty, Candidate = candidate ?? string.Empty, Message = message }; +} + +public static class SvSubscriberEvidenceComparisonSerializer +{ + private static readonly JsonSerializerOptions Options = CreateOptions(); + public static string ToJson(SvSubscriberEvidenceComparison comparison) { ArgumentNullException.ThrowIfNull(comparison); comparison.Validate(); return JsonSerializer.Serialize(comparison, Options); } + public static SvSubscriberEvidenceComparison FromJson(string json) { if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("SV comparison JSON cannot be empty.", nameof(json)); var value = JsonSerializer.Deserialize(json, Options) ?? throw new InvalidDataException("SV comparison JSON did not contain a document."); value.Validate(); return value; } + public static string ToMarkdown(SvSubscriberEvidenceComparison comparison) + { + ArgumentNullException.ThrowIfNull(comparison); comparison.Validate(); var b = new StringBuilder(); + b.AppendLine("# ARSVIN Subscriber Evidence Comparison").AppendLine(); + b.AppendLine(comparison.Summary.HasRegressions ? "## REVIEW REQUIRED" : "## NO REGRESSION DETECTED").AppendLine(); + b.Append("- Baseline commit: ").AppendLine(comparison.Baseline.Commit).Append("- Candidate commit: ").AppendLine(comparison.Candidate.Commit).AppendLine(); + foreach (var stream in comparison.Streams) + { + b.Append("## ").Append(stream.Kind).Append(" — ").AppendLine(stream.Identity.SvId).AppendLine(); + foreach (var change in stream.Changes) b.Append("- **").Append(change.Field).Append("** · ").Append(change.Severity).Append(" · ").Append(change.Baseline).Append(" → ").Append(change.Candidate).Append(" — ").AppendLine(change.Message); + b.AppendLine(); + } + return b.ToString(); + } + private static JsonSerializerOptions CreateOptions() { var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true }; options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); return options; } +} From 49220cf63597f235dd3752e32190a13e77c42f4e Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:36:19 +0700 Subject: [PATCH 33/39] Update SCL publisher tests for supported multi-ASDU profiles --- .../Scl/SclPublisherProfileTests.cs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Scl/SclPublisherProfileTests.cs b/tests/AR.Iec61850.Tests/Scl/SclPublisherProfileTests.cs index feeeb9f..e215299 100644 --- a/tests/AR.Iec61850.Tests/Scl/SclPublisherProfileTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/SclPublisherProfileTests.cs @@ -14,7 +14,7 @@ public void SampledValues_Profile_FromScl_Builds_Parseable_Frame() var profile = SampledValuesPublisherProfile.FromScl(document, "MU01LD0/LLN0$SV$MSVCB01"); var source = MacAddress.Parse("02:00:00:00:10:01"); var referenceTime = new Iec61850UtcTime(new DateTimeOffset(2026, 6, 12, 11, 0, 0, TimeSpan.Zero), Quality: 0); - var payload = Convert.FromHexString("0000006400000001000000C800000003"); + var payload = profile.BuildDefaultPayload(referenceTime); var bytes = profile.BuildEthernetFrame(source, sampleCount: 44, payload, referenceTime); @@ -83,15 +83,25 @@ public void Goose_Profile_Rejects_Value_Count_Mismatch() } [Fact] - public void SampledValues_Profile_Rejects_MultiAsdu_Stream_Until_Supported() + public void SampledValues_Profile_Supports_MultiAsdu_Stream_With_Sequential_Counters() { var xml = File.ReadAllText(SclParserTests.MinimalStationPath()) .Replace("nofASDU=\"1\"", "nofASDU=\"2\"", StringComparison.Ordinal); var document = new AR.Iec61850.Scl.SclParser().Parse(xml, "multi-asdu.scd"); + var profile = SampledValuesPublisherProfile.FromScl(document); + var source = MacAddress.Parse("02:00:00:00:10:01"); + var payload = profile.BuildDefaultPayload(); - void Action() => _ = SampledValuesPublisherProfile.FromScl(document); - var error = Assert.Throws(Action); + var bytes = profile.BuildEthernetFrame( + source, + sampleCount: 3999, + samplePayloads: [payload, payload], + sampleCounterWrap: 4000); - Assert.Contains("nofASDU=2", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(SampledValuesFrameParser.TryParseEthernetFrame(bytes, out var parsed)); + Assert.Equal(2, parsed.Pdu.Asdus.Count); + Assert.Equal((ushort)3999, parsed.Pdu.Asdus[0].SampleCount); + Assert.Equal((ushort)0, parsed.Pdu.Asdus[1].SampleCount); + Assert.All(parsed.Pdu.Asdus, asdu => Assert.Equal(payload, asdu.SamplePayload)); } } From 861f853e997b32f84b10a110d9ad11fcdaaa1984 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:39:20 +0700 Subject: [PATCH 34/39] Support multi-ASDU publisher session batches in core --- .../SampledValuesPublisherSession.cs | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPublisherSession.cs b/src/AR.Iec61850/SampledValues/SampledValuesPublisherSession.cs index 3d85ae5..0224a41 100644 --- a/src/AR.Iec61850/SampledValues/SampledValuesPublisherSession.cs +++ b/src/AR.Iec61850/SampledValues/SampledValuesPublisherSession.cs @@ -38,19 +38,35 @@ public async ValueTask PublishNextAsync( { ArgumentNullException.ThrowIfNull(samplePayload); - var sampleCount = NextSampleCount; - NextSampleCount = IncrementSampleCount(sampleCount, SampleCounterWrap); + if (_profile.AsduPerFrame != 1) + throw new InvalidOperationException($"SV {_profile.Stream.ControlBlockReference} declares nofASDU={_profile.AsduPerFrame}. Use PublishNextBatchAsync."); - var frame = _profile.BuildEthernetFrame(_source, sampleCount, samplePayload, referenceTime, sampleSynchronization); - await _transport.SendAsync(frame, cancellationToken).ConfigureAwait(false); - return frame; + return await PublishNextBatchAsync( + [samplePayload], + referenceTime, + sampleSynchronization, + cancellationToken).ConfigureAwait(false); } - private static ushort IncrementSampleCount(ushort current, ushort? wrap) + public async ValueTask PublishNextBatchAsync( + IReadOnlyList samplePayloads, + Iec61850UtcTime? referenceTime = null, + byte sampleSynchronization = 2, + CancellationToken cancellationToken = default) { - if (wrap is > 1) - return current + 1 >= wrap.Value ? (ushort)0 : (ushort)(current + 1); + ArgumentNullException.ThrowIfNull(samplePayloads); + + var sampleCount = NextSampleCount; + NextSampleCount = SampleCounterPolicy.Increment(sampleCount, SampleCounterWrap, samplePayloads.Count); - return current == ushort.MaxValue ? (ushort)0 : (ushort)(current + 1); + var frame = _profile.BuildEthernetFrame( + _source, + sampleCount, + samplePayloads, + referenceTime, + sampleSynchronization, + SampleCounterWrap); + await _transport.SendAsync(frame, cancellationToken).ConfigureAwait(false); + return frame; } } From cac08b0d33cf81b8c347b1fe4c24c4efe6bdf313 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:39:54 +0700 Subject: [PATCH 35/39] Move Sampled Values transmitter timing health into core namespace --- .../SampledValues/TxTimingHealth.cs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/AR.Iec61850/SampledValues/TxTimingHealth.cs diff --git a/src/AR.Iec61850/SampledValues/TxTimingHealth.cs b/src/AR.Iec61850/SampledValues/TxTimingHealth.cs new file mode 100644 index 0000000..90879bd --- /dev/null +++ b/src/AR.Iec61850/SampledValues/TxTimingHealth.cs @@ -0,0 +1,181 @@ +using System.Diagnostics; + +namespace AR.Iec61850.SampledValues; + +public enum TxTimingHealthStatus +{ + Idle, + Good, + Warning, + Bad +} + +public sealed record TxTimingHealthSnapshot( + TxTimingHealthStatus Status, + long FrameCount, + double TargetFramesPerSecond, + double ActualFramesPerSecond, + double TargetIntervalMicroseconds, + double AverageAbsJitterMicroseconds, + double MaxAbsJitterMicroseconds, + long LateFrameCount, + long MissedScheduleCount, + double AverageSendDurationMicroseconds, + double MaxSendDurationMicroseconds, + double MaxLateByMicroseconds) +{ + public string StatusLabel => Status switch + { + TxTimingHealthStatus.Good => "GOOD", + TxTimingHealthStatus.Warning => "WARN", + TxTimingHealthStatus.Bad => "BAD", + _ => "IDLE" + }; +} + +/// +/// Measures publisher-side transmit timing only. This class does not capture or analyze network traffic; +/// it records the schedule, send start, and send completion timestamps from the local SV publisher loop. +/// +public sealed class TxTimingHealth +{ + private readonly long _timestampFrequency; + private readonly long _targetIntervalTicks; + private readonly long _lateThresholdTicks; + private long _frameCount; + private long _firstSendStartTicks; + private long _lastSendStartTicks; + private double _sumAbsJitterTicks; + private long _maxAbsJitterTicks; + private long _intervalCount; + private long _lateFrameCount; + private long _missedScheduleCount; + private long _maxLateByTicks; + private double _sumSendDurationTicks; + private long _maxSendDurationTicks; + + public TxTimingHealth(double targetFramesPerSecond) + : this(targetFramesPerSecond, Stopwatch.Frequency) + { + } + + public TxTimingHealth(double targetFramesPerSecond, long timestampFrequency) + { + if (targetFramesPerSecond <= 0 || double.IsNaN(targetFramesPerSecond) || double.IsInfinity(targetFramesPerSecond)) + throw new ArgumentOutOfRangeException(nameof(targetFramesPerSecond), "Target frame rate must be a positive finite value."); + if (timestampFrequency <= 0) + throw new ArgumentOutOfRangeException(nameof(timestampFrequency), "Timestamp frequency must be positive."); + + TargetFramesPerSecond = targetFramesPerSecond; + _timestampFrequency = timestampFrequency; + _targetIntervalTicks = Math.Max(1, (long)Math.Round(timestampFrequency / targetFramesPerSecond)); + _lateThresholdTicks = Math.Max(MicrosecondsToTicks(25), _targetIntervalTicks / 10); + } + + public double TargetFramesPerSecond { get; } + public double TargetIntervalMicroseconds => TicksToMicroseconds(_targetIntervalTicks); + public long FrameCount => _frameCount; + + public void Record(long scheduledTicks, long sendStartTicks, long sendEndTicks) + { + if (sendEndTicks < sendStartTicks) + sendEndTicks = sendStartTicks; + + if (_frameCount == 0) + { + _firstSendStartTicks = sendStartTicks; + } + else + { + var actualIntervalTicks = Math.Max(0, sendStartTicks - _lastSendStartTicks); + var jitterTicks = Math.Abs(actualIntervalTicks - _targetIntervalTicks); + _sumAbsJitterTicks += jitterTicks; + _maxAbsJitterTicks = Math.Max(_maxAbsJitterTicks, jitterTicks); + _intervalCount++; + } + + var lateByTicks = sendStartTicks - scheduledTicks; + if (lateByTicks > 0) + { + _maxLateByTicks = Math.Max(_maxLateByTicks, lateByTicks); + if (lateByTicks > _lateThresholdTicks) + _lateFrameCount++; + if (lateByTicks > _targetIntervalTicks) + _missedScheduleCount += Math.Max(1, lateByTicks / _targetIntervalTicks); + } + + var sendDurationTicks = Math.Max(0, sendEndTicks - sendStartTicks); + _sumSendDurationTicks += sendDurationTicks; + _maxSendDurationTicks = Math.Max(_maxSendDurationTicks, sendDurationTicks); + _lastSendStartTicks = sendStartTicks; + _frameCount++; + } + + public TxTimingHealthSnapshot Snapshot(long nowTicks) + { + if (_frameCount == 0) + { + return new TxTimingHealthSnapshot( + TxTimingHealthStatus.Idle, + 0, + TargetFramesPerSecond, + 0, + TargetIntervalMicroseconds, + 0, + 0, + 0, + 0, + 0, + 0, + 0); + } + + var elapsedTicks = Math.Max(1, nowTicks - _firstSendStartTicks); + var elapsedSeconds = elapsedTicks / (double)_timestampFrequency; + var actualFramesPerSecond = _frameCount / Math.Max(elapsedSeconds, 0.000001); + var averageAbsJitter = _intervalCount == 0 ? 0 : TicksToMicroseconds(_sumAbsJitterTicks / _intervalCount); + var maxAbsJitter = TicksToMicroseconds(_maxAbsJitterTicks); + var averageSendDuration = TicksToMicroseconds(_sumSendDurationTicks / _frameCount); + var maxSendDuration = TicksToMicroseconds(_maxSendDurationTicks); + var maxLateBy = TicksToMicroseconds(_maxLateByTicks); + var status = ResolveStatus(actualFramesPerSecond, maxAbsJitter, _lateFrameCount, _missedScheduleCount); + + return new TxTimingHealthSnapshot( + status, + _frameCount, + TargetFramesPerSecond, + actualFramesPerSecond, + TargetIntervalMicroseconds, + averageAbsJitter, + maxAbsJitter, + _lateFrameCount, + _missedScheduleCount, + averageSendDuration, + maxSendDuration, + maxLateBy); + } + + private TxTimingHealthStatus ResolveStatus(double actualFramesPerSecond, double maxAbsJitterMicroseconds, long lateFrameCount, long missedScheduleCount) + { + if (_frameCount < 8) + return TxTimingHealthStatus.Good; + + var actualRatio = actualFramesPerSecond / TargetFramesPerSecond; + var targetIntervalUs = TargetIntervalMicroseconds; + var lateRatio = lateFrameCount / (double)Math.Max(1, _frameCount); + + if (missedScheduleCount > 0 || actualRatio < 0.98 || maxAbsJitterMicroseconds > targetIntervalUs) + return TxTimingHealthStatus.Bad; + + if (lateRatio > 0.01 || actualRatio < 0.995 || maxAbsJitterMicroseconds > targetIntervalUs * 0.25) + return TxTimingHealthStatus.Warning; + + return TxTimingHealthStatus.Good; + } + + private long MicrosecondsToTicks(double microseconds) + => Math.Max(1, (long)Math.Round(microseconds * _timestampFrequency / 1_000_000.0)); + + private double TicksToMicroseconds(double ticks) + => ticks * 1_000_000.0 / _timestampFrequency; +} From fd03fe0e0c643f0722d3973c5789054252aa4898 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:42:53 +0700 Subject: [PATCH 36/39] Keep transmitter timing health in the Sampled Values core namespace --- .../TimeSync/Health/TxTimingHealth.cs | 180 ------------------ 1 file changed, 180 deletions(-) delete mode 100644 src/AR.Iec61850/TimeSync/Health/TxTimingHealth.cs diff --git a/src/AR.Iec61850/TimeSync/Health/TxTimingHealth.cs b/src/AR.Iec61850/TimeSync/Health/TxTimingHealth.cs deleted file mode 100644 index 19298e2..0000000 --- a/src/AR.Iec61850/TimeSync/Health/TxTimingHealth.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System.Diagnostics; - -namespace AR.Iec61850.TimeSync.Health; - -public enum TxTimingHealthStatus -{ - Idle, - Good, - Warning, - Bad -} - -/// -/// Immutable transmitter timing evidence. Values describe the local process that attempted -/// to send frames; they do not prove deterministic wire timing or remote reception. -/// -public sealed record TxTimingHealthSnapshot -{ - public TxTimingHealthStatus Status { get; init; } = TxTimingHealthStatus.Idle; - public double TargetFramesPerSecond { get; init; } - public double ActualFramesPerSecond { get; init; } - public long FrameCount { get; init; } - public double AverageAbsJitterMicroseconds { get; init; } - public double MaxAbsJitterMicroseconds { get; init; } - public long LateFrameCount { get; init; } - public long MissedScheduleCount { get; init; } - public double AverageSendDurationMicroseconds { get; init; } - public double MaxSendDurationMicroseconds { get; init; } - public double MaxLateByMicroseconds { get; init; } - public string Detail { get; init; } = string.Empty; -} - -/// -/// Thread-safe local scheduling and send-duration monitor for a best-effort publisher. -/// The monitor uses Stopwatch ticks supplied by the caller and keeps cumulative evidence. -/// -public sealed class TxTimingHealth -{ - private readonly object _gate = new(); - private readonly double _targetFramesPerSecond; - private readonly double _intervalMicroseconds; - private long? _firstScheduledTicks; - private long _frameCount; - private double _sumAbsJitterMicroseconds; - private double _maxAbsJitterMicroseconds; - private long _lateFrameCount; - private long _missedScheduleCount; - private double _sumSendDurationMicroseconds; - private double _maxSendDurationMicroseconds; - private double _maxLateByMicroseconds; - - public TxTimingHealth(double targetFramesPerSecond) - { - if (!double.IsFinite(targetFramesPerSecond) || targetFramesPerSecond <= 0) - throw new ArgumentOutOfRangeException(nameof(targetFramesPerSecond), "Target frame rate must be positive and finite."); - - _targetFramesPerSecond = targetFramesPerSecond; - _intervalMicroseconds = 1_000_000.0 / targetFramesPerSecond; - } - - public void Record(long scheduledTicks, long sendStartTicks, long sendEndTicks) - { - if (sendEndTicks < sendStartTicks) - throw new ArgumentOutOfRangeException(nameof(sendEndTicks), "Send end must not precede send start."); - - var jitterMicroseconds = ToMicroseconds(sendStartTicks - scheduledTicks); - var absJitterMicroseconds = Math.Abs(jitterMicroseconds); - var sendDurationMicroseconds = ToMicroseconds(sendEndTicks - sendStartTicks); - var lateByMicroseconds = Math.Max(0, jitterMicroseconds); - - lock (_gate) - { - _firstScheduledTicks ??= scheduledTicks; - _frameCount++; - _sumAbsJitterMicroseconds += absJitterMicroseconds; - _maxAbsJitterMicroseconds = Math.Max(_maxAbsJitterMicroseconds, absJitterMicroseconds); - _sumSendDurationMicroseconds += sendDurationMicroseconds; - _maxSendDurationMicroseconds = Math.Max(_maxSendDurationMicroseconds, sendDurationMicroseconds); - _maxLateByMicroseconds = Math.Max(_maxLateByMicroseconds, lateByMicroseconds); - - if (lateByMicroseconds > _intervalMicroseconds * 0.10) - _lateFrameCount++; - - if (lateByMicroseconds >= _intervalMicroseconds) - { - var missed = (long)Math.Floor(lateByMicroseconds / _intervalMicroseconds); - _missedScheduleCount += Math.Max(1, missed); - } - } - } - - public TxTimingHealthSnapshot Snapshot(long nowTicks) - { - lock (_gate) - { - if (_frameCount == 0 || !_firstScheduledTicks.HasValue) - { - return new TxTimingHealthSnapshot - { - TargetFramesPerSecond = _targetFramesPerSecond, - Detail = "No transmission timing samples have been recorded." - }; - } - - var elapsedSeconds = Math.Max( - 1.0 / _targetFramesPerSecond, - (nowTicks - _firstScheduledTicks.Value) / (double)Stopwatch.Frequency); - var actualFramesPerSecond = _frameCount / elapsedSeconds; - var averageJitter = _sumAbsJitterMicroseconds / _frameCount; - var averageSend = _sumSendDurationMicroseconds / _frameCount; - var status = ResolveStatus( - elapsedSeconds, - actualFramesPerSecond, - averageJitter, - _maxAbsJitterMicroseconds, - _lateFrameCount, - _missedScheduleCount, - averageSend, - _maxSendDurationMicroseconds); - - return new TxTimingHealthSnapshot - { - Status = status, - TargetFramesPerSecond = _targetFramesPerSecond, - ActualFramesPerSecond = actualFramesPerSecond, - FrameCount = _frameCount, - AverageAbsJitterMicroseconds = averageJitter, - MaxAbsJitterMicroseconds = _maxAbsJitterMicroseconds, - LateFrameCount = _lateFrameCount, - MissedScheduleCount = _missedScheduleCount, - AverageSendDurationMicroseconds = averageSend, - MaxSendDurationMicroseconds = _maxSendDurationMicroseconds, - MaxLateByMicroseconds = _maxLateByMicroseconds, - Detail = BuildDetail(status, actualFramesPerSecond) - }; - } - } - - private TxTimingHealthStatus ResolveStatus( - double elapsedSeconds, - double actualFramesPerSecond, - double averageJitterMicroseconds, - double maxJitterMicroseconds, - long lateFrameCount, - long missedScheduleCount, - double averageSendMicroseconds, - double maxSendMicroseconds) - { - var rateRatio = actualFramesPerSecond / _targetFramesPerSecond; - var hasMatureRateWindow = elapsedSeconds >= Math.Max(0.25, 8.0 / _targetFramesPerSecond); - - if (missedScheduleCount > 0 || - maxJitterMicroseconds >= _intervalMicroseconds || - maxSendMicroseconds >= _intervalMicroseconds || - (hasMatureRateWindow && rateRatio < 0.80)) - return TxTimingHealthStatus.Bad; - - if (lateFrameCount > 0 || - averageJitterMicroseconds > _intervalMicroseconds * 0.10 || - maxJitterMicroseconds > _intervalMicroseconds * 0.25 || - averageSendMicroseconds > _intervalMicroseconds * 0.25 || - maxSendMicroseconds > _intervalMicroseconds * 0.50 || - (hasMatureRateWindow && rateRatio < 0.95)) - return TxTimingHealthStatus.Warning; - - return TxTimingHealthStatus.Good; - } - - private string BuildDetail(TxTimingHealthStatus status, double actualFramesPerSecond) - => status switch - { - TxTimingHealthStatus.Good => "Local publisher scheduling and send duration are within the engineering monitor thresholds.", - TxTimingHealthStatus.Warning => $"Local publisher timing requires review; actual rate is {actualFramesPerSecond:0.###}/{_targetFramesPerSecond:0.###} frame/s.", - TxTimingHealthStatus.Bad => $"Local publisher timing missed a schedule or exceeded one target frame interval; actual rate is {actualFramesPerSecond:0.###}/{_targetFramesPerSecond:0.###} frame/s.", - _ => "No transmission timing samples have been recorded." - }; - - private static double ToMicroseconds(long stopwatchTicks) - => stopwatchTicks * 1_000_000.0 / Stopwatch.Frequency; -} From bb399645f2097c42bd22dcc7112b5157b8c34d6c Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:43:30 +0700 Subject: [PATCH 37/39] Use the Sampled Values timing-health namespace in core tests --- tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs index 3b955d6..27b4a0c 100644 --- a/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs +++ b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using AR.Iec61850.SampledValues; using AR.Iec61850.SampledValues.Measurements; -using AR.Iec61850.TimeSync.Health; namespace AR.Iec61850.Tests.SampledValues; From 143e6ca69986cd553405eec883a9928cdfda9367 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:46:40 +0700 Subject: [PATCH 38/39] Test timing health after the warmup window --- .../SampledValues/SvPublisherSupportTests.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs index 27b4a0c..ae20170 100644 --- a/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs +++ b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs @@ -99,15 +99,25 @@ public void TxTimingHealthReportsGoodForOnScheduleFrames() } [Fact] - public void TxTimingHealthReportsBadWhenOneIntervalIsMissed() + public void TxTimingHealthReportsBadWhenOneIntervalIsMissedAfterWarmup() { const double rate = 1000; var monitor = new TxTimingHealth(rate); var intervalTicks = (long)Math.Round(Stopwatch.Frequency / rate); var start = Stopwatch.GetTimestamp(); - monitor.Record(start, start + intervalTicks + ToTicks(100), start + intervalTicks + ToTicks(150)); - var snapshot = monitor.Snapshot(start + (2 * intervalTicks)); + for (var index = 0; index < 8; index++) + { + var scheduled = start + (index * intervalTicks); + monitor.Record(scheduled, scheduled, scheduled + ToTicks(25)); + } + + var missedSchedule = start + (8 * intervalTicks); + monitor.Record( + missedSchedule, + missedSchedule + intervalTicks + ToTicks(100), + missedSchedule + intervalTicks + ToTicks(150)); + var snapshot = monitor.Snapshot(start + (10 * intervalTicks)); Assert.Equal(TxTimingHealthStatus.Bad, snapshot.Status); Assert.True(snapshot.MissedScheduleCount >= 1); From fcc05de1f4b11560195e2589aa032e1d4c4b95f7 Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 24 Jul 2026 07:52:36 +0700 Subject: [PATCH 39/39] Restore MAC value semantics and Publisher evidence compatibility --- src/AR.Iec61850/Ethernet/MacAddress.cs | 27 +++++++++++++++++++ .../SampledValuesPublisherEvidenceReport.cs | 6 ++++- .../Ethernet/MacAddressValueSemanticsTests.cs | 27 +++++++++++++++++++ .../SampledValues/SvPublisherSupportTests.cs | 3 ++- 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 tests/AR.Iec61850.Tests/Ethernet/MacAddressValueSemanticsTests.cs diff --git a/src/AR.Iec61850/Ethernet/MacAddress.cs b/src/AR.Iec61850/Ethernet/MacAddress.cs index cc86b1f..f125e88 100644 --- a/src/AR.Iec61850/Ethernet/MacAddress.cs +++ b/src/AR.Iec61850/Ethernet/MacAddress.cs @@ -51,6 +51,30 @@ public static bool TryParse(string? text, out MacAddress address) public byte[] ToArray() => _bytes?.ToArray() ?? new byte[6]; + /// + /// Compares the six address octets by value. The backing array is an implementation detail + /// and must not make two equivalent MAC addresses compare as different instances. + /// + public bool Equals(MacAddress other) + { + for (var i = 0; i < 6; i++) + { + if (ByteAt(i) != other.ByteAt(i)) + return false; + } + + return true; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + for (var i = 0; i < 6; i++) + hash.Add(ByteAt(i)); + + return hash.ToHashCode(); + } + public void CopyTo(Span destination) { if (destination.Length < 6) @@ -64,4 +88,7 @@ public override string ToString() var bytes = _bytes ?? new byte[6]; return string.Join(":", bytes.Select(b => b.ToString("X2", CultureInfo.InvariantCulture))); } + + private byte ByteAt(int index) + => _bytes is null ? (byte)0 : _bytes[index]; } diff --git a/src/AR.Iec61850/SampledValues/SampledValuesPublisherEvidenceReport.cs b/src/AR.Iec61850/SampledValues/SampledValuesPublisherEvidenceReport.cs index b0d7b91..1587cd4 100644 --- a/src/AR.Iec61850/SampledValues/SampledValuesPublisherEvidenceReport.cs +++ b/src/AR.Iec61850/SampledValues/SampledValuesPublisherEvidenceReport.cs @@ -60,7 +60,11 @@ public static string ToMarkdown(SampledValuesPublisherEvidenceReport report) ArgumentNullException.ThrowIfNull(report); var builder = new StringBuilder(); - builder.AppendLine("# Sampled Values Publisher Evidence"); + builder.Append("# ") + .Append(EmptyAsDash(report.ToolName)) + .AppendLine(" Sampled Values Publisher Evidence Report"); + builder.AppendLine(); + builder.AppendLine("> TX-side publisher evidence only. This report records configured intent and local transmitter observations; it does not prove remote subscription, calibrated accuracy, or formal IEC 61850 conformance."); builder.AppendLine(); AppendField(builder, "Tool", $"{report.ToolName} {report.ToolVersion}".Trim()); AppendField(builder, "Created", report.CreatedAt.ToString("O", CultureInfo.InvariantCulture)); diff --git a/tests/AR.Iec61850.Tests/Ethernet/MacAddressValueSemanticsTests.cs b/tests/AR.Iec61850.Tests/Ethernet/MacAddressValueSemanticsTests.cs new file mode 100644 index 0000000..3578b4c --- /dev/null +++ b/tests/AR.Iec61850.Tests/Ethernet/MacAddressValueSemanticsTests.cs @@ -0,0 +1,27 @@ +using AR.Iec61850.Ethernet; + +namespace AR.Iec61850.Tests.Ethernet; + +public sealed class MacAddressValueSemanticsTests +{ + [Fact] + public void EquivalentParsedAddressesCompareByOctetValue() + { + var colon = MacAddress.Parse("01:0C:CD:04:00:02"); + var hyphen = MacAddress.Parse("01-0c-cd-04-00-02"); + + Assert.Equal(colon, hyphen); + Assert.True(colon == hyphen); + Assert.Equal(colon.GetHashCode(), hyphen.GetHashCode()); + } + + [Fact] + public void DifferentAddressesDoNotCompareEqual() + { + var first = MacAddress.Parse("01:0C:CD:04:00:01"); + var second = MacAddress.Parse("01:0C:CD:04:00:02"); + + Assert.NotEqual(first, second); + Assert.True(first != second); + } +} diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs index ae20170..93f23d2 100644 --- a/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs +++ b/tests/AR.Iec61850.Tests/SampledValues/SvPublisherSupportTests.cs @@ -70,7 +70,8 @@ public void PublisherEvidenceWriterRecordsStreamAndSafetyBoundary() var markdown = SampledValuesPublisherEvidenceReportWriter.ToMarkdown(report); - Assert.Contains("# Sampled Values Publisher Evidence", markdown, StringComparison.Ordinal); + Assert.Contains("# Test Publisher Sampled Values Publisher Evidence Report", markdown, StringComparison.Ordinal); + Assert.Contains("TX-side publisher evidence only", markdown, StringComparison.Ordinal); Assert.Contains("MU01", markdown, StringComparison.Ordinal); Assert.Contains("TX-side evidence only", markdown, StringComparison.Ordinal); Assert.Contains("4.032 Mbit/s", markdown, StringComparison.Ordinal);