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..0e4d91f
--- /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
+}
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;
+ }
+}
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);
+ }
+}
diff --git a/src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs b/src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs
index 9885714..61f48ec 100644
--- a/src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs
+++ b/src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs
@@ -11,6 +11,10 @@ namespace ARSVIN.Subscriber.Controls;
public sealed class OscilloscopePlot : FrameworkElement
{
private double _cursorFraction = 0.72;
+ private double _voltageScale = 1.0;
+ private double _currentScale = 1.0;
+ private string _voltageUnit = "count";
+ private string _currentUnit = "count";
private bool _dragging;
private INotifyCollectionChanged? _pointsNotifier;
@@ -72,49 +76,68 @@ protected override void OnRender(DrawingContext dc)
return;
}
+ var latest = points[^1];
+ ResetScaleWhenUnitChanges(latest.VoltageUnit, latest.CurrentUnit);
+
const double pad = 10;
const double gap = 12;
var laneHeight = Math.Max(130, (bounds.Height - (pad * 2) - gap) / 2.0);
var voltageRect = new Rect(pad, pad, bounds.Width - pad * 2, laneHeight);
var currentRect = new Rect(pad, pad + laneHeight + gap, bounds.Width - pad * 2, laneHeight);
- DrawLane(dc, voltageRect, "Voltage", "V", points,
+ DrawLane(dc, voltageRect, "Voltage", latest.VoltageUnit, points,
new[]
{
new Trace("Va", p => p.Va, "#EF4444"),
new Trace("Vb", p => p.Vb, "#D97706"),
new Trace("Vc", p => p.Vc, "#2563EB"),
new Trace("Vn", p => p.Vn, "#94A3B8")
- });
+ }, ref _voltageScale);
- DrawLane(dc, currentRect, "Current", "A", points,
+ DrawLane(dc, currentRect, "Current", latest.CurrentUnit, points,
new[]
{
new Trace("Ia", p => p.Ia, "#EF4444"),
new Trace("Ib", p => p.Ib, "#D97706"),
new Trace("Ic", p => p.Ic, "#2563EB"),
new Trace("In", p => p.In, "#94A3B8")
- });
+ }, ref _currentScale);
}
- private void DrawLane(DrawingContext dc, Rect lane, string title, string unit, IReadOnlyList points, IReadOnlyList traces)
+ private void DrawLane(
+ DrawingContext dc,
+ Rect lane,
+ string title,
+ string unit,
+ IReadOnlyList points,
+ IReadOnlyList traces,
+ ref double retainedScale)
{
dc.DrawRoundedRectangle(Brush("#FFFFFF"), Pen("#D8E2EF", 1), lane, 8, 8);
DrawGrid(dc, lane);
- var plot = new Rect(lane.Left + 54, lane.Top + 18, Math.Max(60, lane.Width - 70), Math.Max(40, lane.Height - 36));
- var values = traces.SelectMany(trace => points.Select(trace.Selector)).Where(x => x.HasValue).Select(x => Math.Abs(x!.Value)).ToArray();
- var max = values.DefaultIfEmpty(0).Max();
- if (max <= 0)
- max = 1;
- max *= 1.15;
-
+ var plot = new Rect(lane.Left + 58, lane.Top + 18, Math.Max(60, lane.Width - 74), Math.Max(40, lane.Height - 36));
+ var absoluteValues = traces
+ .SelectMany(trace => points.Select(trace.Selector))
+ .Where(value => value.HasValue)
+ .Select(value => Math.Abs(value!.Value))
+ .ToArray();
+ var observedMax = absoluteValues.DefaultIfEmpty(0).Max();
+ var floor = ResolveScaleFloor(title, unit);
+ var targetScale = Math.Max(floor, observedMax * 1.15);
+ retainedScale = targetScale >= retainedScale
+ ? targetScale
+ : Math.Max(targetScale, retainedScale * 0.92);
+
+ var nearZero = observedMax <= floor * 0.2;
DrawText(dc, title, new Point(lane.Left + 12, lane.Top + 10), 12.5, "#2563EB", FontWeights.SemiBold);
DrawText(dc, unit, new Point(lane.Left + 12, lane.Bottom - 28), 11, "#64748B", FontWeights.Normal);
- DrawText(dc, $"±{max:0.#}", new Point(lane.Left + 12, lane.Top + 31), 10.5, "#94A3B8", FontWeights.Normal);
+ DrawText(dc, $"±{retainedScale:0.###}", new Point(lane.Left + 12, lane.Top + 31), 10.5, "#94A3B8", FontWeights.Normal);
+ if (nearZero)
+ DrawText(dc, "near zero", new Point(lane.Left + 12, lane.Top + 48), 9.8, "#94A3B8", FontWeights.Normal);
foreach (var trace in traces)
- DrawTrace(dc, plot, points, trace, max);
+ DrawTrace(dc, plot, points, trace, retainedScale);
var cursorX = plot.Left + plot.Width * _cursorFraction;
var cursorPen = Pen("#0EA5E9", 1.2, 4, 5);
@@ -130,9 +153,33 @@ private void DrawLane(DrawingContext dc, Rect lane, string title, string unit, I
}
}
+ private void ResetScaleWhenUnitChanges(string voltageUnit, string currentUnit)
+ {
+ if (!string.Equals(_voltageUnit, voltageUnit, StringComparison.Ordinal))
+ {
+ _voltageUnit = voltageUnit;
+ _voltageScale = ResolveScaleFloor("Voltage", voltageUnit);
+ }
+
+ if (!string.Equals(_currentUnit, currentUnit, StringComparison.Ordinal))
+ {
+ _currentUnit = currentUnit;
+ _currentScale = ResolveScaleFloor("Current", currentUnit);
+ }
+ }
+
+ private static double ResolveScaleFloor(string title, string unit)
+ {
+ if (unit.Equals("V", StringComparison.OrdinalIgnoreCase))
+ return 0.5;
+ if (unit.Equals("A", StringComparison.OrdinalIgnoreCase))
+ return 0.02;
+ return title.Equals("Voltage", StringComparison.OrdinalIgnoreCase) ? 100.0 : 10.0;
+ }
+
private static void DrawGrid(DrawingContext dc, Rect lane)
{
- var plot = new Rect(lane.Left + 54, lane.Top + 18, Math.Max(60, lane.Width - 70), Math.Max(40, lane.Height - 36));
+ var plot = new Rect(lane.Left + 58, lane.Top + 18, Math.Max(60, lane.Width - 74), Math.Max(40, lane.Height - 36));
var minor = Pen("#E8EEF7", 0.85, 2, 6);
var major = Pen("#D6E0EC", 0.95, 4, 6);
for (var i = 0; i <= 8; i++)
@@ -148,14 +195,22 @@ private static void DrawGrid(DrawingContext dc, Rect lane)
}
}
- private static void DrawTrace(DrawingContext dc, Rect plot, IReadOnlyList points, Trace trace, double scale)
+ private static void DrawTrace(
+ DrawingContext dc,
+ Rect plot,
+ IReadOnlyList points,
+ Trace trace,
+ double scale)
{
- var usable = points.Select((point, index) => (value: trace.Selector(point), index)).Where(x => x.value.HasValue).ToArray();
- if (usable.Length < 2)
+ var usable = points
+ .Select((point, index) => (value: trace.Selector(point), index))
+ .Where(item => item.value.HasValue)
+ .ToArray();
+ if (usable.Length < 2 || scale <= 0)
return;
var geometry = new StreamGeometry();
- using (var ctx = geometry.Open())
+ using (var context = geometry.Open())
{
for (var i = 0; i < usable.Length; i++)
{
@@ -164,11 +219,12 @@ private static void DrawTrace(DrawingContext dc, Rect plot, IReadOnlyList
+
+
+
+
-
+
@@ -24,20 +45,21 @@
-
-
+
+
-
+
+
+
-
-
-
+
-
-
+
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -97,16 +134,7 @@
-
-
-
-
-
-
-
-
-
-
+
@@ -148,7 +176,8 @@
CornerRadius="8"
Background="#FFFFFF"
BorderBrush="#D8E2EF"
- BorderThickness="1">
+ BorderThickness="1"
+ ToolTip="{Binding HealthDetail}">
@@ -162,7 +191,7 @@
-
+
@@ -183,7 +212,7 @@
-
+
@@ -197,10 +226,10 @@
-
+
-
+
@@ -210,28 +239,40 @@
+
+
+
+
-
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
@@ -305,7 +346,7 @@
-
+
@@ -323,10 +364,11 @@
-
-
-
-
+
+
+
+
+
@@ -351,4 +393,4 @@
-
\ No newline at end of file
+
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";
+}
diff --git a/src/ARSVIN.Subscriber/Models/PhasorVector.cs b/src/ARSVIN.Subscriber/Models/PhasorVector.cs
index 79fedf4..78ddc06 100644
--- a/src/ARSVIN.Subscriber/Models/PhasorVector.cs
+++ b/src/ARSVIN.Subscriber/Models/PhasorVector.cs
@@ -4,10 +4,13 @@ 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 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 RmsText => IsValid ? $"{Rms:0.###} {Unit}".TrimEnd() : "invalid";
+ public string PeakText => IsValid ? $"{Peak:0.###} {Unit}".TrimEnd() : "invalid";
+ public string AngleText => IsValid ? $"{AngleDegrees:0.0}°" : "—";
}
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++;
- }
}
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; }
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; }
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}.");
diff --git a/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs b/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs
new file mode 100644
index 0000000..d120bbb
--- /dev/null
+++ b/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs
@@ -0,0 +1,138 @@
+using AR.Iec61850.SampledValues.Measurements;
+using Xunit;
+
+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);
+ }
+}