From 0768e8644b70aa1fcfae88079c933a3f5cd2bddc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:46:32 +0700 Subject: [PATCH 1/8] fix(sv): explicit per-stream measurement scaling, safe raw defaults and truthful power --- .../Controls/WaveformPanelControl.cs | 4 +- src/ProcessBus.App.Wpf/MainWindow.xaml | 12 ++++ .../ViewModels/MainWindowViewModel.cs | 71 ++++++++++++++++++- .../Models/SvEngineeringScale.cs | 30 ++++++++ .../Services/IRawCaptureDataSource.cs | 1 + .../Analysis/RawProcessBusAnalyzer.cs | 53 ++++++++++++-- .../Live/RawAnalyzerDataSource.cs | 5 ++ tests/ProcessBus.Tests/ScopeStabilityTests.cs | 57 +++++++++++++++ 8 files changed, 222 insertions(+), 11 deletions(-) create mode 100644 src/ProcessBus.Core/Models/SvEngineeringScale.cs diff --git a/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs b/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs index ddc1dc2e..3689426d 100644 --- a/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs +++ b/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs @@ -214,9 +214,9 @@ protected override void OnRender(DrawingContext dc) var visibleLanes = new List(2); if (showVoltage) - visibleLanes.Add(new LaneDefinition("Voltage", "V", snapshot.VoltageSeries)); + visibleLanes.Add(new LaneDefinition("Voltage", snapshot.VoltageSeries.FirstOrDefault()?.Unit ?? "counts", snapshot.VoltageSeries)); if (showCurrent) - visibleLanes.Add(new LaneDefinition("Current", "A", snapshot.CurrentSeries)); + visibleLanes.Add(new LaneDefinition("Current", snapshot.CurrentSeries.FirstOrDefault()?.Unit ?? "counts", snapshot.CurrentSeries)); if (visibleLanes.Count == 0) { diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 3f1b4661..7685587c 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -2413,6 +2413,18 @@ + + + + + + _streamWorkspaces = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _explicitSvScales = new(StringComparer.OrdinalIgnoreCase); + private const string RawScaleText = "Raw counts (uncalibrated)"; + private const string NominalScaleText = "4I+4V nominal (1 mA / 10 mV per count)"; private string _waveformShowMode = "Both"; private string _waveformLayoutMode = "Overlay"; private string _waveformTimebase = "2 cycles"; @@ -729,6 +732,9 @@ public SvStreamItem? SelectedStream BeginWorkspaceTargetTransition(); _selectedStream = value; RequestRawStreamSelection(value?.StreamId); + ApplySelectedStreamScale(value?.StreamId); + OnPropertyChanged(nameof(SelectedEngineeringScale)); + OnPropertyChanged(nameof(EngineeringScaleExplanation)); RestoreSelectedStreamWorkspace(value); OnPropertyChanged(); OnPropertyChanged(nameof(AnalogValues)); @@ -843,6 +849,48 @@ public int CurrentWorkspaceTabIndex public IReadOnlyList WaveformTimebaseOptions { get; } = ["1 cycle", "2 cycles", "4 cycles", "8 cycles"]; public IReadOnlyList WaveformModeOptions { get; } = ["Locked"]; public IReadOnlyList WaveformMarkerOptions { get; } = ["On", "Off"]; + public IReadOnlyList EngineeringScaleOptions { get; } = [RawScaleText, NominalScaleText]; + public string SelectedEngineeringScale + { + get => SelectedStream is not null && + _explicitSvScales.TryGetValue(SelectedStream.StreamId, out var mode) && + mode == SvEngineeringScale.Nominal4I4V ? NominalScaleText : RawScaleText; + set + { + if (SelectedStream is null || (value != RawScaleText && value != NominalScaleText)) + return; + var next = value == NominalScaleText ? SvEngineeringScale.Nominal4I4V : SvEngineeringScale.RawCounts; + var key = SelectedStream.StreamId; + if (_explicitSvScales.TryGetValue(key, out var previous) ? previous == next : next == SvEngineeringScale.RawCounts) + return; + + if (next == SvEngineeringScale.RawCounts) _explicitSvScales.Remove(key); + else _explicitSvScales[key] = next; + ApplySelectedStreamScale(key); + // No stale physical metering, vectors, waveform, or wrong-stream cached state. + _streamWorkspaces.Remove(key); + RestoreSelectedStreamWorkspace(SelectedStream); + OnPropertyChanged(); + OnPropertyChanged(nameof(EngineeringScaleExplanation)); + RaiseAnalyzerRenderProperties(); + OnPropertyChanged(nameof(EvidenceSnapshotText)); + } + } + + public string EngineeringScaleExplanation => SelectedStream is null + ? "Select an SV stream to see its measurement units." + : SelectedEngineeringScale == NominalScaleText + ? "Operator-selected 4I+4V nominal scaling; not calibrated or independently verified. Applies to this stream only." + : "Uncalibrated raw counts: waveform/angles remain available; physical power is unavailable."; + + private void ApplySelectedStreamScale(string? streamId) + { + if (string.IsNullOrWhiteSpace(streamId)) return; + var mode = _explicitSvScales.TryGetValue(streamId, out var selected) + ? selected : SvEngineeringScale.RawCounts; + _rawDataSource.SetSvEngineeringScale(streamId, mode); + } + public IReadOnlyList WaveformVoltageScaleOptions { get; } = [ "Auto", @@ -1266,7 +1314,7 @@ public string PowerFactorText get { var power = ComputeThreePhasePower(); - if (power.S <= 0) + if (!double.IsFinite(power.S) || power.S <= 0) return "N/A"; return $"{Math.Clamp(power.P / power.S, -1.0, 1.0):0.000}"; @@ -2077,7 +2125,10 @@ private void SyncUiSelectionToRuntimeStream(string? streamId) return; _selectedStream = row; + ApplySelectedStreamScale(row.StreamId); OnPropertyChanged(nameof(SelectedStream)); + OnPropertyChanged(nameof(SelectedEngineeringScale)); + OnPropertyChanged(nameof(EngineeringScaleExplanation)); } private void RestoreSelectedStreamWorkspace(SvStreamItem? stream) @@ -2392,6 +2443,8 @@ private void RaiseAnalyzerRenderProperties() OnPropertyChanged(nameof(SvSemanticChannelSummaryText)); OnPropertyChanged(nameof(SvSemanticChannelCompactText)); OnPropertyChanged(nameof(AnalogValues)); + OnPropertyChanged(nameof(SelectedEngineeringScale)); + OnPropertyChanged(nameof(EngineeringScaleExplanation)); OnPropertyChanged(nameof(TotalActivePowerText)); OnPropertyChanged(nameof(TotalReactivePowerText)); OnPropertyChanged(nameof(TotalApparentPowerText)); @@ -3688,6 +3741,10 @@ private string BuildEvidenceSnapshotText() builder.AppendLine(); builder.AppendLine("[Observed SV]"); builder.AppendLine($"Stream status: {Diagnostics.StreamStatusText}"); + builder.AppendLine($"Engineering scaling: {SelectedEngineeringScale}"); + builder.AppendLine(EngineeringScaleExplanation); + builder.AppendLine("Scaling authority: operator explicit selection only; SV packet shape/svID is not calibration evidence."); + builder.AppendLine($"RMS Ia: {AnalogValues.Ia.RmsDisplayText}; Ua: {AnalogValues.Ua.RmsDisplayText}; P: {TotalActivePowerText}; Q: {TotalReactivePowerText}; S: {TotalApparentPowerText}; PF: {PowerFactorText}"); builder.AppendLine($"svID: {selectedStream?.SvId ?? "N/A"}"); builder.AppendLine($"APPID: {selectedStream?.AppId ?? "N/A"}"); builder.AppendLine($"Source MAC: {selectedStream?.SourceMac ?? "N/A"}"); @@ -3932,6 +3989,11 @@ private static IReadOnlyList BuildPhasorItems(AnalogValuesSna private (double P, double Q, double S) ComputeThreePhasePower() { + // Physical watts/vars require explicit valid scale authority for BOTH + // quantities. Raw INT32 sample counts must NEVER be multiplied as A/V. + if (SelectedEngineeringScale != NominalScaleText) + return (double.NaN, double.NaN, double.NaN); + var phases = new[] { (U: AnalogValues.Ua, I: AnalogValues.Ia), @@ -3945,8 +4007,10 @@ private static IReadOnlyList BuildPhasorItems(AnalogValuesSna foreach (var phase in phases) { - if (!phase.U.RmsValue.HasValue || !phase.I.RmsValue.HasValue) - continue; + if (phase.U.Unit != "V" || phase.I.Unit != "A" || + !phase.U.RmsValue.HasValue || !phase.I.RmsValue.HasValue || + !phase.U.AngleDegrees.HasValue || !phase.I.AngleDegrees.HasValue) + return (double.NaN, double.NaN, double.NaN); var apparent = Math.Abs(phase.U.RmsValue.Value * phase.I.RmsValue.Value); s += apparent; @@ -3964,6 +4028,7 @@ private static IReadOnlyList BuildPhasorItems(AnalogValuesSna private static string FormatPower(double value, string unit) { + if (!double.IsFinite(value)) return "N/A (scaling not verified)"; var abs = Math.Abs(value); if (abs >= 1_000_000.0) return $"{value / 1_000_000.0:0.00} M{unit}"; diff --git a/src/ProcessBus.Core/Models/SvEngineeringScale.cs b/src/ProcessBus.Core/Models/SvEngineeringScale.cs new file mode 100644 index 00000000..9e617413 --- /dev/null +++ b/src/ProcessBus.Core/Models/SvEngineeringScale.cs @@ -0,0 +1,30 @@ +namespace ProcessBus.Core.Models; + +/// +/// Explicit scale authority for a selected SV stream. Packet shape, svID, APPID, +/// and a missing SCL are NOT evidence of engineering calibration. +/// +public enum SvEngineeringScale +{ + RawCounts = 0, + Nominal4I4V = 1 +} + +public static class SvEngineeringScaleFactors +{ + // This explicitly opted-in 4I+4V lab/nominal profile is 1 mA per + // current count and 10 mV per voltage count. It is NOT universal + // IEC 61850 SV scaling and must never be auto-selected by svID. + public static double Factor(SvEngineeringScale mode, string channel) + => mode == SvEngineeringScale.Nominal4I4V + ? (channel.StartsWith("U", StringComparison.OrdinalIgnoreCase) ? 0.01 : 0.001) + : 1.0; + + public static string Unit(SvEngineeringScale mode, string channel) + => mode != SvEngineeringScale.Nominal4I4V + ? "counts" + : channel.StartsWith("U", StringComparison.OrdinalIgnoreCase) ? "V" : "A"; + + public static bool IsPhysical(SvEngineeringScale mode) + => mode == SvEngineeringScale.Nominal4I4V; +} diff --git a/src/ProcessBus.Core/Services/IRawCaptureDataSource.cs b/src/ProcessBus.Core/Services/IRawCaptureDataSource.cs index 1648aa18..15df24db 100644 --- a/src/ProcessBus.Core/Services/IRawCaptureDataSource.cs +++ b/src/ProcessBus.Core/Services/IRawCaptureDataSource.cs @@ -8,6 +8,7 @@ public interface IRawCaptureDataSource : IAnalyzerDataSource void SelectAdapter(string adapterId, string rawDeviceName); void SelectStream(string? streamId); void SetScopeCycles(int cycles); + void SetSvEngineeringScale(string streamId, SvEngineeringScale scale); void SetSvChannelMappings(IReadOnlyList profiles); Task StartAsync(CancellationToken cancellationToken = default); Task StopAsync(CancellationToken cancellationToken = default); diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 5747c0db..d53937b4 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -15,6 +15,8 @@ public sealed class RawProcessBusAnalyzer private static readonly TimeSpan JitterEventInterval = TimeSpan.FromSeconds(5); private readonly object _gate = new(); private readonly Dictionary _svStreams = new(StringComparer.OrdinalIgnoreCase); + // Per-stream operator evidence, never inferred from svID, APPID or payload count. + private readonly Dictionary _svScales = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _gooseStates = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _aggregatedEvents = new(StringComparer.OrdinalIgnoreCase); private readonly PtpHealthTracker _ptpTracker = new(); @@ -141,6 +143,7 @@ public void Reset() lock (_gate) { _svStreams.Clear(); + _svScales.Clear(); _gooseStates.Clear(); _events.Clear(); _aggregatedEvents.Clear(); @@ -167,6 +170,23 @@ public void SelectStream(string? streamId) _selectedSvKey = string.IsNullOrWhiteSpace(streamId) ? null : streamId; } + public void SetSvEngineeringScale(string streamId, SvEngineeringScale scale) + { + if (string.IsNullOrWhiteSpace(streamId) || !Enum.IsDefined(scale)) + return; + + lock (_gate) + { + if (scale == SvEngineeringScale.RawCounts) + _svScales.Remove(streamId); + else + _svScales[streamId] = scale; + + if (_svStreams.TryGetValue(streamId, out var stream)) + stream.SetEngineeringScale(scale); + } + } + public void SetScopeCycles(int cycles) { var clamped = Math.Clamp(cycles, 1, 8); @@ -220,6 +240,8 @@ private void ObserveSampledValues(SampledValuePacket packet) if (!_svStreams.TryGetValue(key, out var state)) { state = new SvStreamState(key, ++_svFirstSeenSequence, _requestedScopeCycles); + if (_svScales.TryGetValue(key, out var scale)) + state.SetEngineeringScale(scale); _svStreams[key] = state; AddEvent("Info", $"Raw SV stream detected: {DescribeSv(packet.Frame, asdu)}."); } @@ -802,6 +824,17 @@ private static readonly (string Channel, int ElementIndex)[] OmicronInstMagQuali private long? _previousCaptureTicks; private string? _pendingJitterEvidence; private string? _activeMappingSignature; + private SvEngineeringScale _engineeringScale = SvEngineeringScale.RawCounts; + + public void SetEngineeringScale(SvEngineeringScale scale) + { + if (_engineeringScale == scale) return; + _engineeringScale = scale; + // Raw ring buffers, phase and sequence evidence are unchanged. + // Drop only the previously projected coherent display snapshots. + _lastAnalogValues = null; + _lastScopeWaveform = null; + } public SvStreamState(string key, int firstSeenOrder, int scopeCycles) { @@ -960,7 +993,10 @@ public StreamDetailsModel ToDetails() ConfRevText = ConfRev?.ToString() ?? "N/A", SampleValueMappingText = MappingProfileName, SampleValueCountText = DecodedElementCount > 0 ? $"{DecodedElementCount} element(s)" : "0", - MappedChannelNamesText = MappedChannelNamesText, + MappedChannelNamesText = MappedChannelNamesText + + (_engineeringScale == SvEngineeringScale.Nominal4I4V + ? " | Engineering units: operator-selected nominal 4I+4V (I=0.001 A/count, U=0.01 V/count)" + : " | Engineering units: RAW COUNTS ONLY; calibration unverified"), RawValuesText = RawValuesText, PacketEvidenceText = BuildPacketEvidenceText(), TimebaseStatusText = "Raw capture monotonic clock", @@ -1218,10 +1254,13 @@ private ChannelValueModel CreateChannelValue( IReadOnlyList coherentWindow, int samplesPerCycle) { - var unit = channel.StartsWith("U", StringComparison.OrdinalIgnoreCase) ? "V" : "A"; - var instant = GetLatestScopeValue(channel); + var unit = SvEngineeringScaleFactors.Unit(_engineeringScale, channel); + var factor = SvEngineeringScaleFactors.Factor(_engineeringScale, channel); + var rawInstant = GetLatestScopeValue(channel); + var instant = rawInstant.HasValue ? rawInstant.Value * factor : (double?)null; var rawAngle = GetFundamentalAngleDegrees(coherentWindow, channel, samplesPerCycle); - var rms = GetRms(coherentWindow, channel); + var rawRms = GetRms(coherentWindow, channel); + var rms = rawRms.HasValue ? rawRms.Value * factor : (double?)null; var angle = ResolveDisplayAngle(channel, rawAngle, reference); return new ChannelValueModel(channel) @@ -1283,8 +1322,10 @@ private IReadOnlyList CreateLiveWaveformSeries( result.Add(new WaveformSeriesModel { Name = channel, - Unit = channel.StartsWith("U", StringComparison.OrdinalIgnoreCase) ? "V" : "A", - Samples = scopeWindow, + Unit = SvEngineeringScaleFactors.Unit(_engineeringScale, channel), + Samples = _engineeringScale == SvEngineeringScale.RawCounts + ? scopeWindow + : scopeWindow.Select(value => value * SvEngineeringScaleFactors.Factor(_engineeringScale, channel)).ToArray(), ShapeSeverity = shape.Severity, ShapeStatusText = shape.StatusText, ShapeResidualPercent = shape.ResidualPercent, diff --git a/src/ProcessBus.Iec61850.Raw/Live/RawAnalyzerDataSource.cs b/src/ProcessBus.Iec61850.Raw/Live/RawAnalyzerDataSource.cs index eb75875d..51a75d96 100644 --- a/src/ProcessBus.Iec61850.Raw/Live/RawAnalyzerDataSource.cs +++ b/src/ProcessBus.Iec61850.Raw/Live/RawAnalyzerDataSource.cs @@ -52,6 +52,11 @@ public void SetScopeCycles(int cycles) _analyzer.SetScopeCycles(cycles); } + public void SetSvEngineeringScale(string streamId, SvEngineeringScale scale) + { + _analyzer.SetSvEngineeringScale(streamId, scale); + } + public void SetSvChannelMappings(IReadOnlyList profiles) { _analyzer.SetSvChannelMappings(profiles); diff --git a/tests/ProcessBus.Tests/ScopeStabilityTests.cs b/tests/ProcessBus.Tests/ScopeStabilityTests.cs index edcd9862..89587ea7 100644 --- a/tests/ProcessBus.Tests/ScopeStabilityTests.cs +++ b/tests/ProcessBus.Tests/ScopeStabilityTests.cs @@ -64,6 +64,63 @@ private static IReadOnlyList UaSamples(RawProcessBusAnalyzer analyzer) } + [Fact] + public void EngineeringScale_RawByDefault_RequiresExplicitPerStreamAuthority() + { + var analyzer = FeedSine(800); + var snapshot = analyzer.GetAnalyzerSnapshot(); + var streamId = snapshot.SelectedStreamId; + Assert.NotNull(streamId); + Assert.Equal("counts", snapshot.AnalogValues.Ia.Unit); + Assert.Equal("counts", snapshot.AnalogValues.Ua.Unit); + var rawIa = snapshot.AnalogValues.Ia.RmsValue!.Value; + var rawUa = snapshot.AnalogValues.Ua.RmsValue!.Value; + Assert.InRange(rawIa, 690, 720); + Assert.InRange(rawUa, 5500, 5750); + + analyzer.SetSvEngineeringScale(streamId!, SvEngineeringScale.Nominal4I4V); + var nominal = analyzer.GetAnalyzerSnapshot(); + Assert.Equal("A", nominal.AnalogValues.Ia.Unit); + Assert.Equal("V", nominal.AnalogValues.Ua.Unit); + Assert.InRange(nominal.AnalogValues.Ia.RmsValue!.Value, 0.690, 0.720); + Assert.InRange(nominal.AnalogValues.Ua.RmsValue!.Value, 55, 57.5); + Assert.Equal(rawIa / 1000.0, nominal.AnalogValues.Ia.RmsValue!.Value, 5); + Assert.Equal(rawUa / 100.0, nominal.AnalogValues.Ua.RmsValue!.Value, 5); + var wave = nominal.Waveform; + var amp = wave.VoltageSeries.First(x => x.Name == "Ua"); + Assert.Equal("V", amp.Unit); + Assert.True(amp.Samples.Max() < 81); + Assert.Equal(snapshot.AnalogValues.Ia.AngleDegrees, nominal.AnalogValues.Ia.AngleDegrees); + + analyzer.SetSvEngineeringScale(streamId!, SvEngineeringScale.RawCounts); + var reverted = analyzer.GetAnalyzerSnapshot(); + Assert.Equal("counts", reverted.AnalogValues.Ua.Unit); + Assert.Equal(rawUa, reverted.AnalogValues.Ua.RmsValue!.Value, 5); + Assert.Equal("counts", reverted.Waveform.VoltageSeries[0].Unit); + } + + [Fact] + public void EngineeringScale_DoesNotLeakToAnotherStrongStream() + { + var analyzer = new RawProcessBusAnalyzer(); + for (var i = 0; i < 800; i++) + { + var values = ChannelValuesAt(i, Math.Sin); + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples((ushort)i, values, "LAB_A", 0x4000)); + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples((ushort)i, values, "LAB_B", 0x4F01)); + } + var streams = analyzer.GetAnalyzerSnapshot().Streams; + var keyA = streams.Single(x => x.SvId == "LAB_A").StreamId; + var keyB = streams.Single(x => x.SvId == "LAB_B").StreamId; + analyzer.SetSvEngineeringScale(keyA, SvEngineeringScale.Nominal4I4V); + analyzer.SelectStream(keyA); + Assert.Equal("A", analyzer.GetAnalyzerSnapshot().AnalogValues.Ia.Unit); + analyzer.SelectStream(keyB); + Assert.Equal("counts", analyzer.GetAnalyzerSnapshot().AnalogValues.Ia.Unit); + analyzer.SelectStream(keyA); + Assert.Equal("A", analyzer.GetAnalyzerSnapshot().AnalogValues.Ia.Unit); + } + [Fact] public void Scope_ArstackPrimaryWithOptionalRateOmitted_RecoversWaveAnd50HzWithoutScl() { From 4d718364b624dab3edf92a74ba4cbc926387680a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:48:21 +0700 Subject: [PATCH 2/8] ci(candidate): verify native Windows bugfix PR builds as distributable candidates --- .github/workflows/candidate-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/candidate-package.yml b/.github/workflows/candidate-package.yml index b82a565f..60e02c61 100644 --- a/.github/workflows/candidate-package.yml +++ b/.github/workflows/candidate-package.yml @@ -36,7 +36,7 @@ env: jobs: candidate: name: Build verified Windows x64 candidate - if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'stabilization/') || startsWith(github.head_ref, 'architecture/') || startsWith(github.head_ref, 'legal/') + if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'stabilization/') || startsWith(github.head_ref, 'architecture/') || startsWith(github.head_ref, 'legal/') || startsWith(github.head_ref, 'fix/') runs-on: windows-latest timeout-minutes: 35 From e9e55fb8f7f4ec19438873b96f063a1fddd6ea47 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:49:09 +0700 Subject: [PATCH 3/8] test(sv): import engineering scale contract and reassert selected authority after capture reset --- src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs | 3 +++ tests/ProcessBus.Tests/ScopeStabilityTests.cs | 1 + 2 files changed, 4 insertions(+) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 30b97c13..3e047504 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -2065,6 +2065,9 @@ private void RequestRawStreamSelection(string? streamId) // first-seen while the UI workspace points elsewhere. Reasserting selection before // each snapshot is cheap and prevents the "phasor updates only after clicking SV" bug. _rawDataSource.SelectStream(streamId); + // Re-assert operator-owned scale after a capture reset/reconnect too. + // This is an idempotent per-stream configuration, not a per-packet action. + ApplySelectedStreamScale(streamId); _lastRequestedRawStreamId = streamId; } diff --git a/tests/ProcessBus.Tests/ScopeStabilityTests.cs b/tests/ProcessBus.Tests/ScopeStabilityTests.cs index 89587ea7..5705415b 100644 --- a/tests/ProcessBus.Tests/ScopeStabilityTests.cs +++ b/tests/ProcessBus.Tests/ScopeStabilityTests.cs @@ -1,3 +1,4 @@ +using ProcessBus.Core.Models; using ProcessBus.Iec61850.Raw.Analysis; using Xunit; From 6b5988e1fe86ac7003b09c2b4ff078306512643c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:52:46 +0700 Subject: [PATCH 4/8] fix(scope): keep raw voltage/current autoranges isolated and prevent fixed A/V axes --- src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs b/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs index 3689426d..629d561b 100644 --- a/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs +++ b/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs @@ -521,13 +521,16 @@ private static double[] BuildDisplaySamples(IReadOnlyList source, int vi private double ResolveLaneAmplitudeMax(LaneDefinition lane, IReadOnlyList series) { - var isVoltage = string.Equals(lane.Unit, "V", StringComparison.OrdinalIgnoreCase); + // A raw-count voltage lane is still the voltage lane. Its history + // must not share the current lane auto-range, nor adopt fixed A/V scales. + var isVoltage = string.Equals(lane.Title, "Voltage", StringComparison.OrdinalIgnoreCase); + var hasPhysicalUnits = string.Equals(lane.Unit, isVoltage ? "V" : "A", StringComparison.OrdinalIgnoreCase); var scaleText = isVoltage ? VoltageScale : CurrentScale; - if (isVoltage && TryParseVoltageScalePeak(scaleText, out var fixedVoltagePeak)) + if (hasPhysicalUnits && isVoltage && TryParseVoltageScalePeak(scaleText, out var fixedVoltagePeak)) return Math.Max(fixedVoltagePeak, 1.0); - if (!isVoltage && TryParseCurrentScalePeak(scaleText, out var fixedCurrentPeak)) + if (hasPhysicalUnits && !isVoltage && TryParseCurrentScalePeak(scaleText, out var fixedCurrentPeak)) return Math.Max(fixedCurrentPeak, 1.0); var observedPeak = ResolveRobustObservedPeak(series); From fd558549373c3c26fade0704960e433369df5fa8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:53:21 +0700 Subject: [PATCH 5/8] refactor(metering): single pure power authority rejects raw counts and partial phases --- .../ViewModels/MainWindowViewModel.cs | 38 ++--------------- .../Models/SvPhysicalPowerMeter.cs | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 35 deletions(-) create mode 100644 src/ProcessBus.Core/Models/SvPhysicalPowerMeter.cs diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 3e047504..36d98070 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -3992,41 +3992,9 @@ private static IReadOnlyList BuildPhasorItems(AnalogValuesSna private (double P, double Q, double S) ComputeThreePhasePower() { - // Physical watts/vars require explicit valid scale authority for BOTH - // quantities. Raw INT32 sample counts must NEVER be multiplied as A/V. - if (SelectedEngineeringScale != NominalScaleText) - return (double.NaN, double.NaN, double.NaN); - - var phases = new[] - { - (U: AnalogValues.Ua, I: AnalogValues.Ia), - (U: AnalogValues.Ub, I: AnalogValues.Ib), - (U: AnalogValues.Uc, I: AnalogValues.Ic) - }; - - var p = 0.0; - var q = 0.0; - var s = 0.0; - - foreach (var phase in phases) - { - if (phase.U.Unit != "V" || phase.I.Unit != "A" || - !phase.U.RmsValue.HasValue || !phase.I.RmsValue.HasValue || - !phase.U.AngleDegrees.HasValue || !phase.I.AngleDegrees.HasValue) - return (double.NaN, double.NaN, double.NaN); - - var apparent = Math.Abs(phase.U.RmsValue.Value * phase.I.RmsValue.Value); - s += apparent; - - if (!phase.U.AngleDegrees.HasValue || !phase.I.AngleDegrees.HasValue) - continue; - - var angle = (phase.U.AngleDegrees.Value - phase.I.AngleDegrees.Value) * Math.PI / 180.0; - p += apparent * Math.Cos(angle); - q += apparent * Math.Sin(angle); - } - - return (p, q, s); + return SvPhysicalPowerMeter.TryCompute(AnalogValues, out var power) + ? (power.P, power.Q, power.S) + : (double.NaN, double.NaN, double.NaN); } private static string FormatPower(double value, string unit) diff --git a/src/ProcessBus.Core/Models/SvPhysicalPowerMeter.cs b/src/ProcessBus.Core/Models/SvPhysicalPowerMeter.cs new file mode 100644 index 00000000..20b1de15 --- /dev/null +++ b/src/ProcessBus.Core/Models/SvPhysicalPowerMeter.cs @@ -0,0 +1,42 @@ +namespace ProcessBus.Core.Models; + +// Physical watts/vars require real A/V units for every phase in one +// selected-stream coherent snapshot. Raw INT32 counts never qualify. +public readonly record struct SvPhysicalPowerTotals(double P, double Q, double S); + +public static class SvPhysicalPowerMeter +{ + public static bool TryCompute(AnalogValuesSnapshot snapshot, out SvPhysicalPowerTotals totals) + { + totals = default; + if (snapshot is null) return false; + var phases = new[] + { + (U: snapshot.Ua, I: snapshot.Ia), + (U: snapshot.Ub, I: snapshot.Ib), + (U: snapshot.Uc, I: snapshot.Ic) + }; + var p = 0.0; + var q = 0.0; + var s = 0.0; + foreach (var (u, i) in phases) + { + if (u.Unit != "V" || i.Unit != "A" || + u.RmsValue is not { } uRms || i.RmsValue is not { } iRms || + u.AngleDegrees is not { } uAngle || i.AngleDegrees is not { } iAngle || + !double.IsFinite(uRms) || !double.IsFinite(iRms) || + !double.IsFinite(uAngle) || !double.IsFinite(iAngle) || + uRms < 0 || iRms < 0) + return false; + + var apparent = uRms * iRms; + var delta = (uAngle - iAngle) * Math.PI / 180.0; + p += apparent * Math.Cos(delta); + q += apparent * Math.Sin(delta); + s += apparent; + } + if (!double.IsFinite(p) || !double.IsFinite(q) || !double.IsFinite(s)) return false; + totals = new SvPhysicalPowerTotals(p, q, s); + return true; + } +} From 9ae894ea7ab6850f573c14637c70b4a7bb97d7fb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:53:47 +0700 Subject: [PATCH 6/8] test(metering): prohibit bogus megawatts and prove 1A/57.74V 30-degree three-phase totals --- tests/ProcessBus.Tests/ScopeStabilityTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/ProcessBus.Tests/ScopeStabilityTests.cs b/tests/ProcessBus.Tests/ScopeStabilityTests.cs index 5705415b..ac27605f 100644 --- a/tests/ProcessBus.Tests/ScopeStabilityTests.cs +++ b/tests/ProcessBus.Tests/ScopeStabilityTests.cs @@ -65,6 +65,40 @@ private static IReadOnlyList UaSamples(RawProcessBusAnalyzer analyzer) } + [Fact] + public void PhysicalPower_RejectsRawCountsAndRequiresAllThreeCalibratedPhases() + { + static ChannelValueModel C(string name, double rms, double angle, string unit) => + new(name) { RmsValue = rms, AngleDegrees = angle, Unit = unit }; + + var raw = new AnalogValuesSnapshot + { + Ia = C("Ia", 1000, -30, "counts"), Ua = C("Ua", 5774, 0, "counts"), + Ib = C("Ib", 1000, -150, "counts"), Ub = C("Ub", 5774, -120, "counts"), + Ic = C("Ic", 1000, 90, "counts"), Uc = C("Uc", 5774, 120, "counts") + }; + Assert.False(SvPhysicalPowerMeter.TryCompute(raw, out _)); + + var physical = new AnalogValuesSnapshot + { + Ia = C("Ia", 1, -30, "A"), Ua = C("Ua", 57.74, 0, "V"), + Ib = C("Ib", 1, -150, "A"), Ub = C("Ub", 57.74, -120, "V"), + Ic = C("Ic", 1, 90, "A"), Uc = C("Uc", 57.74, 120, "V") + }; + Assert.True(SvPhysicalPowerMeter.TryCompute(physical, out var totals)); + Assert.InRange(totals.S, 173.21, 173.23); + Assert.InRange(totals.P, 149.98, 150.03); + Assert.InRange(totals.Q, 86.60, 86.62); + + var missing = new AnalogValuesSnapshot + { + Ia = C("Ia", 1, -30, "A"), Ua = C("Ua", 57.74, 0, "V"), + Ib = C("Ib", 1, -150, "A"), Ub = C("Ub", 57.74, -120, "V"), + Ic = C("Ic", 1, 90, "A") + }; + Assert.False(SvPhysicalPowerMeter.TryCompute(missing, out _)); + } + [Fact] public void EngineeringScale_RawByDefault_RequiresExplicitPerStreamAuthority() { From 21607ede8fa2623ff564d9d6b3fd3b3dc08e6bca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:54:20 +0700 Subject: [PATCH 7/8] docs(lab): no-SCL metering, scaling authority and physical SV acceptance checks --- docs/SV_ENGINEERING_SCALE_LAB.md | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/SV_ENGINEERING_SCALE_LAB.md diff --git a/docs/SV_ENGINEERING_SCALE_LAB.md b/docs/SV_ENGINEERING_SCALE_LAB.md new file mode 100644 index 00000000..9c3717ad --- /dev/null +++ b/docs/SV_ENGINEERING_SCALE_LAB.md @@ -0,0 +1,42 @@ +# SV Engineering Units — independent capture lab + +Process Bus Insight is receive-only. It cannot prove physical A/V scaling from +IEC 61850 SV packet identity, raw payload width, APPID, svID or a waveform plot. + +## Simple workflow (no SCL required) + +1. Run the publisher, select the receiving Ethernet/Npcap adapter in Process + Bus Insight, press **Run**, then select the *canonical* stream under SV Stream + Explorer. The separate broadcast diagnostic mirror remains independent. +2. Check waveform and phase relationships immediately; this works in **Raw + counts (uncalibrated)** mode. RMS and waveform axes indicate **counts**. + P/Q/S/PF are unavailable rather than fictional MW/Mvar. +3. In **Metering > SV units**, opt in to **4I+4V nominal (1 mA / 10 mV per + count)** *only* when that is the known mapping of the selected lab publisher. + For example, raw 1000 corresponds to 1.000 A; raw 5774 corresponds to + 57.74 V. This is not independent calibration or a universal SV rule. +4. Check A/V RMS, 50 Hz, coherent phasors, waveform, P/Q/S and power factor. + Try changing a single phase while the publisher runs; compare the + timestamp-aligned evidence rather than screenshots taken at different times. +5. Use **Diagnostics > Copy Evidence**. Text includes per-stream SV identity, + sample counters, decode and timing evidence, the selected engineering scale, + the operator authority warning, RMS A/V/count values and P/Q/S/PF. +6. Select another stream: it defaults to **Raw counts** until explicitly + configured. Return to the first stream to restore its own scaling choice. + A fresh capture reset clears the analyzer's runtime state; select the + nominal profile again when needed. + +## Acceptance boundaries + +- Known 4I+4V: `I=0.001 A/count`, `U=0.01 V/count`; voltage/current axes + have separate autorange and do not accept fixed A/V limits while uncalibrated. +- One coherent selected-stream snapshot owns waveform, RMS, phasor and power. +- Pure power regression: three phases at 57.74 V, 1 A and 30 degree angle + offset produce roughly 173.22 VA, 150 W and 86.61 var. Any raw-count, + missing-phase or nonfinite input rejects physical power. +- Packet quality, sample continuity, VLAN visibility, Npcap capture drops, + host arrival jitter and hardware PTP lock remain separate facts. +- Use locally held operator PCAP and physical ESP32-P4 to validate Windows + candidate; do not commit private recordings or identifying site data. +- A successful waveform or power calculation does not prove calibration, + hardware timestamp quality or that a real IED accepted SV. From 9fd74d6073a7bfeb7b0298e09dd17466e91a6db7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:55:14 +0700 Subject: [PATCH 8/8] feat(metering): one-click copy diagnostics alongside selected SV scale --- src/ProcessBus.App.Wpf/MainWindow.xaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 7685587c..c19ad425 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -2420,6 +2420,11 @@ ItemsSource="{Binding EngineeringScaleOptions}" SelectedItem="{Binding SelectedEngineeringScale, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" ToolTip="Choose the physical scale for this selected SV stream. No SCL required; raw counts is safest until the profile is verified."/> +