From 41e8a759a30acbea6e9099c23f4f644dc3bed883 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:02:12 +0700 Subject: [PATCH 1/9] Add semantic Sampled Values quality analysis --- .../Measurements/SvQualityAnalysis.cs | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 src/ARSVIN.Engine/SampledValues/Measurements/SvQualityAnalysis.cs diff --git a/src/ARSVIN.Engine/SampledValues/Measurements/SvQualityAnalysis.cs b/src/ARSVIN.Engine/SampledValues/Measurements/SvQualityAnalysis.cs new file mode 100644 index 0000000..5f15228 --- /dev/null +++ b/src/ARSVIN.Engine/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 ec3ddcce1992077983d41eecd9195200b6e266fc Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:02:43 +0700 Subject: [PATCH 2/9] Add explicit primary and secondary measurement context --- .../Measurements/SvMeasurementRatioContext.cs | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementRatioContext.cs diff --git a/src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementRatioContext.cs b/src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementRatioContext.cs new file mode 100644 index 0000000..160951d --- /dev/null +++ b/src/ARSVIN.Engine/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 d766268c198c6245b608a2002833acaeacb26502 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:03:15 +0700 Subject: [PATCH 3/9] Add quality and measurement-domain regression tests --- .../SampledValuesQualityAndRatioTests.cs | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs diff --git a/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs b/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs new file mode 100644 index 0000000..0cadf06 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs @@ -0,0 +1,125 @@ +using AR.Iec61850.SampledValues.Measurements; + +namespace ARSVIN.Tests; + +public sealed class SampledValuesQualityAndRatioTests +{ + [Fact] + public void QualityDecoderRecognizesAllZeroQualityAsGood() + { + var state = SvQualityDecoder.DecodeNetworkBytes([0x00, 0x00, 0x00, 0x00]); + + Assert.Equal(SvQualityValidity.Good, state.Validity); + Assert.Equal(SvQualitySeverity.Good, state.Severity); + Assert.True(state.IsStrictlyUsable); + Assert.Equal("Good", state.Summary); + } + + [Fact] + public void QualityDecoderRecognizesInvalidFailureAsBad() + { + // Validity bits 10b = Invalid, plus Failure bit 6. + var state = SvQualityDecoder.DecodeWord(0x0042); + + Assert.Equal(SvQualityValidity.Invalid, state.Validity); + Assert.True(state.Failure); + Assert.Equal(SvQualitySeverity.Bad, state.Severity); + Assert.False(state.IsStrictlyUsable); + Assert.Contains("failure", state.ActiveFlags); + } + + [Fact] + public void QualityDecoderTreatsLegacyDerivedAsInformationNotBad() + { + var state = SvQualityDecoder.DecodeWord(0x2000, allowLegacyDerived: true); + + Assert.True(state.Derived); + Assert.Equal(SvQualityValidity.Good, state.Validity); + Assert.Equal(SvQualitySeverity.Information, state.Severity); + Assert.True(state.IsStrictlyUsable); + } + + [Fact] + public void QualityDecoderRecognizesQuestionableInaccurate() + { + var state = SvQualityDecoder.DecodeWord(0x0203); + + Assert.Equal(SvQualityValidity.Questionable, state.Validity); + Assert.True(state.Inaccurate); + Assert.Equal(SvQualitySeverity.Warning, state.Severity); + Assert.Contains("inaccurate", state.Summary); + } + + [Fact] + public void QualityDecoderHandlesHighAndLowWordWirePlacements() + { + var high = SvQualityDecoder.DecodeNetworkBytes([0x00, 0x40, 0x00, 0x00]); + var low = SvQualityDecoder.DecodeNetworkBytes([0x00, 0x00, 0x00, 0x40]); + + Assert.Equal(SvQualityWordPlacement.HighWord, high.Placement); + Assert.Equal(SvQualityWordPlacement.LowWord, low.Placement); + Assert.True(high.Failure); + Assert.True(low.Failure); + } + + [Fact] + public void QualityDecoderDoesNotGuessConflictingWordPlacement() + { + var state = SvQualityDecoder.DecodeNetworkBytes([0x00, 0x40, 0x00, 0x80]); + + Assert.True(state.IsEncodingAmbiguous); + Assert.Equal(SvQualitySeverity.Unknown, state.Severity); + Assert.Equal(SvQualityWordPlacement.Ambiguous, state.Placement); + } + + [Fact] + public void PrimaryEngineeringValueCanBeConvertedToSecondaryEquivalent() + { + var ratio = new SvMeasurementRatio + { + PrimaryNominal = 1_000, + SecondaryNominal = 1, + Unit = "A", + Source = SvRatioSource.DeviceConfiguration, + Reference = "SMU current input configuration" + }; + + var value = SvMeasurementDomainResolver.Resolve( + 800, + "A", + SvMeasurementValueDomain.PrimaryEngineering, + ratio); + + Assert.Equal(800, value.PrimaryValue); + Assert.Equal(0.8, value.SecondaryEquivalentValue, 9); + Assert.Equal(SvRatioSource.DeviceConfiguration, value.RatioSource); + } + + [Fact] + public void MissingRatioDoesNotInventSecondaryEquivalent() + { + var value = SvMeasurementDomainResolver.Resolve( + 800, + "A", + SvMeasurementValueDomain.PrimaryEngineering, + ratio: null); + + Assert.Equal(800, value.PrimaryValue); + Assert.Null(value.SecondaryEquivalentValue); + Assert.Contains("no verified CT/VT ratio", value.Diagnostic); + } + + [Fact] + public void InvalidRatioIsRejectedWhenConversionIsRequested() + { + var ratio = new SvMeasurementRatio + { + PrimaryNominal = 0, + SecondaryNominal = 1, + Unit = "A" + }; + + Assert.False(ratio.IsValid); + Assert.Throws(() => ratio.PrimaryToSecondary(1)); + } +} From fce29b75c7436f0cc1a7e9b34b2113122259cd9e Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:03:43 +0700 Subject: [PATCH 4/9] Expose semantic quality details in decoded rows --- .../Models/DecodedValueRow.cs | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs b/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs index 4d4a3ec..c4c64ac 100644 --- a/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs +++ b/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs @@ -21,10 +21,34 @@ public sealed class DecodedValueRow 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"; + public bool IsQuality => Kind.Contains("Quality", StringComparison.OrdinalIgnoreCase); + public SvQualityState? QualityState => IsQuality && SvQualityDecoder.TryDecodeHex(Raw, out var quality) + ? quality + : null; + public string QualitySeverity => QualityState?.Severity.ToString() ?? string.Empty; + public string QualityPlacement => QualityState?.Placement.ToString() ?? string.Empty; + + public string DisplayValue + { + get + { + if (QualityState is { } quality) + return quality.Summary; + return HasEngineeringValue + ? $"{EngineeringValue:0.###} {EngineeringUnit}" + : Value; + } + } + + public string ScalingText + { + get + { + if (QualityState is { } quality) + return $"Quality · {quality.Severity} · {quality.Placement}"; + return HasEngineeringValue + ? $"{ScalingConfidence} · {ScalingSource}" + : "Raw counts"; + } + } } From 1aa6680789455380771fa731b219cee34a3492df Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:05:41 +0700 Subject: [PATCH 5/9] Export semantic quality and rolling health evidence --- .../Reporting/SvSubscriberReportBuilder.cs | 80 +++++++++++++++---- 1 file changed, 66 insertions(+), 14 deletions(-) diff --git a/src/ARSVIN.Subscriber/Reporting/SvSubscriberReportBuilder.cs b/src/ARSVIN.Subscriber/Reporting/SvSubscriberReportBuilder.cs index 5bdeb7d..731d900 100644 --- a/src/ARSVIN.Subscriber/Reporting/SvSubscriberReportBuilder.cs +++ b/src/ARSVIN.Subscriber/Reporting/SvSubscriberReportBuilder.cs @@ -1,4 +1,5 @@ using System.Reflection; +using AR.Iec61850.SampledValues.Measurements; using AR.Iec61850.SampledValues.Profiles; using AR.Iec61850.SampledValues.Reporting; using ARSVIN.Subscriber.Models; @@ -87,9 +88,12 @@ private static SvSubscriberStreamEvidence BuildStream( var observationDiagnostics = observation?.Diagnostics ?? stream.ObservationDiagnostics; var profileDetection = observation?.ProfileDetection ?? stream.ProfileDetection; var configurationComparison = observation?.ConfigurationComparison ?? stream.ConfigurationComparison; - var health = ResolveHealth(stream, observation, configurationComparison); - var healthDetail = ResolveHealthDetail(stream, observation, configurationComparison, health); - var diagnostics = stream.Diagnostics.Concat(observationDiagnostics) + var qualityStates = ResolveQualityStates(stream.Values); + var health = ResolveHealth(stream, configurationComparison, qualityStates); + var healthDetail = ResolveHealthDetail(stream, observation, configurationComparison, qualityStates, health); + var diagnostics = stream.Diagnostics + .Concat(observationDiagnostics) + .Concat(BuildQualityDiagnostics(stream.Values)) .Where(item => !string.IsNullOrWhiteSpace(item)) .Distinct(StringComparer.Ordinal) .ToArray(); @@ -129,7 +133,7 @@ private static SvSubscriberStreamEvidence BuildStream( SclMismatchCount = stream.SclMismatchCount, IsWaveformWindowReady = stream.IsWaveformWindowReady, LayoutBinding = stream.LayoutBinding, - QualitySummary = stream.QualitySummary, + QualitySummary = BuildQualitySummary(qualityStates, stream.QualitySummary), CursorSummary = stream.CursorSummary, LastSeen = stream.LastSeen }, @@ -205,43 +209,91 @@ private static int ResolveWindowSamples(SvObservedStreamFacts facts, int fallbac private static string ResolveHealth( SvStreamSnapshot stream, - SvStreamObservationSnapshot? observation, - SvConfigurationComparisonResult? comparison) + SvConfigurationComparisonResult? comparison, + IReadOnlyList qualityStates) { - var hasRuntimeError = stream.PayloadIssueCount > 0 || stream.OutOfOrderCount > 0; - var hasRuntimeWarning = stream.SequenceGapCount > 0 || stream.DuplicateCount > 0; - var isConfigured = observation?.IsBoundToScl ?? stream.IsBoundToScl; - if (hasRuntimeError || comparison?.HasBlockingErrors == true) + var hasQualityBad = qualityStates.Any(item => item.Severity is SvQualitySeverity.Bad or SvQualitySeverity.Unknown); + var hasQualityWarning = qualityStates.Any(item => item.Severity == SvQualitySeverity.Warning); + + if (comparison?.HasBlockingErrors == true || hasQualityBad) return "BAD"; - if (hasRuntimeWarning || comparison?.WarningCount > 0 || !isConfigured) + if (stream.Health == "BAD") + return "BAD"; + if (comparison?.WarningCount > 0 || hasQualityWarning) return "WARN"; - return "GOOD"; + return stream.Health; } private static string ResolveHealthDetail( SvStreamSnapshot stream, SvStreamObservationSnapshot? observation, SvConfigurationComparisonResult? comparison, + IReadOnlyList qualityStates, string health) { var blocking = comparison?.Findings .FirstOrDefault(item => item.Severity == SvConfigurationFindingSeverity.Error); if (blocking is not null) return blocking.Message; - if (stream.PayloadIssueCount > 0 || stream.OutOfOrderCount > 0) + + var badQuality = qualityStates.FirstOrDefault(item => + item.Severity is SvQualitySeverity.Bad or SvQualitySeverity.Unknown); + if (badQuality is not null) + return $"Sample quality requires attention: {badQuality.Summary}."; + + if (stream.Health == "BAD") return stream.HealthDetail; var warning = comparison?.Findings .FirstOrDefault(item => item.Severity == SvConfigurationFindingSeverity.Warning); if (warning is not null) return warning.Message; + + var warningQuality = qualityStates.FirstOrDefault(item => item.Severity == SvQualitySeverity.Warning); + if (warningQuality is not null) + return $"Sample quality warning: {warningQuality.Summary}."; + if (health == "GOOD") - return "SV stream is stable and matches the configured SCL expectation."; + return "SV stream is stable, quality is usable, and the configured expectation has no blocking mismatch."; if (observation?.IsBoundToScl != true) return "SV stream is not bound to an SCL expectation."; return stream.HealthDetail; } + private static IReadOnlyList ResolveQualityStates(IEnumerable values) + => values.Select(value => value.QualityState) + .Where(value => value is not null) + .Select(value => value!) + .ToArray(); + + private static string BuildQualitySummary( + IReadOnlyList states, + string fallback) + { + if (states.Count == 0) + return fallback; + + var good = states.Count(item => item.Severity == SvQualitySeverity.Good); + var information = states.Count(item => item.Severity == SvQualitySeverity.Information); + var warning = states.Count(item => item.Severity == SvQualitySeverity.Warning); + var bad = states.Count(item => item.Severity is SvQualitySeverity.Bad or SvQualitySeverity.Unknown); + return $"Quality channels {states.Count}: good {good}, info {information}, warning {warning}, bad/unknown {bad}"; + } + + private static IEnumerable BuildQualityDiagnostics(IEnumerable values) + { + foreach (var value in values.Where(item => item.IsQuality)) + { + if (value.QualityState is not { } quality) + { + yield return $"QUALITY · {value.Signal} · undecoded · raw {value.Raw}"; + continue; + } + + yield return $"QUALITY · {value.Signal} · {quality.Severity} · {quality.Summary} · placement {quality.Placement} · raw {value.Raw}"; + } + } + private static double ResolveWindowDuration(SvObservedStreamFacts facts, double fallback) { if (facts.FirstTimestamp.HasValue && facts.LastTimestamp.HasValue) From f4bb9a71b9a098a5412b422b43632d5398162b71 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:07:04 +0700 Subject: [PATCH 6/9] Integrate semantic quality into live stream health --- .../ViewModels/SvStreamViewModel.cs | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs index 8e5817e..3fd00e7 100644 --- a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs @@ -1,3 +1,4 @@ +using AR.Iec61850.SampledValues.Measurements; using AR.Iec61850.SampledValues.Profiles; using ARSVIN.Subscriber.Models; @@ -99,26 +100,33 @@ public void Apply(SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observ Gap = $"avg {snapshot.AverageFrameGapMilliseconds:0.###} ms / max {snapshot.MaxFrameGapMilliseconds:0.###} ms"; DataSet = string.IsNullOrWhiteSpace(snapshot.DataSet) ? "-" : snapshot.DataSet; CursorSummary = snapshot.CursorSummary; - QualitySummary = snapshot.QualitySummary; Scaling = snapshot.ScalingSummary; Timebase = BuildTimebaseText(snapshot); + var qualityStates = ResolveQualityStates(snapshot.Values); + QualitySummary = BuildQualitySummary(qualityStates, snapshot.QualitySummary); + var comparison = observation?.ConfigurationComparison; var configurationIssues = comparison?.Findings.Count ?? 0; + var qualityIssues = qualityStates.Count(item => + item.Severity is SvQualitySeverity.Warning or SvQualitySeverity.Bad or SvQualitySeverity.Unknown); var issueTotal = snapshot.SequenceGapCount + snapshot.DuplicateCount + snapshot.OutOfOrderCount + - snapshot.PayloadIssueCount + configurationIssues; + snapshot.PayloadIssueCount + configurationIssues + qualityIssues; Issues = issueTotal == 0 ? "0" - : $"{issueTotal} (gap {snapshot.SequenceGapCount}, dup {snapshot.DuplicateCount}, order {snapshot.OutOfOrderCount}, payload {snapshot.PayloadIssueCount}, SCL {configurationIssues})"; + : $"{issueTotal} (gap {snapshot.SequenceGapCount}, dup {snapshot.DuplicateCount}, order {snapshot.OutOfOrderCount}, payload {snapshot.PayloadIssueCount}, quality {qualityIssues}, SCL {configurationIssues})"; var hasBlockingConfigurationError = comparison?.HasBlockingErrors == true; var hasConfigurationWarning = comparison?.WarningCount > 0; - Health = hasBlockingConfigurationError + var hasQualityBad = qualityStates.Any(item => + item.Severity is SvQualitySeverity.Bad or SvQualitySeverity.Unknown); + var hasQualityWarning = qualityStates.Any(item => item.Severity == SvQualitySeverity.Warning); + Health = hasBlockingConfigurationError || hasQualityBad ? "BAD" - : hasConfigurationWarning && snapshot.Health == "GOOD" + : (hasConfigurationWarning || hasQualityWarning) && snapshot.Health == "GOOD" ? "WARN" : snapshot.Health; - HealthDetail = ResolveHealthDetail(snapshot, observation, Health); + HealthDetail = ResolveHealthDetail(snapshot, observation, qualityStates, Health); var isConfigured = observation?.IsBoundToScl == true; Bound = isConfigured @@ -141,7 +149,7 @@ public void Apply(SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observ _values.ReplaceAll(snapshot.Values.Take(64)); UpdateStableVisuals(snapshot); - _evidenceDetails.ReplaceAll(BuildEvidenceDetails(snapshot, observation)); + _evidenceDetails.ReplaceAll(BuildEvidenceDetails(snapshot, observation, qualityStates)); } private void UpdateStableVisuals(SvStreamSnapshot snapshot) @@ -219,6 +227,7 @@ private static string ConfidenceReason(SvProfileDetectionResult detection) private static string ResolveHealthDetail( SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observation, + IReadOnlyList qualityStates, string health) { var blocking = observation?.ConfigurationComparison?.Findings @@ -226,25 +235,60 @@ private static string ResolveHealthDetail( if (blocking is not null) return blocking.Message; + var badQuality = qualityStates.FirstOrDefault(item => + item.Severity is SvQualitySeverity.Bad or SvQualitySeverity.Unknown); + if (badQuality is not null) + return $"Sample quality requires attention: {badQuality.Summary}."; + var warning = observation?.ConfigurationComparison?.Findings .FirstOrDefault(item => item.Severity == SvConfigurationFindingSeverity.Warning); if (warning is not null && health != "BAD") return warning.Message; + var warningQuality = qualityStates.FirstOrDefault(item => item.Severity == SvQualitySeverity.Warning); + if (warningQuality is not null && health != "BAD") + return $"Sample quality warning: {warningQuality.Summary}."; + return snapshot.HealthDetail; } + private static IReadOnlyList ResolveQualityStates(IEnumerable values) + => values.Select(value => value.QualityState) + .Where(value => value is not null) + .Select(value => value!) + .ToArray(); + + private static string BuildQualitySummary( + IReadOnlyList states, + string fallback) + { + if (states.Count == 0) + return fallback; + + var good = states.Count(item => item.Severity == SvQualitySeverity.Good); + var information = states.Count(item => item.Severity == SvQualitySeverity.Information); + var warning = states.Count(item => item.Severity == SvQualitySeverity.Warning); + var bad = states.Count(item => item.Severity is SvQualitySeverity.Bad or SvQualitySeverity.Unknown); + return $"Quality channels {states.Count}: good {good}, info {information}, warning {warning}, bad/unknown {bad}"; + } + private static IReadOnlyList BuildEvidenceDetails( SvStreamSnapshot snapshot, - SvStreamObservationSnapshot? observation) + SvStreamObservationSnapshot? observation, + IReadOnlyList qualityStates) { 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}." + $"HEALTH · current {snapshot.Health} · session totals gap {snapshot.SequenceGapCount}, duplicate {snapshot.DuplicateCount}, out-of-order {snapshot.OutOfOrderCount}, payload {snapshot.PayloadIssueCount}.", + $"QUALITY · {BuildQualitySummary(qualityStates, snapshot.QualitySummary)}" }; + lines.AddRange(snapshot.Values + .Where(value => value.IsQuality && value.QualityState is not null) + .Select(value => $"QUALITY · {value.Signal} · {value.QualityState!.Severity} · {value.QualityState.Summary} · placement {value.QualityState.Placement} · raw {value.Raw}")); + if (observation is null) { lines.Add("No observation evidence is available yet."); From ec39c88e2c70fb595e010f3429ece1996de84a9b Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:07:38 +0700 Subject: [PATCH 7/9] Add real-device SV evidence pack workflow --- ...ampled-values-real-device-evidence-pack.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/sampled-values-real-device-evidence-pack.md diff --git a/docs/sampled-values-real-device-evidence-pack.md b/docs/sampled-values-real-device-evidence-pack.md new file mode 100644 index 0000000..78e0734 --- /dev/null +++ b/docs/sampled-values-real-device-evidence-pack.md @@ -0,0 +1,100 @@ +# Sampled Values Real-Device Evidence Pack + +This document defines the minimum evidence needed before ARSVIN promotes a Sampled Values behavior from structural inference to device-observed or lab-interoperable status. + +## Required inputs + +Each evidence pack should contain sanitized, mutually consistent artifacts: + +- device and firmware identity recorded outside protocol rules; +- SCD, CID, ICD, or IID file used for the test; +- zero-input PCAP; +- known current and voltage injection PCAPs; +- nominal system frequency; +- explicit CT, VT, or sensor conversion context; +- test-set values and tolerances; +- time-synchronization state; +- capture adapter and operating-system details; +- expected decoding, scaling, quality, continuity, RMS, and phasor results. + +Device identity is evidence metadata. It must not become a hidden scaling or payload-layout rule. + +## Capture cases + +A first protection-stream pack should include: + +1. zero input; +2. balanced nominal current; +3. balanced nominal voltage; +4. unbalanced phase values; +5. phase-angle reference cases; +6. normal sample-counter wrap; +7. one dropped sample; +8. one duplicate sample; +9. one deliberately late or out-of-order sample; +10. publisher restart or configuration-revision transition; +11. good, questionable, invalid, test, substituted, blocked, and derived quality cases where the device supports them; +12. synchronized and unsynchronized operation; +13. matching and intentionally mismatching SCL. + +## Expected machine-readable files + +```text +evidence// + source-register.md + device-context.json + expected-stream.json + expected-values.json + expected-quality.json + expected-anomalies.json + zero-input.pcap + known-current.pcap + known-voltage.pcap + sanitized-system.scd +``` + +Private captures may remain outside the public repository. A synthetic public fixture must reproduce every behavior protected by automated tests. + +## Value domains + +Expected values must state their domain explicitly: + +- raw protocol count; +- primary engineering value; +- secondary-equivalent value; +- injected test-set value. + +No primary-to-secondary conversion is valid without an explicit positive ratio and provenance. The report must record the ratio source and reference. + +## Quality evidence + +Quality is recorded per channel with: + +- raw bytes; +- selected 16-bit word placement; +- validity; +- detail flags; +- process or substituted source; +- test state; +- operator-blocked state; +- profile-specific derived extension where applicable; +- resulting severity and calculation usability. + +Ambiguous high-word versus low-word encoding is reported as unknown instead of guessed. + +## Acceptance criteria + +A real-device pack passes when: + +- payload decoding is byte exact; +- channel order agrees with the bound configuration or verified profile; +- scaling provenance is explicit; +- primary and secondary values agree with the stated context and combined equipment tolerance; +- quality flags produce the expected semantic state; +- normal counter wrap is not reported as an anomaly; +- injected gaps, duplicates, and out-of-order transitions are detected; +- zero input is not visually presented as a full-scale signal; +- report JSON and Markdown preserve all relevant provenance and diagnostics; +- clean traffic produces no false blocking health state. + +Passing these criteria is engineering interoperability evidence, not an accredited conformance certificate. From 02314957c6cc42cf5d56f6f2464ca27ff71014a0 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:08:53 +0700 Subject: [PATCH 8/9] Harden nullable measurement assertions --- tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs b/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs index 0cadf06..41cfcf4 100644 --- a/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs +++ b/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs @@ -90,8 +90,10 @@ public void PrimaryEngineeringValueCanBeConvertedToSecondaryEquivalent() SvMeasurementValueDomain.PrimaryEngineering, ratio); - Assert.Equal(800, value.PrimaryValue); - Assert.Equal(0.8, value.SecondaryEquivalentValue, 9); + Assert.True(value.PrimaryValue.HasValue); + Assert.True(value.SecondaryEquivalentValue.HasValue); + Assert.Equal(800.0, value.PrimaryValue.Value, 9); + Assert.Equal(0.8, value.SecondaryEquivalentValue.Value, 9); Assert.Equal(SvRatioSource.DeviceConfiguration, value.RatioSource); } @@ -104,7 +106,8 @@ public void MissingRatioDoesNotInventSecondaryEquivalent() SvMeasurementValueDomain.PrimaryEngineering, ratio: null); - Assert.Equal(800, value.PrimaryValue); + Assert.True(value.PrimaryValue.HasValue); + Assert.Equal(800.0, value.PrimaryValue.Value, 9); Assert.Null(value.SecondaryEquivalentValue); Assert.Contains("no verified CT/VT ratio", value.Diagnostic); } From ff527ae725eccbb94e98c9044007a94091f65f36 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:11:59 +0700 Subject: [PATCH 9/9] Import xUnit attributes for quality tests --- tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs b/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs index 41cfcf4..831dbc1 100644 --- a/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs +++ b/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs @@ -1,4 +1,5 @@ using AR.Iec61850.SampledValues.Measurements; +using Xunit; namespace ARSVIN.Tests;