Skip to content
56 changes: 54 additions & 2 deletions src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
private double _currentAutoPeak;
private DateTime _voltagePeakUpdatedUtc = DateTime.MinValue;
private DateTime _currentPeakUpdatedUtc = DateTime.MinValue;
private string? _voltageUnit;
private string? _currentUnit;
private const double AutoScaleHeadroom = 1.28;
private const double AutoScaleSmoothing = 0.18;
// Steady-state deadband: the observed window peak jitters a little every cycle, and an
Expand All @@ -64,6 +66,15 @@
typeof(WaveformPanelControl),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));

// A reused WPF control must never carry a raw-count peak range into a
// different SV publisher. Stream identity is cheap state, not packet work.
public static readonly DependencyProperty StreamKeyProperty =
DependencyProperty.Register(
nameof(StreamKey), typeof(string), typeof(WaveformPanelControl),
new FrameworkPropertyMetadata(string.Empty,
FrameworkPropertyMetadataOptions.AffectsRender,
static (d, _) => ((WaveformPanelControl)d).ResetScaleState()));

public static readonly DependencyProperty ShowModeProperty =
DependencyProperty.Register(
nameof(ShowMode),
Expand Down Expand Up @@ -112,6 +123,42 @@
set => SetValue(SnapshotProperty, value);
}

public string StreamKey
{
get => (string)GetValue(StreamKeyProperty);
set => SetValue(StreamKeyProperty, value);
}

private void ResetScaleState()
{
_voltageAutoPeak = 0;
_currentAutoPeak = 0;
_voltagePeakUpdatedUtc = DateTime.MinValue;
_currentPeakUpdatedUtc = DateTime.MinValue;
_voltageUnit = null;
_currentUnit = null;
}

private void ResetLaneScaleWhenUnitsChange(bool isVoltage, string unit)
{
if (isVoltage)
{
if (!WaveformAutoRangePolicy.UnitsChanged(_voltageUnit, unit))
return;
_voltageUnit = unit;
_voltageAutoPeak = 0;
_voltagePeakUpdatedUtc = DateTime.MinValue;
}
else
{
if (!WaveformAutoRangePolicy.UnitsChanged(_currentUnit, unit))
return;
_currentUnit = unit;
_currentAutoPeak = 0;
_currentPeakUpdatedUtc = DateTime.MinValue;
}
}

public string ShowMode
{
get => (string)GetValue(ShowModeProperty);
Expand Down Expand Up @@ -155,7 +202,7 @@
return new Size(Math.Max(480, width), Math.Max(320, height));
}

protected override void OnRender(DrawingContext dc)

Check warning on line 205 in src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

In member void WaveformPanelControl.OnRender(DrawingContext dc), change parameter name dc to drawingContext in order to match the identifier as it has been declared in void UIElement.OnRender(DrawingContext drawingContext) (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1725)
{
base.OnRender(dc);

Expand All @@ -174,9 +221,9 @@

var visibleLanes = new List<LaneDefinition>(2);
if (showVoltage)
visibleLanes.Add(new LaneDefinition("Voltage", snapshot.VoltageSeries.FirstOrDefault()?.Unit ?? "counts", snapshot.VoltageSeries));

Check warning on line 224 in src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

Do not use Enumerable methods on indexable collections. Instead use the collection directly. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1826)
if (showCurrent)
visibleLanes.Add(new LaneDefinition("Current", snapshot.CurrentSeries.FirstOrDefault()?.Unit ?? "counts", snapshot.CurrentSeries));

Check warning on line 226 in src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

Do not use Enumerable methods on indexable collections. Instead use the collection directly. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1826)

