From 4a74053e005f3024d5ad8ee08ebcbf1fb787b628 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:19:07 +0700 Subject: [PATCH 01/15] Add provenance-aware SV engineering scale resolver --- .../Measurements/SvEngineeringScaling.cs | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs new file mode 100644 index 0000000..3e14c1a --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs @@ -0,0 +1,172 @@ +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, + 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 installed-base 9-2LE-style protection streams. +/// The resolver requires structural evidence plus sampling or SCL evidence. Otherwise it returns raw counts. +/// +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 channel could not be classified as current or voltage."); + + var fixedLayout = evidence.IsFixedFourCurrentFourVoltageLayout && + evidence.AnalogChannelCount == 8 && + evidence.PayloadBytesPerAsdu == 64; + if (!fixedLayout) + return SvEngineeringScale.RawOnly("The payload is not proven to be the fixed 4I+4U value-quality layout."); + + var samplingEvidence = HasProtectionRateEvidence(evidence); + if (!evidence.IsSclBound && !samplingEvidence) + return SvEngineeringScale.RawOnly("The fixed layout is visible, but sampling or SCL evidence is insufficient for engineering scaling."); + + var source = evidence.IsSclBound + ? SvEngineeringScaleSource.SclBackedLegacy92LeStyle + : SvEngineeringScaleSource.Legacy92LeStyleStructuralInference; + var confidence = evidence.IsSclBound + ? SvEngineeringScaleConfidence.SclBacked + : SvEngineeringScaleConfidence.Inferred; + var reason = evidence.IsSclBound + ? "SCL binding and fixed 4I+4U structural evidence support installed-base 9-2LE-style scaling." + : "Fixed 4I+4U structure and protection-rate evidence support provisional 9-2LE-style scaling."; + + return domain switch + { + SvMeasurementDomain.Current => new SvEngineeringScale + { + Multiplier = CurrentAmperesPerCount, + Unit = "A", + Source = source, + Confidence = confidence, + Reason = reason + }, + SvMeasurementDomain.Voltage => new SvEngineeringScale + { + Multiplier = VoltageVoltsPerCount, + Unit = "V", + Source = source, + Confidence = confidence, + 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) + { + // smpMod=0 means samples per period. 80 samples/cycle is the installed-base protection variant. + if (evidence.DeclaredSampleMode == 0 && evidence.DeclaredSampleRate == 80) + return true; + + // smpMod=1 means samples per second. Accept the 50 Hz and 60 Hz protection rates. + 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 a48f29bace26180e4b2d97fec2622819f1536109 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:19:38 +0700 Subject: [PATCH 02/15] Add frequency-neutral SV timebase resolver --- .../Measurements/SvTimebaseResolver.cs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs new file mode 100644 index 0000000..824acf0 --- /dev/null +++ b/src/ARSVIN.Engine/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 2a6107e84b51a3ef5668ddd5a38e5488886f459e Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:20:04 +0700 Subject: [PATCH 03/15] Add robust Sampled Values counter transition tracker --- .../Measurements/SvSampleCounterTracker.cs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs new file mode 100644 index 0000000..0f84c7b --- /dev/null +++ b/src/ARSVIN.Engine/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 a906bd00175051970a11d05d97268d7fe0a64f7c Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:20:48 +0700 Subject: [PATCH 04/15] Add deterministic tests for SV scaling, timebase and smpCnt handling --- .../SampledValuesMeasurementsTests.cs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs diff --git a/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs b/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs new file mode 100644 index 0000000..c738072 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs @@ -0,0 +1,137 @@ +using AR.Iec61850.SampledValues.Measurements; + +namespace ARSVIN.Tests; + +public sealed class SampledValuesMeasurementsTests +{ + [Fact] + public void ScaleResolverConvertsFixedProtectionCurrentToAmperes() + { + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = "Ia", + Kind = "Current", + IsFixedFourCurrentFourVoltageLayout = true, + AnalogChannelCount = 8, + PayloadBytesPerAsdu = 64, + DeclaredSampleMode = 1, + DeclaredSampleRate = 4_000 + }); + + Assert.Equal(SvEngineeringScaleSource.Legacy92LeStyleStructuralInference, scale.Source); + Assert.Equal(SvEngineeringScaleConfidence.Inferred, scale.Confidence); + Assert.Equal("A", scale.Unit); + Assert.Equal(1.0, scale.Apply(1_000), 9); + } + + [Fact] + public void ScaleResolverConvertsFixedProtectionVoltageToVolts() + { + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = "TVTR1/VolSv.instMag.i", + Kind = "Voltage", + IsSclBound = true, + IsFixedFourCurrentFourVoltageLayout = true, + AnalogChannelCount = 8, + PayloadBytesPerAsdu = 64 + }); + + Assert.Equal(SvEngineeringScaleSource.SclBackedLegacy92LeStyle, scale.Source); + Assert.Equal(SvEngineeringScaleConfidence.SclBacked, scale.Confidence); + Assert.Equal("V", scale.Unit); + Assert.Equal(100.0, scale.Apply(10_000), 9); + } + + [Fact] + public void ScaleResolverKeepsUnknownLayoutAsRawCounts() + { + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = "Ia", + Kind = "Current", + AnalogChannelCount = 12, + PayloadBytesPerAsdu = 96, + ObservedSamplesPerSecond = 4_000 + }); + + Assert.Equal(SvEngineeringScaleSource.RawOnly, scale.Source); + Assert.Equal("count", scale.Unit); + Assert.Equal(1_000, scale.Apply(1_000)); + } + + [Theory] + [InlineData(4_000, 50)] + [InlineData(4_800, 60)] + public void TimebaseResolverDetectsLegacyProtectionFrequency(double samplesPerSecond, double expectedFrequency) + { + var resolution = SvTimebaseResolver.Resolve(new SvTimebaseEvidence + { + ObservedSamplesPerSecond = samplesPerSecond, + IsFixedLegacyProtectionLayout = true + }); + + Assert.True(resolution.IsResolved); + Assert.Equal(expectedFrequency, resolution.NominalFrequencyHz); + Assert.Equal(80, resolution.SamplesPerCycle); + Assert.Equal((ushort)samplesPerSecond, resolution.SampleCounterWrap); + } + + [Fact] + public void TimebaseResolverDoesNotAssumeFrequencyForUnknownProfile() + { + var resolution = SvTimebaseResolver.Resolve(new SvTimebaseEvidence + { + DeclaredSampleMode = 1, + DeclaredSampleRate = 4_000, + IsFixedLegacyProtectionLayout = false + }); + + Assert.Null(resolution.NominalFrequencyHz); + Assert.Null(resolution.SamplesPerCycle); + Assert.Equal((ushort)4_000, resolution.SampleCounterWrap); + } + + [Fact] + public void CounterTrackerAcceptsNormalProfileWrap() + { + var tracker = new SvSampleCounterTracker(); + tracker.Observe(3_998, 4_000); + Assert.Equal(SvSampleCounterTransitionKind.Continuous, tracker.Observe(3_999, 4_000).Kind); + + var transition = tracker.Observe(0, 4_000); + + Assert.Equal(SvSampleCounterTransitionKind.NormalWrap, transition.Kind); + Assert.False(transition.IsAnomaly); + } + + [Fact] + public void CounterTrackerDistinguishesGapFromOutOfOrder() + { + var gapTracker = new SvSampleCounterTracker(); + gapTracker.Observe(10, 4_000); + var gap = gapTracker.Observe(13, 4_000); + + var orderTracker = new SvSampleCounterTracker(); + orderTracker.Observe(10, 4_000); + orderTracker.Observe(11, 4_000); + var outOfOrder = orderTracker.Observe(9, 4_000); + + Assert.Equal(SvSampleCounterTransitionKind.Gap, gap.Kind); + Assert.Equal(2, gap.MissingSamples); + Assert.Equal(SvSampleCounterTransitionKind.OutOfOrder, outOfOrder.Kind); + } + + [Fact] + public void CounterTrackerHonorsTrustedRestartHint() + { + var tracker = new SvSampleCounterTracker(); + tracker.Observe(2_500, 4_000); + + var transition = tracker.Observe(0, 4_000, restartHint: true); + + Assert.Equal(SvSampleCounterTransitionKind.Restart, transition.Kind); + Assert.False(transition.IsAnomaly); + Assert.Equal((ushort)1, tracker.Expected); + } +} From 3bedbde7afdcb9d9069bd925ac6afdf7a36f4017 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:21:59 +0700 Subject: [PATCH 05/15] Expose raw and provenance-aware engineering SV values --- .../Models/DecodedValueRow.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs b/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs index 797bc89..4d4a3ec 100644 --- a/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs +++ b/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs @@ -1,3 +1,5 @@ +using AR.Iec61850.SampledValues.Measurements; + namespace ARSVIN.Subscriber.Models; public sealed class DecodedValueRow @@ -7,6 +9,22 @@ public sealed class DecodedValueRow public string Kind { get; init; } = string.Empty; public string Value { get; init; } = string.Empty; public string Raw { get; init; } = string.Empty; + + /// Decoded protocol number before any engineering scaling. public double? NumericValue { get; init; } -} + /// Primary engineering value when scaling evidence is sufficient. + public double? EngineeringValue { get; init; } + public string EngineeringUnit { get; init; } = string.Empty; + public SvEngineeringScaleSource ScalingSource { get; init; } = SvEngineeringScaleSource.RawOnly; + public SvEngineeringScaleConfidence ScalingConfidence { get; init; } = SvEngineeringScaleConfidence.Unknown; + public string ScalingReason { get; init; } = string.Empty; + + public bool HasEngineeringValue => EngineeringValue.HasValue && ScalingSource != SvEngineeringScaleSource.RawOnly; + public string DisplayValue => HasEngineeringValue + ? $"{EngineeringValue:0.###} {EngineeringUnit}" + : Value; + public string ScalingText => HasEngineeringValue + ? $"{ScalingConfidence} · {ScalingSource}" + : "Raw counts"; +} From 40e7feab4b7ee7ddf40e3864275b2c92f37440e2 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:22:14 +0700 Subject: [PATCH 06/15] Show engineering units and validity on SV phasors --- src/ARSVIN.Subscriber/Models/PhasorVector.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ARSVIN.Subscriber/Models/PhasorVector.cs b/src/ARSVIN.Subscriber/Models/PhasorVector.cs index 79fedf4..2dad04e 100644 --- a/src/ARSVIN.Subscriber/Models/PhasorVector.cs +++ b/src/ARSVIN.Subscriber/Models/PhasorVector.cs @@ -7,7 +7,11 @@ public sealed class PhasorVector public double Rms { get; init; } public double Peak { get; init; } public double AngleDegrees { get; init; } - public string RmsText => $"{Rms:0.###}"; - public string PeakText => $"{Peak:0.###}"; - public string AngleText => $"{AngleDegrees:0.0}°"; + public bool IsValid { get; init; } = true; + public string InvalidReason { get; init; } = string.Empty; + public string Unit => Kind.Equals("Voltage", StringComparison.OrdinalIgnoreCase) ? "V" : + Kind.Equals("Current", StringComparison.OrdinalIgnoreCase) ? "A" : string.Empty; + public string RmsText => IsValid ? $"{Rms:0.###} {Unit}".TrimEnd() : "invalid"; + public string PeakText => IsValid ? $"{Peak:0.###} {Unit}".TrimEnd() : "invalid"; + public string AngleText => IsValid ? $"{AngleDegrees:0.0}°" : "—"; } From b806a2e2bcdfccce3d95e1099453f557f9c47426 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:22:39 +0700 Subject: [PATCH 07/15] Add scaling and timebase evidence to subscriber snapshots --- src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs b/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs index 475ac24..e81cd74 100644 --- a/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs +++ b/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs @@ -1,3 +1,4 @@ +using AR.Iec61850.SampledValues.Measurements; using AR.Iec61850.SampledValues.Profiles; namespace ARSVIN.Subscriber.Models; @@ -20,6 +21,13 @@ public sealed class SvStreamSnapshot public ushort? SampleRate { get; init; } public ushort? SampleMode { get; init; } public byte? SmpSynch { get; init; } + public double? NominalFrequencyHz { get; init; } + public int? SamplesPerCycle { get; init; } + public ushort? ResolvedCounterWrap { get; init; } + public SvTimebaseSource TimebaseSource { get; init; } = SvTimebaseSource.Unknown; + public string TimebaseReason { get; init; } = string.Empty; + public string ScalingSummary { get; init; } = "Raw counts"; + public string ScalingReason { get; init; } = string.Empty; public long FrameCount { get; init; } public long AsduCount { get; init; } public double ActualFps { get; init; } From e13d0db24b135c19b6884645d8fafb14307c443b Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:23:53 +0700 Subject: [PATCH 08/15] Carry waveform unit and scaling provenance --- src/ARSVIN.Subscriber/Models/WaveformPoint.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ARSVIN.Subscriber/Models/WaveformPoint.cs b/src/ARSVIN.Subscriber/Models/WaveformPoint.cs index 39a325b..6d07739 100644 --- a/src/ARSVIN.Subscriber/Models/WaveformPoint.cs +++ b/src/ARSVIN.Subscriber/Models/WaveformPoint.cs @@ -4,6 +4,9 @@ public sealed class WaveformPoint { public int Index { get; init; } public ushort? SampleCount { get; init; } + public string CurrentUnit { get; init; } = "count"; + public string VoltageUnit { get; init; } = "count"; + public string ScalingSummary { get; init; } = "Raw counts"; public double? Ia { get; set; } public double? Ib { get; set; } public double? Ic { get; set; } From 9f2bd4c55076609360ef7dda96310e23cb7e4de4 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:24:41 +0700 Subject: [PATCH 09/15] Fix channel prefix detection in SV scale resolver --- .../SampledValues/Measurements/SvEngineeringScaling.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs index 3e14c1a..0e4d91f 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs @@ -128,11 +128,11 @@ public static SvMeasurementDomain ResolveDomain(string channel, string kind) return SvMeasurementDomain.Current; var normalizedChannel = channel?.Trim() ?? string.Empty; - if (normalizedChannel.StartsWith('V', StringComparison.OrdinalIgnoreCase) || + 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) || + if (normalizedChannel.StartsWith("I", StringComparison.OrdinalIgnoreCase) || normalizedChannel.Contains("TCTR", StringComparison.OrdinalIgnoreCase) || normalizedChannel.Contains("AmpSv", StringComparison.OrdinalIgnoreCase)) return SvMeasurementDomain.Current; From 1329251cdf578f7160fdbb54950a80e6e88f5d43 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:25:29 +0700 Subject: [PATCH 10/15] Keep phasor units explicit when only raw counts are known --- src/ARSVIN.Subscriber/Models/PhasorVector.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ARSVIN.Subscriber/Models/PhasorVector.cs b/src/ARSVIN.Subscriber/Models/PhasorVector.cs index 2dad04e..78ddc06 100644 --- a/src/ARSVIN.Subscriber/Models/PhasorVector.cs +++ b/src/ARSVIN.Subscriber/Models/PhasorVector.cs @@ -4,13 +4,12 @@ public sealed class PhasorVector { public string Channel { get; init; } = string.Empty; public string Kind { get; init; } = string.Empty; + public string Unit { get; init; } = "count"; public double Rms { get; init; } public double Peak { get; init; } public double AngleDegrees { get; init; } public bool IsValid { get; init; } = true; public string InvalidReason { get; init; } = string.Empty; - public string Unit => Kind.Equals("Voltage", StringComparison.OrdinalIgnoreCase) ? "V" : - Kind.Equals("Current", StringComparison.OrdinalIgnoreCase) ? "A" : string.Empty; public string RmsText => IsValid ? $"{Rms:0.###} {Unit}".TrimEnd() : "invalid"; public string PeakText => IsValid ? $"{Peak:0.###} {Unit}".TrimEnd() : "invalid"; public string AngleText => IsValid ? $"{AngleDegrees:0.0}°" : "—"; From 70daa42388cd54e9518baeb4c5b57e0bb1706122 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:28:04 +0700 Subject: [PATCH 11/15] Integrate adaptive scaling, timebase and rolling stream health --- .../Models/SvStreamRuntime.cs | 568 ++++++++++++------ 1 file changed, 378 insertions(+), 190 deletions(-) diff --git a/src/ARSVIN.Subscriber/Models/SvStreamRuntime.cs b/src/ARSVIN.Subscriber/Models/SvStreamRuntime.cs index 8a68261..7056f57 100644 --- a/src/ARSVIN.Subscriber/Models/SvStreamRuntime.cs +++ b/src/ARSVIN.Subscriber/Models/SvStreamRuntime.cs @@ -1,6 +1,8 @@ -using AR.Iec61850.Mms; using System.Buffers.Binary; +using System.Globalization; +using AR.Iec61850.Mms; using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Measurements; using AR.Iec61850.SampledValues.Profiles; using AR.Iec61850.Scl; @@ -9,14 +11,18 @@ namespace ARSVIN.Subscriber.Models; internal sealed class SvStreamRuntime { private const int MaxWaveformPoints = 640; + private static readonly TimeSpan CurrentHealthWindow = TimeSpan.FromSeconds(2); private readonly object _gate = new(); private readonly Queue _waveform = new(MaxWaveformPoints + 8); private readonly List _diagnostics = new(); + private readonly SvSampleCounterTracker _counterTracker = new(); private DateTimeOffset? _firstSeen; private DateTimeOffset? _lastSeen; - private ushort? _expectedNext; - private ushort? _lastSampleCount; + private DateTimeOffset? _lastSequenceWarningAt; + private DateTimeOffset? _lastOutOfOrderAt; + private DateTimeOffset? _lastPayloadIssueAt; + private DateTimeOffset? _lastSclMismatchAt; private long _frameCount; private long _asduCount; private double _gapTotalMs; @@ -31,9 +37,14 @@ internal sealed class SvStreamRuntime private int _qualityGood; private int _qualityNonZero; private string _lastHealthDetail = string.Empty; + private string _lastSequenceDetail = string.Empty; private string _layoutBinding = string.Empty; + private string _scalingSummary = "Raw counts"; + private string _scalingReason = "Engineering scaling has not been resolved."; private IReadOnlyList _decodedValues = Array.Empty(); private SvStreamObservationSnapshot? _observationSnapshot; + private SvTimebaseResolution _timebase = new(); + private uint? _lastConfigurationRevision; public SvStreamRuntime(string key) { @@ -70,12 +81,12 @@ public void Observe( var points = new List(); var qualityGood = 0; var qualityNonZero = 0; + var scalingSummary = "Raw counts"; + var scalingReason = "Engineering scaling has not been resolved."; + var fixedLegacyLayout = false; if (asdus.Count == 0) - { diagnostics.Add("SV frame contains no ASDU."); - IncrementPayloadIssue(); - } var layoutBinding = string.Empty; if (profile is not null) @@ -84,11 +95,23 @@ public void Observe( ValidateAgainstScl(frame, asdus, profile, diagnostics); foreach (var asdu in asdus) { - var rows = DecodePayload(asdu, profile.PayloadLayout, diagnostics).ToArray(); + var rawRows = DecodePayload(asdu, profile.PayloadLayout, diagnostics).ToArray(); + var rows = ApplyEngineeringScaling( + rawRows, + isSclBound: true, + asdu, + observationSnapshot, + out var localFixedLayout, + out var localSummary, + out var localReason); + fixedLegacyLayout |= localFixedLayout; + scalingSummary = localSummary; + scalingReason = localReason; + if (latestRows.Count == 0) latestRows.AddRange(rows); - points.Add(BuildWaveformPoint(asdu.SampleCount, rows)); + points.Add(BuildWaveformPoint(asdu.SampleCount, rows, scalingSummary)); CountQuality(rows, ref qualityGood, ref qualityNonZero); } } @@ -96,23 +119,43 @@ public void Observe( { foreach (var asdu in asdus) { - if (TryDecodeAutoPayload(asdu, diagnostics, out var rows, out var binding)) - { - layoutBinding = binding; - if (latestRows.Count == 0) - latestRows.AddRange(rows); + if (!TryDecodeAutoPayload(asdu, diagnostics, out var rawRows, out var binding)) + continue; + + layoutBinding = binding; + var rows = ApplyEngineeringScaling( + rawRows, + isSclBound: false, + asdu, + observationSnapshot, + out var localFixedLayout, + out var localSummary, + out var localReason); + fixedLegacyLayout |= localFixedLayout; + scalingSummary = localSummary; + scalingReason = localReason; - points.Add(BuildWaveformPoint(asdu.SampleCount, rows)); - CountQuality(rows, ref qualityGood, ref qualityNonZero); - } + if (latestRows.Count == 0) + latestRows.AddRange(rows); + + points.Add(BuildWaveformPoint(asdu.SampleCount, rows, scalingSummary)); + CountQuality(rows, ref qualityGood, ref qualityNonZero); } if (latestRows.Count == 0) - diagnostics.Add("No SCL binding and payload layout is unknown. Import SCL or use a fixed 9-2LE/UCA-style stream."); + diagnostics.Add("No SCL binding and payload layout is unknown. Import SCL or inspect raw payload bytes."); else - diagnostics.Add($"{layoutBinding}. SCL not loaded; channel names are inferred from the fixed payload profile."); + diagnostics.Add($"{layoutBinding}. SCL not loaded; channel names and scaling confidence are shown explicitly."); } + var resolvedTimebase = SvTimebaseResolver.Resolve(new SvTimebaseEvidence + { + DeclaredSampleRate = first?.SampleRate, + DeclaredSampleMode = first?.SampleMode, + ObservedSamplesPerSecond = observationSnapshot.Facts.ObservedSamplesPerSecond, + IsFixedLegacyProtectionLayout = fixedLegacyLayout + }); + lock (_gate) { if (_firstSeen is null) @@ -141,6 +184,16 @@ public void Observe( IsBoundToScl = profile is not null; ControlBlockReference = profile?.Stream.ControlBlockReference ?? string.Empty; LayoutBinding = layoutBinding; + _layoutBinding = layoutBinding; + _scalingSummary = scalingSummary; + _scalingReason = scalingReason; + + if (resolvedTimebase.IsResolved || !_timebase.IsResolved) + _timebase = resolvedTimebase; + + var configurationRevisionChanged = first is not null && + _lastConfigurationRevision.HasValue && + first.ConfigurationRevision != _lastConfigurationRevision.Value; if (first is not null) { @@ -150,10 +203,17 @@ public void Observe( SampleRate = first.SampleRate; SampleMode = first.SampleMode; SmpSynch = first.SampleSynchronization; + _lastConfigurationRevision = first.ConfigurationRevision; } - foreach (var asdu in asdus) - RecordSample(asdu.SampleCount, ResolveCounterWrap(asdu, profile)); + for (var index = 0; index < asdus.Count; index++) + { + var transition = _counterTracker.Observe( + asdus[index].SampleCount, + _timebase.SampleCounterWrap, + restartHint: configurationRevisionChanged && index == 0); + RecordTransition(transition, timestamp); + } foreach (var point in points) { @@ -165,11 +225,23 @@ public void Observe( _qualityGood += qualityGood; _qualityNonZero += qualityNonZero; - if (diagnostics.Any(x => x.Contains("payload", StringComparison.OrdinalIgnoreCase) || x.Contains("decode", StringComparison.OrdinalIgnoreCase))) + var hasPayloadIssue = asdus.Count == 0 || diagnostics.Any(x => + x.Contains("payload", StringComparison.OrdinalIgnoreCase) || + x.Contains("decode", StringComparison.OrdinalIgnoreCase)); + if (hasPayloadIssue) + { _payloadIssues++; + _lastPayloadIssueAt = timestamp; + } - if (diagnostics.Any(x => x.Contains("mismatch", StringComparison.OrdinalIgnoreCase) || x.Contains("differs", StringComparison.OrdinalIgnoreCase))) + var hasSclMismatch = diagnostics.Any(x => + x.Contains("mismatch", StringComparison.OrdinalIgnoreCase) || + x.Contains("differs", StringComparison.OrdinalIgnoreCase)); + if (hasSclMismatch) + { _sclMismatches++; + _lastSclMismatchAt = timestamp; + } _diagnostics.Clear(); _diagnostics.AddRange(diagnostics.Take(12)); @@ -187,19 +259,18 @@ public SvStreamSnapshot Snapshot() ? Math.Max(0.001, (_lastSeen.Value - _firstSeen.Value).TotalSeconds) : 0.001; var fps = _frameCount / duration; - var issues = _sequenceGaps + _duplicates + _outOfOrder + _payloadIssues + _sclMismatches; - var health = issues > 0 - ? (_payloadIssues > 0 || _sclMismatches > 0 || _outOfOrder > 0 ? "BAD" : "WARN") - : IsBoundToScl ? "GOOD" : "WARN"; + var referenceTime = _lastSeen ?? DateTimeOffset.Now; + var (health, healthDetail) = ResolveCurrentHealth(referenceTime); var allPoints = _waveform.ToArray(); - var visiblePoints = BuildLockedTwoCycleWindow(allPoints, SampleRate); - var phasors = ComputePhasors(visiblePoints, SampleRate).ToArray(); + var visiblePoints = BuildLockedTwoCycleWindow(allPoints, _timebase.SamplesPerCycle); + var phasors = ComputePhasors(visiblePoints, _timebase.SamplesPerCycle).ToArray(); + var observationFacts = _observationSnapshot?.Facts; return new SvStreamSnapshot { Key = Key, Health = health, - HealthDetail = _lastHealthDetail, + HealthDetail = healthDetail, AppId = AppId, Source = Source, Destination = Destination, @@ -209,10 +280,17 @@ public SvStreamSnapshot Snapshot() DataSet = DataSet, ConfRev = ConfRev, NofAsdu = NofAsdu, - LastSmpCnt = _lastSampleCount, + LastSmpCnt = _counterTracker.Last, SampleRate = SampleRate, SampleMode = SampleMode, SmpSynch = SmpSynch, + NominalFrequencyHz = _timebase.NominalFrequencyHz, + SamplesPerCycle = _timebase.SamplesPerCycle, + ResolvedCounterWrap = _timebase.SampleCounterWrap, + TimebaseSource = _timebase.Source, + TimebaseReason = _timebase.Reason, + ScalingSummary = _scalingSummary, + ScalingReason = _scalingReason, FrameCount = _frameCount, AsduCount = _asduCount, ActualFps = fps, @@ -236,18 +314,155 @@ public SvStreamSnapshot Snapshot() ? "Quality not decoded" : $"Quality good {_qualityGood:N0}, non-zero {_qualityNonZero:N0}", ObservationInputKinds = _observationSnapshot?.InputKinds ?? Array.Empty(), - ObservationWindowFrames = _observationSnapshot?.Facts.ObservationCount ?? 0, - ObservedFramesPerSecond = _observationSnapshot?.Facts.ObservedFramesPerSecond, - ObservedSamplesPerSecond = _observationSnapshot?.Facts.ObservedSamplesPerSecond, - ObservedCounterWrap = _observationSnapshot?.Facts.ObservedCounterWrap, + ObservationWindowFrames = observationFacts?.ObservationCount ?? 0, + ObservationWindowSamples = observationFacts is null + ? 0 + : observationFacts.ObservationCount * Math.Max(1, observationFacts.AsduPerFrame ?? 1), + ObservationWindowDurationSeconds = ResolveObservationDuration(observationFacts), + ObservedFramesPerSecond = observationFacts?.ObservedFramesPerSecond, + ObservedSamplesPerSecond = observationFacts?.ObservedSamplesPerSecond, + ObservedCounterWrap = observationFacts?.ObservedCounterWrap, + IsWaveformWindowReady = _timebase.SamplesPerCycle is > 0 && + visiblePoints.Length >= _timebase.SamplesPerCycle.Value * 2, + ProfileDetection = _observationSnapshot?.ProfileDetection, + ConfigurationComparison = _observationSnapshot?.ConfigurationComparison, ObservationDiagnostics = _observationSnapshot?.Diagnostics ?? Array.Empty(), - FactProvenance = _observationSnapshot?.Facts.Provenance + FactProvenance = observationFacts?.Provenance ?? new Dictionary(StringComparer.Ordinal) }; } } - private static bool TryDecodeAutoPayload(SampledValueAsdu asdu, ICollection diagnostics, out DecodedValueRow[] rows, out string layoutBinding) + private static double ResolveObservationDuration(SvObservedStreamFacts? facts) + { + if (facts?.FirstTimestamp is not { } first || facts.LastTimestamp is not { } last) + return 0; + return Math.Max(0, (last - first).TotalSeconds); + } + + private (string Health, string Detail) ResolveCurrentHealth(DateTimeOffset referenceTime) + { + if (IsRecent(_lastPayloadIssueAt, referenceTime)) + return ("BAD", _lastHealthDetail.Length == 0 ? "Recent SV payload or decode failure." : _lastHealthDetail); + if (IsRecent(_lastOutOfOrderAt, referenceTime)) + return ("BAD", _lastSequenceDetail.Length == 0 ? "Recent out-of-order smpCnt transition." : _lastSequenceDetail); + if (IsRecent(_lastSclMismatchAt, referenceTime)) + return ("WARN", _lastHealthDetail.Length == 0 ? "Observed traffic differs from the SCL expectation." : _lastHealthDetail); + if (IsRecent(_lastSequenceWarningAt, referenceTime)) + return ("WARN", _lastSequenceDetail.Length == 0 ? "Recent smpCnt gap or duplicate." : _lastSequenceDetail); + if (!IsBoundToScl) + return ("WARN", "Stream is not SCL-bound; layout, channel names or scaling may be inferred."); + if (_scalingSummary.Equals("Raw counts", StringComparison.OrdinalIgnoreCase)) + return ("WARN", "Payload is decoded, but engineering scaling is not proven; waveform remains in raw counts."); + return ("GOOD", "SV stream is stable, SCL-bound and displayed with evidence-backed engineering scaling."); + } + + private static bool IsRecent(DateTimeOffset? eventTime, DateTimeOffset referenceTime) + => eventTime.HasValue && referenceTime >= eventTime.Value && + referenceTime - eventTime.Value <= CurrentHealthWindow; + + private void RecordTransition(SvSampleCounterTransition transition, DateTimeOffset timestamp) + { + switch (transition.Kind) + { + case SvSampleCounterTransitionKind.Gap: + _sequenceGaps++; + _lastSequenceWarningAt = timestamp; + _lastSequenceDetail = transition.Detail; + break; + case SvSampleCounterTransitionKind.Duplicate: + _duplicates++; + _lastSequenceWarningAt = timestamp; + _lastSequenceDetail = transition.Detail; + break; + case SvSampleCounterTransitionKind.OutOfOrder: + _outOfOrder++; + _lastOutOfOrderAt = timestamp; + _lastSequenceDetail = transition.Detail; + break; + case SvSampleCounterTransitionKind.Restart: + _lastSequenceDetail = transition.Detail; + break; + } + } + + private static DecodedValueRow[] ApplyEngineeringScaling( + IReadOnlyList rawRows, + bool isSclBound, + SampledValueAsdu asdu, + SvStreamObservationSnapshot observation, + out bool fixedLegacyLayout, + out string scalingSummary, + out string scalingReason) + { + var analogRows = rawRows + .Where(row => row.NumericValue.HasValue && !string.IsNullOrWhiteSpace(ClassifyAnalogChannel(row.Signal, row.Kind))) + .ToArray(); + fixedLegacyLayout = analogRows.Length == 8 && asdu.SamplePayload.Length == 64; + var scaled = new List(rawRows.Count); + var resolvedScales = new List(); + + foreach (var row in rawRows) + { + if (!row.NumericValue.HasValue) + { + scaled.Add(row); + continue; + } + + var channel = ClassifyAnalogChannel(row.Signal, row.Kind); + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = channel, + Kind = row.Kind, + IsSclBound = isSclBound, + IsFixedFourCurrentFourVoltageLayout = fixedLegacyLayout, + AnalogChannelCount = analogRows.Length, + PayloadBytesPerAsdu = asdu.SamplePayload.Length, + DeclaredSampleRate = asdu.SampleRate, + DeclaredSampleMode = asdu.SampleMode, + ObservedSamplesPerSecond = observation.Facts.ObservedSamplesPerSecond + }); + resolvedScales.Add(scale); + + scaled.Add(new DecodedValueRow + { + Index = row.Index, + Signal = row.Signal, + Kind = row.Kind, + Value = row.Value, + Raw = row.Raw, + NumericValue = row.NumericValue, + EngineeringValue = scale.HasEngineeringUnit ? scale.Apply(row.NumericValue.Value) : null, + EngineeringUnit = scale.HasEngineeringUnit ? scale.Unit : string.Empty, + ScalingSource = scale.Source, + ScalingConfidence = scale.Confidence, + ScalingReason = scale.Reason + }); + } + + var engineeringScale = resolvedScales.FirstOrDefault(scale => scale.HasEngineeringUnit); + if (engineeringScale is null) + { + scalingSummary = "Raw counts"; + scalingReason = resolvedScales.FirstOrDefault()?.Reason ?? "No numeric analog channels were available for scaling."; + } + else + { + scalingSummary = engineeringScale.Confidence == SvEngineeringScaleConfidence.SclBacked + ? "Engineering A/V · SCL-backed 9-2LE-style" + : "Engineering A/V · inferred 9-2LE-style"; + scalingReason = engineeringScale.Reason; + } + + return scaled.ToArray(); + } + + private static bool TryDecodeAutoPayload( + SampledValueAsdu asdu, + ICollection diagnostics, + out DecodedValueRow[] rows, + out string layoutBinding) { rows = Array.Empty(); layoutBinding = string.Empty; @@ -300,7 +515,7 @@ private static bool TryDecodeAutoPayload(SampledValueAsdu asdu, ICollection channel + "/VolSv.instMag.i", }; } - private WaveformPoint BuildWaveformPoint(ushort? smpCnt, IEnumerable rows) + private WaveformPoint BuildWaveformPoint(ushort? smpCnt, IEnumerable rows, string scalingSummary) { - var point = new WaveformPoint { Index = _waveformIndex++, SampleCount = smpCnt }; - foreach (var row in rows) + var rowArray = rows.ToArray(); + var currentUnit = rowArray.FirstOrDefault(row => + row.HasEngineeringValue && SvEngineeringScaleResolver.ResolveDomain(row.Signal, row.Kind) == SvMeasurementDomain.Current)?.EngineeringUnit ?? "count"; + var voltageUnit = rowArray.FirstOrDefault(row => + row.HasEngineeringValue && SvEngineeringScaleResolver.ResolveDomain(row.Signal, row.Kind) == SvMeasurementDomain.Voltage)?.EngineeringUnit ?? "count"; + var point = new WaveformPoint { - if (!row.NumericValue.HasValue) + Index = _waveformIndex++, + SampleCount = smpCnt, + CurrentUnit = currentUnit, + VoltageUnit = voltageUnit, + ScalingSummary = scalingSummary + }; + + foreach (var row in rowArray) + { + var value = row.EngineeringValue ?? row.NumericValue; + if (!value.HasValue) continue; var channel = ClassifyAnalogChannel(row.Signal, row.Kind); switch (channel) { - case "Ia": point.Ia = row.NumericValue.Value; break; - case "Ib": point.Ib = row.NumericValue.Value; break; - case "Ic": point.Ic = row.NumericValue.Value; break; - case "In": point.In = row.NumericValue.Value; break; - case "Va": point.Va = row.NumericValue.Value; break; - case "Vb": point.Vb = row.NumericValue.Value; break; - case "Vc": point.Vc = row.NumericValue.Value; break; - case "Vn": point.Vn = row.NumericValue.Value; break; + case "Ia": point.Ia = value.Value; break; + case "Ib": point.Ib = value.Value; break; + case "Ic": point.Ic = value.Value; break; + case "In": point.In = value.Value; break; + case "Va": point.Va = value.Value; break; + case "Vb": point.Vb = value.Value; break; + case "Vc": point.Vc = value.Value; break; + case "Vn": point.Vn = value.Value; break; } } @@ -363,7 +592,8 @@ private WaveformPoint BuildWaveformPoint(ushort? smpCnt, IEnumerable rows, ref int good } } - private static IEnumerable ComputePhasors(IReadOnlyList points, ushort? sampleRate) + private static IEnumerable ComputePhasors( + IReadOnlyList points, + int? samplesPerCycle) { - var cyclePoints = ResolvePointsPerCycle(sampleRate, points.Count); - var window = points.TakeLast(cyclePoints).ToArray(); - if (window.Length < 8) + if (samplesPerCycle is not > 0 || points.Count < samplesPerCycle.Value) return Array.Empty(); + var window = points.TakeLast(samplesPerCycle.Value).ToArray(); + var currentUnit = window.FirstOrDefault()?.CurrentUnit ?? "count"; + var voltageUnit = window.FirstOrDefault()?.VoltageUnit ?? "count"; var phasors = new List(); foreach (var item in new[] { - (Name: "Ia", Kind: "Current", Values: window.Select(x => x.Ia)), - (Name: "Ib", Kind: "Current", Values: window.Select(x => x.Ib)), - (Name: "Ic", Kind: "Current", Values: window.Select(x => x.Ic)), - (Name: "In", Kind: "Current", Values: window.Select(x => x.In)), - (Name: "Va", Kind: "Voltage", Values: window.Select(x => x.Va)), - (Name: "Vb", Kind: "Voltage", Values: window.Select(x => x.Vb)), - (Name: "Vc", Kind: "Voltage", Values: window.Select(x => x.Vc)), - (Name: "Vn", Kind: "Voltage", Values: window.Select(x => x.Vn)) + (Name: "Ia", Kind: "Current", Unit: currentUnit, Values: window.Select(x => x.Ia)), + (Name: "Ib", Kind: "Current", Unit: currentUnit, Values: window.Select(x => x.Ib)), + (Name: "Ic", Kind: "Current", Unit: currentUnit, Values: window.Select(x => x.Ic)), + (Name: "In", Kind: "Current", Unit: currentUnit, Values: window.Select(x => x.In)), + (Name: "Va", Kind: "Voltage", Unit: voltageUnit, Values: window.Select(x => x.Va)), + (Name: "Vb", Kind: "Voltage", Unit: voltageUnit, Values: window.Select(x => x.Vb)), + (Name: "Vc", Kind: "Voltage", Unit: voltageUnit, Values: window.Select(x => x.Vc)), + (Name: "Vn", Kind: "Voltage", Unit: voltageUnit, Values: window.Select(x => x.Vn)) }) { - var values = item.Values.Where(x => x.HasValue).Select(x => x!.Value).ToArray(); - if (values.Length < 8) + var values = item.Values.ToArray(); + if (values.Any(value => !value.HasValue)) + continue; + + var numeric = values.Select(value => value!.Value).ToArray(); + if (numeric.Length != samplesPerCycle.Value) continue; - var rms = Math.Sqrt(values.Select(x => x * x).Average()); - var peak = values.Select(Math.Abs).DefaultIfEmpty(0).Max(); + var rms = Math.Sqrt(numeric.Select(value => value * value).Average()); + var peak = numeric.Select(Math.Abs).DefaultIfEmpty(0).Max(); + var mean = numeric.Average(); var sin = 0.0; var cos = 0.0; - for (var i = 0; i < values.Length; i++) + for (var i = 0; i < numeric.Length; i++) { - var theta = 2.0 * Math.PI * i / values.Length; - sin += values[i] * Math.Sin(theta); - cos += values[i] * Math.Cos(theta); + var theta = 2.0 * Math.PI * i / numeric.Length; + var ac = numeric[i] - mean; + sin += ac * Math.Sin(theta); + cos += ac * Math.Cos(theta); } var angle = Math.Atan2(cos, sin) * 180.0 / Math.PI; @@ -442,13 +691,15 @@ private static IEnumerable ComputePhasors(IReadOnlyList string.Equals(x.Channel, "Va", StringComparison.OrdinalIgnoreCase) && x.Rms > 0); + var va = phasors.FirstOrDefault(x => + string.Equals(x.Channel, "Va", StringComparison.OrdinalIgnoreCase) && x.Rms > 0); if (va is null) return phasors; @@ -456,25 +707,26 @@ private static IEnumerable ComputePhasors(IReadOnlyList points, ushort? sampleRate) + private static WaveformPoint[] BuildLockedTwoCycleWindow( + IReadOnlyList points, + int? samplesPerCycle) { if (points.Count == 0) return Array.Empty(); + if (samplesPerCycle is not > 0) + return points.TakeLast(Math.Min(512, points.Count)).ToArray(); - var pointsPerCycle = ResolvePointsPerCycle(sampleRate, points.Count); - if (pointsPerCycle <= 0) - return points.ToArray(); - - var window = Math.Clamp(pointsPerCycle * 2, 32, 512); + var window = Math.Clamp(samplesPerCycle.Value * 2, 32, 512); var slots = new WaveformPoint?[window]; - foreach (var point in points) { var slot = point.SampleCount.HasValue @@ -486,35 +738,30 @@ private static WaveformPoint[] BuildLockedTwoCycleWindow(IReadOnlyList(window); for (var slot = 0; slot < slots.Length; slot++) { - if (slots[slot] is { } point) + if (slots[slot] is not { } point) + continue; + + result.Add(new WaveformPoint { - result.Add(new WaveformPoint - { - Index = slot, - SampleCount = point.SampleCount, - Ia = point.Ia, - Ib = point.Ib, - Ic = point.Ic, - In = point.In, - Va = point.Va, - Vb = point.Vb, - Vc = point.Vc, - Vn = point.Vn - }); - } + Index = slot, + SampleCount = point.SampleCount, + CurrentUnit = point.CurrentUnit, + VoltageUnit = point.VoltageUnit, + ScalingSummary = point.ScalingSummary, + Ia = point.Ia, + Ib = point.Ib, + Ic = point.Ic, + In = point.In, + Va = point.Va, + Vb = point.Vb, + Vc = point.Vc, + Vn = point.Vn + }); } - return result.Count >= Math.Min(16, window) ? result.ToArray() : points.TakeLast(Math.Min(window, points.Count)).ToArray(); - } - - private static int ResolvePointsPerCycle(ushort? sampleRate, int available) - { - if (available <= 0) - return 0; - - var candidate = sampleRate is > 1000 ? (int)Math.Round(sampleRate.Value / 50.0) : sampleRate ?? 80; - candidate = Math.Clamp(candidate, 16, 256); - return Math.Min(candidate, available); + return result.Count >= window + ? result.ToArray() + : points.TakeLast(Math.Min(window, points.Count)).ToArray(); } private static double NormalizeAngle(double angle) @@ -531,78 +778,21 @@ private static string BuildCursorSummary(IReadOnlyList points) var a = points[Math.Max(0, points.Count - 2)]; var b = points[^1]; - var iaDelta = Delta(a.Ia, b.Ia); - var vaDelta = Delta(a.Va, b.Va); + var iaDelta = Delta(a.Ia, b.Ia, b.CurrentUnit); + var vaDelta = Delta(a.Va, b.Va, b.VoltageUnit); return $"Cursor compare: ΔIa={iaDelta}, ΔVa={vaDelta}, smpCnt {a.SampleCount?.ToString() ?? "-"} → {b.SampleCount?.ToString() ?? "-"}"; } - private static string Delta(double? a, double? b) - => a.HasValue && b.HasValue ? (b.Value - a.Value).ToString("0.###") : "-"; - - private void RecordSample(ushort sampleCount, ushort? wrap) - { - if (!_lastSampleCount.HasValue) - { - _lastSampleCount = sampleCount; - _expectedNext = NextSample(sampleCount, wrap); - return; - } - - var previous = _lastSampleCount.Value; - if (sampleCount == previous) - { - _duplicates++; - return; - } + private static string Delta(double? a, double? b, string unit) + => a.HasValue && b.HasValue + ? $"{b.Value - a.Value:0.###} {unit}".TrimEnd() + : "-"; - var expected = _expectedNext ?? NextSample(previous, wrap); - if (sampleCount == expected) - { - _lastSampleCount = sampleCount; - _expectedNext = NextSample(sampleCount, wrap); - return; - } - - if (IsForwardJump(expected, sampleCount, wrap)) - _sequenceGaps++; - else - _outOfOrder++; - - _lastSampleCount = sampleCount; - _expectedNext = NextSample(sampleCount, wrap); - } - - private static ushort NextSample(ushort sampleCount, ushort? wrap) - { - if (wrap is > 0) - return (ushort)((sampleCount + 1) % wrap.Value); - - return unchecked((ushort)(sampleCount + 1)); - } - - private static bool IsForwardJump(ushort expected, ushort actual, ushort? wrap) - { - if (actual > expected) - return true; - - if (wrap is > 0 && expected > wrap.Value * 0.8 && actual < wrap.Value * 0.2) - return true; - - return false; - } - - private static ushort? ResolveCounterWrap(SampledValueAsdu asdu, SampledValuesPublisherProfile? profile) - { - if (profile is not null) - return profile.ResolveSampleCounterWrap(50); - - if (asdu.SampleMode is 1 && asdu.SampleRate is > 0) - return asdu.SampleRate; - - return null; - } - - private void ValidateAgainstScl(SampledValuesFrame frame, IReadOnlyList asdus, SampledValuesPublisherProfile profile, ICollection diagnostics) + private static void ValidateAgainstScl( + SampledValuesFrame frame, + IReadOnlyList asdus, + SampledValuesPublisherProfile profile, + ICollection diagnostics) { if (!string.Equals(profile.Destination.ToString(), frame.Destination.ToString(), StringComparison.OrdinalIgnoreCase)) diagnostics.Add($"SCL mismatch: destination MAC expected {profile.Destination}, got {frame.Destination}."); @@ -624,7 +814,8 @@ private void ValidateAgainstScl(SampledValuesFrame frame, IReadOnlyList DecodePayload(SampledValueAsdu asdu, SampledValuesPayloadLayout layout, ICollection diagnostics) + private static IEnumerable DecodePayload( + SampledValueAsdu asdu, + SampledValuesPayloadLayout layout, + ICollection diagnostics) { var decode = SampledValuesPayloadDecoder.Decode(layout, asdu.SamplePayload); foreach (var issue in decode.Diagnostics) @@ -655,15 +849,9 @@ private static IEnumerable DecodePayload(SampledValueAsdu asdu, private static double? ExtractNumeric(MmsDataValue value) => value.Kind switch { - MmsDataKind.Integer => Convert.ToDouble(value.Value, System.Globalization.CultureInfo.InvariantCulture), - MmsDataKind.Unsigned => Convert.ToDouble(value.Value, System.Globalization.CultureInfo.InvariantCulture), - MmsDataKind.FloatingPoint => Convert.ToDouble(value.Value, System.Globalization.CultureInfo.InvariantCulture), + MmsDataKind.Integer => Convert.ToDouble(value.Value, CultureInfo.InvariantCulture), + MmsDataKind.Unsigned => Convert.ToDouble(value.Value, CultureInfo.InvariantCulture), + MmsDataKind.FloatingPoint => Convert.ToDouble(value.Value, CultureInfo.InvariantCulture), _ => null }; - - private void IncrementPayloadIssue() - { - lock (_gate) - _payloadIssues++; - } } From e9ecac1cd366610c2adb55d5b3ac2184bd55e1c2 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:29:43 +0700 Subject: [PATCH 12/15] Use rolling health and resolved timebase in Subscriber view state --- .../ViewModels/SvStreamViewModel.cs | 93 +++++++++++++------ 1 file changed, 63 insertions(+), 30 deletions(-) diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs index 976565b..8e5817e 100644 --- a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs @@ -38,7 +38,9 @@ public sealed class SvStreamViewModel : ObservableObject private string _sclMatch = "Not configured"; private string _window = "0.0 s · 0 samples"; private string _comparisonMode = "Compatible"; - private string _waveformState = "Waiting for complete two-cycle window"; + private string _waveformState = "Waiting for a resolved timebase"; + private string _scaling = "Raw counts"; + private string _timebase = "Unknown"; public IReadOnlyList Values => _values; public IReadOnlyList WaveformPoints => _waveformPoints; @@ -75,6 +77,8 @@ public sealed class SvStreamViewModel : ObservableObject public string Window { get => _window; set => SetProperty(ref _window, value); } public string ComparisonMode { get => _comparisonMode; set => SetProperty(ref _comparisonMode, value); } public string WaveformState { get => _waveformState; set => SetProperty(ref _waveformState, value); } + public string Scaling { get => _scaling; set => SetProperty(ref _scaling, value); } + public string Timebase { get => _timebase; set => SetProperty(ref _timebase, value); } public void Apply(SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observation) { @@ -87,7 +91,7 @@ public void Apply(SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observ Vlan = snapshot.VlanId.HasValue ? $"{snapshot.VlanId} / p{snapshot.VlanPriority ?? 0}" : "untagged"; ConfRev = snapshot.ConfRev?.ToString() ?? "-"; NofAsdu = snapshot.NofAsdu <= 0 ? "-" : snapshot.NofAsdu.ToString(); - SampleRate = snapshot.SampleRate?.ToString() ?? "-"; + SampleRate = BuildSampleRateText(snapshot); SmpCnt = snapshot.LastSmpCnt?.ToString() ?? "-"; SmpSynch = snapshot.SmpSynch?.ToString() ?? "-"; Packets = snapshot.FrameCount.ToString("N0"); @@ -96,26 +100,27 @@ public void Apply(SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observ DataSet = string.IsNullOrWhiteSpace(snapshot.DataSet) ? "-" : snapshot.DataSet; CursorSummary = snapshot.CursorSummary; QualitySummary = snapshot.QualitySummary; + Scaling = snapshot.ScalingSummary; + Timebase = BuildTimebaseText(snapshot); var comparison = observation?.ConfigurationComparison; var configurationIssues = comparison?.Findings.Count ?? 0; - var issueTotal = snapshot.SequenceGapCount + snapshot.DuplicateCount + snapshot.OutOfOrderCount + snapshot.PayloadIssueCount + configurationIssues; + var issueTotal = snapshot.SequenceGapCount + snapshot.DuplicateCount + snapshot.OutOfOrderCount + + snapshot.PayloadIssueCount + configurationIssues; Issues = issueTotal == 0 ? "0" : $"{issueTotal} (gap {snapshot.SequenceGapCount}, dup {snapshot.DuplicateCount}, order {snapshot.OutOfOrderCount}, payload {snapshot.PayloadIssueCount}, SCL {configurationIssues})"; var hasBlockingConfigurationError = comparison?.HasBlockingErrors == true; var hasConfigurationWarning = comparison?.WarningCount > 0; - var hasRuntimeError = snapshot.PayloadIssueCount > 0 || snapshot.OutOfOrderCount > 0; - var hasRuntimeWarning = snapshot.SequenceGapCount > 0 || snapshot.DuplicateCount > 0; - var isConfigured = observation?.IsBoundToScl == true; - Health = hasRuntimeError || hasBlockingConfigurationError + Health = hasBlockingConfigurationError ? "BAD" - : hasRuntimeWarning || hasConfigurationWarning || !isConfigured + : hasConfigurationWarning && snapshot.Health == "GOOD" ? "WARN" - : "GOOD"; + : snapshot.Health; HealthDetail = ResolveHealthDetail(snapshot, observation, Health); + var isConfigured = observation?.IsBoundToScl == true; Bound = isConfigured ? $"SCL: {observation!.ControlBlockReference}" : string.IsNullOrWhiteSpace(snapshot.LayoutBinding) ? "Unbound" : snapshot.LayoutBinding; @@ -136,32 +141,54 @@ public void Apply(SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observ _values.ReplaceAll(snapshot.Values.Take(64)); UpdateStableVisuals(snapshot); - _evidenceDetails.ReplaceAll(BuildEvidenceDetails(observation)); + _evidenceDetails.ReplaceAll(BuildEvidenceDetails(snapshot, observation)); } private void UpdateStableVisuals(SvStreamSnapshot snapshot) { - var expectedPoints = ResolveTwoCyclePointCount(snapshot.SampleRate); - var fullWindow = snapshot.WaveformPoints.Count >= expectedPoints; + if (snapshot.SamplesPerCycle is not > 0) + { + _waveformPoints.ReplaceAll(snapshot.WaveformPoints); + _phasors.ReplaceAll(Array.Empty()); + WaveformState = $"{snapshot.ScalingSummary} · timebase unresolved; phasor withheld"; + return; + } + + var expectedPoints = Math.Clamp(snapshot.SamplesPerCycle.Value * 2, 32, 512); + var fullWindow = snapshot.IsWaveformWindowReady && snapshot.WaveformPoints.Count >= expectedPoints; if (fullWindow) { _waveformPoints.ReplaceAll(snapshot.WaveformPoints.TakeLast(expectedPoints)); _phasors.ReplaceAll(snapshot.Phasors); - WaveformState = $"2 cycles locked · {expectedPoints:N0} points"; + WaveformState = $"2 cycles locked · {expectedPoints:N0} points · {snapshot.ScalingSummary}"; return; } - if (_waveformPoints.Count == 0) - _phasors.ReplaceAll(Array.Empty()); - WaveformState = $"Filling full window · {snapshot.WaveformPoints.Count:N0}/{expectedPoints:N0} points"; + _waveformPoints.ReplaceAll(snapshot.WaveformPoints); + _phasors.ReplaceAll(Array.Empty()); + WaveformState = $"Filling verified window · {snapshot.WaveformPoints.Count:N0}/{expectedPoints:N0} points"; + } + + private static string BuildSampleRateText(SvStreamSnapshot snapshot) + { + var declared = snapshot.SampleRate?.ToString() ?? "-"; + var mode = snapshot.SampleMode switch + { + 0 => "smp/cycle", + 1 => "smp/s", + 2 => "s/smp", + _ => "mode unknown" + }; + return $"{declared} {mode}"; } - private static int ResolveTwoCyclePointCount(ushort? sampleRate) + private static string BuildTimebaseText(SvStreamSnapshot snapshot) { - var pointsPerCycle = sampleRate is > 1000 - ? (int)Math.Round(sampleRate.Value / 50.0) - : sampleRate ?? 80; - return Math.Clamp(pointsPerCycle * 2, 32, 512); + if (snapshot.NominalFrequencyHz.HasValue && snapshot.SamplesPerCycle.HasValue) + return $"{snapshot.NominalFrequencyHz:0.#} Hz · {snapshot.SamplesPerCycle} smp/cycle · {snapshot.TimebaseSource}"; + if (snapshot.SamplesPerCycle.HasValue) + return $"{snapshot.SamplesPerCycle} smp/cycle · frequency unknown"; + return "Unknown · no hidden 50/60 Hz assumption"; } private static double ResolveObservationDuration(SvObservedStreamFacts? facts) @@ -199,25 +226,31 @@ private static string ResolveHealthDetail( if (blocking is not null) return blocking.Message; - if (snapshot.PayloadIssueCount > 0 || snapshot.OutOfOrderCount > 0) - return snapshot.HealthDetail; - var warning = observation?.ConfigurationComparison?.Findings .FirstOrDefault(item => item.Severity == SvConfigurationFindingSeverity.Warning); - if (warning is not null) + if (warning is not null && health != "BAD") return warning.Message; - if (health == "GOOD") - return "SV stream is stable and matches the configured SCL expectation."; return snapshot.HealthDetail; } - private static IReadOnlyList BuildEvidenceDetails(SvStreamObservationSnapshot? observation) + private static IReadOnlyList BuildEvidenceDetails( + SvStreamSnapshot snapshot, + SvStreamObservationSnapshot? observation) { + var lines = new List + { + $"MEASUREMENT · scaling {snapshot.ScalingSummary} · {snapshot.ScalingReason}", + $"TIMEBASE · {BuildTimebaseText(snapshot)} · {snapshot.TimebaseReason}", + $"HEALTH · current {snapshot.Health} · session totals gap {snapshot.SequenceGapCount}, duplicate {snapshot.DuplicateCount}, out-of-order {snapshot.OutOfOrderCount}, payload {snapshot.PayloadIssueCount}." + }; + if (observation is null) - return ["No observation evidence is available yet."]; + { + lines.Add("No observation evidence is available yet."); + return lines; + } - var lines = new List(); if (observation.ProfileDetection is { } detection) { lines.Add($"Profile: {detection.Profile.DisplayName} · confidence {detection.Confidence} · score {detection.ScorePercent:0.#}% · evidence {detection.MatchedWeight}/{detection.EvaluatedWeight}."); From b70a8c0e1ef852c8ea03215aeb9126eb9c7f9520 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 14:31:41 +0700 Subject: [PATCH 13/15] Fix clipped header and expose health, scaling and timebase evidence --- src/ARSVIN.Subscriber/MainWindow.xaml | 116 ++++++++++++++++++-------- 1 file changed, 79 insertions(+), 37 deletions(-) diff --git a/src/ARSVIN.Subscriber/MainWindow.xaml b/src/ARSVIN.Subscriber/MainWindow.xaml index 27d69bb..b90e687 100644 --- a/src/ARSVIN.Subscriber/MainWindow.xaml +++ b/src/ARSVIN.Subscriber/MainWindow.xaml @@ -14,9 +14,30 @@ UseLayoutRounding="True" WindowStartupLocation="CenterScreen"> + + + + - + @@ -24,20 +45,21 @@ - - + + - + + + - - - + - - + - +