-
Notifications
You must be signed in to change notification settings - Fork 0
P0: adaptive multivendor SV measurement foundation #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
4a74053
Add provenance-aware SV engineering scale resolver
masarray a48f29b
Add frequency-neutral SV timebase resolver
masarray 2a6107e
Add robust Sampled Values counter transition tracker
masarray a906bd0
Add deterministic tests for SV scaling, timebase and smpCnt handling
masarray 3bedbde
Expose raw and provenance-aware engineering SV values
masarray 40e7fea
Show engineering units and validity on SV phasors
masarray b806a2e
Add scaling and timebase evidence to subscriber snapshots
masarray e13d0db
Carry waveform unit and scaling provenance
masarray 9f2bd4c
Fix channel prefix detection in SV scale resolver
masarray 1329251
Keep phasor units explicit when only raw counts are known
masarray 70daa42
Integrate adaptive scaling, timebase and rolling stream health
masarray e9ecac1
Use rolling health and resolved timebase in Subscriber view state
masarray b70a8c0
Fix clipped header and expose health, scaling and timebase evidence
masarray a00492d
Use dynamic units and stable near-zero waveform scaling
masarray 31ded4f
Fix xUnit imports for SV measurement regression tests
masarray File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
172 changes: 172 additions & 0 deletions
172
src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| namespace AR.Iec61850.SampledValues.Measurements; | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| 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 | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| 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; } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| 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 | ||
| } | ||
156 changes: 156 additions & 0 deletions
156
src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| 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; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For any SCL-bound stream whose 64-byte 4I+4U payload uses device-specific raw multipliers, this condition accepts the stream without sampling evidence and the resolver then applies the fixed
0.001/0.01conversions while labelling themSclBacked. In this codebase the SCL payload layout supplies element types and offsets, not a scaling multiplier, so a valid but differently scaled bound stream will display incorrect A/V values as SCL-backed engineering measurements. Require explicit scale/CT/VT context (or keep this path raw/provisional) rather than treating binding alone as scale evidence.AGENTS.md reference: AGENTS.md:L24-L28
Useful? React with 👍 / 👎.