if (visibleLanes.Count == 0)
{
Expand Down Expand Up @@ -355,6 +402,7 @@
// 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);
ResetLaneScaleWhenUnitsChange(isVoltage, lane.Unit);
var hasPhysicalUnits = string.Equals(lane.Unit, isVoltage ? "V" : "A", StringComparison.OrdinalIgnoreCase);
var scaleText = isVoltage ? VoltageScale : CurrentScale;

Expand Down Expand Up @@ -447,7 +495,10 @@

if (isVoltage)
{
if (targetPeak >= _voltageAutoPeak || (now - _voltagePeakUpdatedUtc).TotalSeconds > PeakHoldSeconds)
// A 1000× counts-to-V conversion (or sudden real magnitude change)
// must not be hidden as a flat line for the entire hold timeout.
if (WaveformAutoRangePolicy.ShouldReleasePeakHold(_voltageAutoPeak, targetPeak) ||
(now - _voltagePeakUpdatedUtc).TotalSeconds > PeakHoldSeconds)
{
_voltageAutoPeak = targetPeak;
_voltagePeakUpdatedUtc = now;
Expand All @@ -459,7 +510,8 @@
return Math.Max(_voltageAutoPeak, 1.0);
}

if (targetPeak >= _currentAutoPeak || (now - _currentPeakUpdatedUtc).TotalSeconds > PeakHoldSeconds)
if (WaveformAutoRangePolicy.ShouldReleasePeakHold(_currentAutoPeak, targetPeak) ||
(now - _currentPeakUpdatedUtc).TotalSeconds > PeakHoldSeconds)
{
_currentAutoPeak = targetPeak;
_currentPeakUpdatedUtc = now;
Expand Down
120 changes: 55 additions & 65 deletions src/ProcessBus.App.Wpf/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,7 @@
<ListView Grid.Row="2"
Style="{StaticResource GridListViewStyle}"
ItemsSource="{Binding Streams}"
Margin="0,10,0,0"
SelectedItem="{Binding SelectedStream, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
ClipToBounds="True"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
Expand Down Expand Up @@ -1927,48 +1928,11 @@
<ColumnDefinition Width="320"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="0"/>
<RowDefinition Height="0"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>

<Border Grid.Column="0"
Grid.Row="0"
Style="{StaticResource DenseGroupBorderStyle}"
Padding="8">
<WrapPanel>
<Border>
<Border.Style>
<Style TargetType="Border" BasedOn="{StaticResource MetricChipBorderStyle}">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding WaveformHeaderFrequencyText}" Value="N/A">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Foreground="{StaticResource AccentBrush}"
FontWeight="SemiBold"
Text="{Binding WaveformHeaderFrequencyText}"/>
</Border>
<Border Style="{StaticResource MetricChipBorderStyle}">
<TextBlock Foreground="{StaticResource AccentBrush}"
FontWeight="SemiBold"
Text="{Binding WaveformHeaderWindowText}"/>
</Border>
<!-- Advanced waveform-shape inference is diagnostic, not a
primary oscilloscope KPI. Keep it on hover, not as a
constantly competing warning-colored pill. -->
<Border Style="{StaticResource MetricChipBorderStyle}"
Visibility="Collapsed"
ToolTip="{Binding WaveformShapeStatusText}">
<TextBlock Foreground="#B1CBE0"
Text="{Binding WaveformShapeStatusText}"/>
</Border>
</WrapPanel>
</Border>

<Border x:Name="ScopeHeroPanel"
Grid.Column="0"
Grid.Row="2"
Expand Down Expand Up @@ -2116,6 +2080,7 @@
<controls:WaveformPanelControl Grid.Row="2"
d:Visibility="Collapsed"
Snapshot="{Binding Waveform}"
StreamKey="{Binding SelectedStream.StreamId}"
ShowMode="{Binding WaveformShowMode}"
LayoutMode="{Binding WaveformLayoutMode}"
TimebaseMode="{Binding WaveformTimebase}"
Expand Down Expand Up @@ -2194,9 +2159,7 @@
<DockPanel LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="Measurements"
Style="{StaticResource PanelTitleTextStyle}"/>
<TextBlock DockPanel.Dock="Right" VerticalAlignment="Center"
Text="{Binding WaveformHeaderFrequencyText}"
FontSize="11" Foreground="#8CBDE4"/>

</DockPanel>
<DockPanel Margin="0,7,0,2">
<Button DockPanel.Dock="Right" Content="Copy" Margin="7,0,0,0"
Expand Down Expand Up @@ -2389,35 +2352,62 @@
ToolTip="{Binding AnalogValues.Ic.AngleDisplayText}"/>
</Grid>
</Border>
<Expander Margin="0,8,0,0" Header="Power &amp; calibration"
IsExpanded="False"
Foreground="#B9D7EE">
<StackPanel Margin="3,7,3,0">
<TextBlock Text="{Binding EngineeringScaleExplanation}"
Foreground="#F4C66D" FontSize="11"
TextWrapping="Wrap" Margin="0,0,0,6"/>
<UniformGrid Columns="2">
<StackPanel Margin="0,0,4,6">
<TextBlock Text="P" Foreground="#89AFCB" FontSize="10"/>
<TextBlock Text="{Binding TotalActivePowerText}" Foreground="#F4FAFF" TextTrimming="CharacterEllipsis"/>
<!-- Estimated from nominal engineering scaling; confidence remains explicit. -->
<Border Margin="0,8,0,0" Padding="4,7"
BorderThickness="0,1,0,0" BorderBrush="#24415B">
<StackPanel>
<DockPanel Margin="0,0,0,5" LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="Power estimate"
Foreground="#A6CBE3" FontSize="11"
FontWeight="SemiBold"/>
<TextBlock DockPanel.Dock="Right" Text="UNVERIFIED"
Foreground="#D4B36C" FontSize="9.5"
ToolTip="{Binding EngineeringScaleExplanation}"/>
</DockPanel>
<UniformGrid Columns="4">
<StackPanel Margin="0,0,3,0">
<TextBlock Text="P" Foreground="#8DAFCA" FontSize="9.5"/>
<TextBlock Text="{Binding TotalActivePowerText}" Foreground="#EEF8FF"
FontSize="10.5" FontFamily="Consolas"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding TotalActivePowerText}"/>
</StackPanel>
<StackPanel Margin="0,0,0,6">
<TextBlock Text="Q" Foreground="#89AFCB" FontSize="10"/>
<TextBlock Text="{Binding TotalReactivePowerText}" Foreground="#F4FAFF" TextTrimming="CharacterEllipsis"/>
<StackPanel Margin="0,0,3,0">
<TextBlock Text="Q" Foreground="#8DAFCA" FontSize="9.5"/>
<TextBlock Text="{Binding TotalReactivePowerText}" Foreground="#EEF8FF"
FontSize="10.5" FontFamily="Consolas"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding TotalReactivePowerText}"/>
</StackPanel>
<StackPanel Margin="0,0,4,0">
<TextBlock Text="S" Foreground="#89AFCB" FontSize="10"/>
<TextBlock Text="{Binding TotalApparentPowerText}" Foreground="#F4FAFF" TextTrimming="CharacterEllipsis"/>
<StackPanel Margin="0,0,3,0">
<TextBlock Text="S" Foreground="#8DAFCA" FontSize="9.5"/>
<TextBlock Text="{Binding TotalApparentPowerText}" Foreground="#EEF8FF"
FontSize="10.5" FontFamily="Consolas"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding TotalApparentPowerText}"/>
</StackPanel>
<StackPanel>
<TextBlock Text="PF" Foreground="#89AFCB" FontSize="10"/>
<TextBlock Text="{Binding PowerFactorText}" Foreground="#F4FAFF" TextTrimming="CharacterEllipsis"/>
<StackPanel Margin="0,0,3,0">
<TextBlock Text="PF" Foreground="#8DAFCA" FontSize="9.5"/>
<TextBlock Text="{Binding PowerFactorText}" Foreground="#EEF8FF"
FontSize="10.5" FontFamily="Consolas"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding PowerFactorText}"/>
</StackPanel>
</UniformGrid>
<TextBlock Text="{Binding EvidenceCopyStatusText}" Foreground="#89AFCB"
FontSize="10" TextTrimming="CharacterEllipsis"/>
<Expander Margin="0,7,0,0" Header="Scaling &amp; evidence"
Foreground="#B7D1E5" IsExpanded="False">
<StackPanel Margin="5,6,4,4">
<TextBlock Text="{Binding EngineeringScaleExplanation}"
Foreground="#C2D9EA" FontSize="11"
TextWrapping="Wrap"/>
<TextBlock Text="{Binding EvidenceCopyStatusText}"
Foreground="#94B4CC" FontSize="10"
TextTrimming="CharacterEllipsis"
Margin="0,4,0,0"/>
</StackPanel>
</Expander>
</StackPanel>
</Expander>
</Border>
</StackPanel>
</ScrollViewer>
</Border>
Expand Down
21 changes: 21 additions & 0 deletions src/ProcessBus.Core/Models/WaveformAutoRangePolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace ProcessBus.Core.Models;

/// <summary>
/// Screen-only oscilloscope scale guard. A WPF panel is reused across SV streams
/// and raw-to-nominal unit transitions; old peaks cannot remain authoritative.
/// No decoded SV samples, phase, RMS, or packet diagnostics are altered.
/// </summary>
public static class WaveformAutoRangePolicy
{
public static bool UnitsChanged(string? previousUnit, string? nextUnit) =>
!string.Equals(previousUnit, nextUnit, StringComparison.OrdinalIgnoreCase);

public static bool ShouldReleasePeakHold(double heldPeak, double incomingPeak)
{
if (!double.IsFinite(heldPeak) || heldPeak <= 0)
return true;
if (!double.IsFinite(incomingPeak) || incomingPeak <= 0)
return false;
return incomingPeak >= heldPeak || incomingPeak < heldPeak * 0.25;
}
}
Loading
Loading