feat: restore bench section UI with controls, PID RPM ramp, flowmeter charts, and fix CAN IDs
Restore the full bench control panel from the old source with MVVM architecture: - Two-column left panel layout: bench info displays (RPM with target/voltage, temps, pressures, Q-flow, pump live values) and user commands (direction toggle, start/stop with RPM popup and quick-select buttons, oil pump toggle, turn downcounter with CAN send) - PID RPM ramp controller (BenchPidController) with bumpless startup, anti-windup, and derivative-on-measurement for smooth motor speed transitions - Real-time flowmeter charts (LiveChartsCore) for Q-Delivery and Q-Over with tolerance band overlays - Bench/pump CAN liveness detection in PcanAdapter (receive-only IDs) - K-Line connection status indicator (placeholder) - Periodic relay bitmask sender (~21ms) and ElectronicMsg keepalive start on CAN connect, pump sender starts immediately on pump load Fix critical CAN message ID bug: default bench XML values were incorrectly converted from old source (decimal-notation hex parsed as actual hex digits, e.g. "10" -> "A" instead of keeping "10" which parses as 0x10). Corrected all IDs to match hardware: 0x10, 0x11, 0x13, 0x14, 0x15, 0x50, 0x51, 0x55. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,11 +48,27 @@ namespace HC_APTBS.Infrastructure.Pcan
|
|||||||
private AutoResetEvent? _receiveEvent;
|
private AutoResetEvent? _receiveEvent;
|
||||||
private volatile bool _stopRead = true;
|
private volatile bool _stopRead = true;
|
||||||
|
|
||||||
|
// ── Liveness tracking ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private const int LivenessTimeoutMs = 500;
|
||||||
|
private HashSet<uint> _benchMessageIds = new();
|
||||||
|
private HashSet<uint> _pumpMessageIds = new();
|
||||||
|
private DateTime _lastBenchFrameUtc = DateTime.MinValue;
|
||||||
|
private DateTime _lastPumpFrameUtc = DateTime.MinValue;
|
||||||
|
private bool _benchAlive;
|
||||||
|
private bool _pumpAlive;
|
||||||
|
|
||||||
// ── ICanService ──────────────────────────────────────────────────────────
|
// ── ICanService ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public event Action<string, bool>? StatusChanged;
|
public event Action<string, bool>? StatusChanged;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event Action<bool>? BenchLivenessChanged;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event Action<bool>? PumpLivenessChanged;
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public TPCANStatus CurrentStatus { get; private set; } = TPCANStatus.PCAN_ERROR_OK;
|
public TPCANStatus CurrentStatus { get; private set; } = TPCANStatus.PCAN_ERROR_OK;
|
||||||
|
|
||||||
@@ -186,6 +202,18 @@ namespace HC_APTBS.Infrastructure.Pcan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void RegisterBenchMessageIds(IReadOnlyCollection<uint> ids)
|
||||||
|
{
|
||||||
|
_benchMessageIds = new HashSet<uint>(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void RegisterPumpMessageIds(IReadOnlyCollection<uint> ids)
|
||||||
|
{
|
||||||
|
_pumpMessageIds = new HashSet<uint>(ids);
|
||||||
|
}
|
||||||
|
|
||||||
// ── ICanService: transmit ─────────────────────────────────────────────────
|
// ── ICanService: transmit ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -289,6 +317,9 @@ namespace HC_APTBS.Infrastructure.Pcan
|
|||||||
EmitStatusChanged(status);
|
EmitStatusChanged(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check liveness timeouts.
|
||||||
|
CheckLivenessTimeout();
|
||||||
|
|
||||||
// Configurable polling interval to avoid pegging the CPU.
|
// Configurable polling interval to avoid pegging the CPU.
|
||||||
// Typical value: 2–50 ms depending on operational phase.
|
// Typical value: 2–50 ms depending on operational phase.
|
||||||
Thread.Sleep(2);
|
Thread.Sleep(2);
|
||||||
@@ -336,6 +367,27 @@ namespace HC_APTBS.Infrastructure.Pcan
|
|||||||
|
|
||||||
if (!snapshot.TryGetValue(frame.ID, out var parameters)) return;
|
if (!snapshot.TryGetValue(frame.ID, out var parameters)) return;
|
||||||
|
|
||||||
|
// Track liveness for bench and pump frame groups.
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (_benchMessageIds.Contains(frame.ID))
|
||||||
|
{
|
||||||
|
_lastBenchFrameUtc = now;
|
||||||
|
if (!_benchAlive)
|
||||||
|
{
|
||||||
|
_benchAlive = true;
|
||||||
|
BenchLivenessChanged?.Invoke(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_pumpMessageIds.Contains(frame.ID))
|
||||||
|
{
|
||||||
|
_lastPumpFrameUtc = now;
|
||||||
|
if (!_pumpAlive)
|
||||||
|
{
|
||||||
|
_pumpAlive = true;
|
||||||
|
PumpLivenessChanged?.Invoke(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
byte[] data = frame.DATA;
|
byte[] data = frame.DATA;
|
||||||
|
|
||||||
foreach (var param in parameters)
|
foreach (var param in parameters)
|
||||||
@@ -472,6 +524,27 @@ namespace HC_APTBS.Infrastructure.Pcan
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if bench or pump frame reception has timed out and fires
|
||||||
|
/// liveness events on transition from alive to dead.
|
||||||
|
/// </summary>
|
||||||
|
private void CheckLivenessTimeout()
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (_benchAlive && (now - _lastBenchFrameUtc).TotalMilliseconds > LivenessTimeoutMs)
|
||||||
|
{
|
||||||
|
_benchAlive = false;
|
||||||
|
BenchLivenessChanged?.Invoke(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_pumpAlive && (now - _lastPumpFrameUtc).TotalMilliseconds > LivenessTimeoutMs)
|
||||||
|
{
|
||||||
|
_pumpAlive = false;
|
||||||
|
PumpLivenessChanged?.Invoke(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── IIR low-pass filter ───────────────────────────────────────────────────
|
// ── IIR low-pass filter ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
184
MainWindow.xaml
184
MainWindow.xaml
@@ -106,9 +106,15 @@
|
|||||||
══════════════════════════════════════════════════════════════ -->
|
══════════════════════════════════════════════════════════════ -->
|
||||||
<Expander Header="Bench" IsExpanded="True" Margin="0,2,0,0">
|
<Expander Header="Bench" IsExpanded="True" Margin="0,2,0,0">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Margin="5">
|
<Grid Margin="5">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/> <!-- Row 0: connection status -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- Row 1: CAN buttons -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- Row 2: two-column info + controls -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- Row 3: flowmeter charts -->
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Connection status indicators -->
|
<!-- ── Row 0: Connection status indicators ─────────────── -->
|
||||||
<Border BorderBrush="Black" BorderThickness="1" Margin="0,4,0,4">
|
<Border BorderBrush="Black" BorderThickness="1" Margin="0,4,0,4">
|
||||||
<Grid Margin="4,4">
|
<Grid Margin="4,4">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
@@ -116,6 +122,7 @@
|
|||||||
<ColumnDefinition/>
|
<ColumnDefinition/>
|
||||||
<ColumnDefinition/>
|
<ColumnDefinition/>
|
||||||
<ColumnDefinition/>
|
<ColumnDefinition/>
|
||||||
|
<ColumnDefinition/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Text="Status:" VerticalAlignment="Center"
|
<TextBlock Text="Status:" VerticalAlignment="Center"
|
||||||
FontSize="10" Margin="0,0,6,0"/>
|
FontSize="10" Margin="0,0,6,0"/>
|
||||||
@@ -161,11 +168,25 @@
|
|||||||
<TextBlock Text="Pump" HorizontalAlignment="Center"
|
<TextBlock Text="Pump" HorizontalAlignment="Center"
|
||||||
FontSize="10" Padding="2"/>
|
FontSize="10" Padding="2"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Column="4">
|
||||||
|
<Border.Style>
|
||||||
|
<Style TargetType="Border" BasedOn="{StaticResource ConnIndicator}">
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsKLineConnected}" Value="True">
|
||||||
|
<Setter Property="Background" Value="#26C200"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Border.Style>
|
||||||
|
<TextBlock Text="K-Line" HorizontalAlignment="Center"
|
||||||
|
FontSize="10" Padding="2"/>
|
||||||
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- CAN connect / disconnect -->
|
<!-- ── Row 1: CAN connect / disconnect ─────────────────── -->
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,4">
|
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,4">
|
||||||
<Button Content="Connect CAN" Width="110" Margin="0,0,6,0"
|
<Button Content="Connect CAN" Width="110" Margin="0,0,6,0"
|
||||||
Command="{Binding ConnectCanCommand}"/>
|
Command="{Binding ConnectCanCommand}"/>
|
||||||
<Button Content="Disconnect CAN" Width="120"
|
<Button Content="Disconnect CAN" Width="120"
|
||||||
@@ -173,24 +194,44 @@
|
|||||||
IsEnabled="{Binding IsCanConnected}"/>
|
IsEnabled="{Binding IsCanConnected}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- RPM display -->
|
<!-- ── Row 2: Two-column layout (Info | Controls) ──────── -->
|
||||||
<Border Style="{StaticResource LcdBlue}" Margin="0,4" Height="90" Padding="10,4">
|
<Grid Grid.Row="2">
|
||||||
<Grid>
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- ─── Column 0: Bench Information ─────────────────── -->
|
||||||
|
<StackPanel>
|
||||||
|
|
||||||
|
<!-- RPM display with target and voltage -->
|
||||||
|
<Border Style="{StaticResource LcdBlue}" Margin="0,4" Padding="10,4">
|
||||||
|
<StackPanel>
|
||||||
|
<Grid Height="70">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition/>
|
<ColumnDefinition/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Text="{Binding BenchRpm, StringFormat=F0}"
|
<TextBlock Text="{Binding BenchRpm, StringFormat=F0}"
|
||||||
FontSize="70" FontWeight="UltraBold" Foreground="#EBEBFF"
|
FontSize="60" FontWeight="UltraBold" Foreground="#EBEBFF"
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
FontFamily="Consolas"/>
|
FontFamily="Consolas"/>
|
||||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="6,0,0,0">
|
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="6,0,0,0">
|
||||||
<TextBlock Text="rpm" FontSize="18" Foreground="#FFFFEB6E"/>
|
<TextBlock Text="rpm" FontSize="18" Foreground="#FFFFEB6E"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<TextBlock Text="Target:" Foreground="#EBEBFF" FontSize="11" Margin="0,0,4,0"/>
|
||||||
|
<TextBlock Text="{Binding BenchControl.TargetRpm, StringFormat=F0}"
|
||||||
|
Foreground="#FFFFEB6E" FontSize="11" FontFamily="Consolas" Margin="0,0,8,0"/>
|
||||||
|
<TextBlock Text="V:" Foreground="#EBEBFF" FontSize="11" Margin="0,0,4,0"/>
|
||||||
|
<TextBlock Text="{Binding BenchControl.CommandVoltage, StringFormat=F3}"
|
||||||
|
Foreground="#FFFFEB6E" FontSize="11" FontFamily="Consolas"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Temperatures -->
|
<!-- Temperatures and Pressure -->
|
||||||
<Border Style="{StaticResource LcdBlue}" Margin="0,4" Padding="8,4">
|
<Border Style="{StaticResource LcdBlue}" Margin="0,4" Padding="8,4">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
@@ -267,16 +308,6 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Relay toggles -->
|
|
||||||
<Border BorderBrush="#888" BorderThickness="1" Margin="0,4" Padding="6">
|
|
||||||
<WrapPanel>
|
|
||||||
<Button Content="Electronic" Style="{StaticResource RelayButton}" Command="{Binding ToggleElectronicCommand}"/>
|
|
||||||
<Button Content="Oil Pump" Style="{StaticResource RelayButton}" Command="{Binding ToggleOilPumpCommand}"/>
|
|
||||||
<Button Content="Deposit Cooler" Style="{StaticResource RelayButton}" Command="{Binding ToggleDepositCoolerCommand}"/>
|
|
||||||
<Button Content="Deposit Heater" Style="{StaticResource RelayButton}" Command="{Binding ToggleDepositHeaterCommand}"/>
|
|
||||||
</WrapPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- PSG encoder value -->
|
<!-- PSG encoder value -->
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,4">
|
<StackPanel Orientation="Horizontal" Margin="0,4">
|
||||||
<TextBlock Text="PSG Encoder:" VerticalAlignment="Center" FontSize="12" Margin="0,0,8,0"/>
|
<TextBlock Text="PSG Encoder:" VerticalAlignment="Center" FontSize="12" Margin="0,0,8,0"/>
|
||||||
@@ -285,6 +316,121 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- ─── Column 1: User Commands ─────────────────────── -->
|
||||||
|
<StackPanel Grid.Column="1" Width="160" Margin="6,0,0,0">
|
||||||
|
|
||||||
|
<!-- Direction toggle -->
|
||||||
|
<TextBlock Text="Direction" FontSize="10" Foreground="DimGray" Margin="0,4,0,2"/>
|
||||||
|
<ToggleButton IsChecked="{Binding BenchControl.IsDirectionRight}"
|
||||||
|
Height="32" FontSize="12" FontWeight="SemiBold">
|
||||||
|
<ToggleButton.Style>
|
||||||
|
<Style TargetType="ToggleButton">
|
||||||
|
<Setter Property="Content" Value="LEFT"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<Setter Property="Content" Value="RIGHT"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</ToggleButton.Style>
|
||||||
|
</ToggleButton>
|
||||||
|
|
||||||
|
<!-- Start / Stop bench -->
|
||||||
|
<TextBlock Text="Bench Motor" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/>
|
||||||
|
<Button Content="START" FontSize="13" FontWeight="Bold" Height="36"
|
||||||
|
Foreground="DarkGreen" Margin="0,0,0,4"
|
||||||
|
Command="{Binding BenchControl.OpenRpmPopupCommand}"/>
|
||||||
|
<Popup StaysOpen="False" Placement="Left"
|
||||||
|
IsOpen="{Binding BenchControl.IsRpmPopupOpen, Mode=TwoWay}">
|
||||||
|
<Border Background="White" BorderBrush="Black" BorderThickness="1" Padding="8">
|
||||||
|
<StackPanel Width="200">
|
||||||
|
<TextBlock Text="Set RPM:" FontSize="12" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Text="{Binding BenchControl.RpmInputText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
FontSize="16" FontFamily="Consolas" Height="28" Margin="0,0,0,6"/>
|
||||||
|
<Button Content="GO" FontSize="13" FontWeight="Bold" Height="30"
|
||||||
|
Foreground="DarkGreen" Margin="0,0,0,6"
|
||||||
|
Command="{Binding BenchControl.StartBenchCommand}"/>
|
||||||
|
<UniformGrid Columns="5">
|
||||||
|
<Button Content="100" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="100"/>
|
||||||
|
<Button Content="200" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="200"/>
|
||||||
|
<Button Content="300" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="300"/>
|
||||||
|
<Button Content="400" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="400"/>
|
||||||
|
<Button Content="500" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="500"/>
|
||||||
|
<Button Content="600" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="600"/>
|
||||||
|
<Button Content="700" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="700"/>
|
||||||
|
<Button Content="800" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="800"/>
|
||||||
|
<Button Content="900" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="900"/>
|
||||||
|
<Button Content="1000" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="1000"/>
|
||||||
|
<Button Content="1200" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="1200"/>
|
||||||
|
<Button Content="1400" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="1400"/>
|
||||||
|
<Button Content="1600" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="1600"/>
|
||||||
|
<Button Content="1800" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="1800"/>
|
||||||
|
<Button Content="2000" Style="{StaticResource RelayButton}" Command="{Binding BenchControl.SetQuickRpmCommand}" CommandParameter="2000"/>
|
||||||
|
</UniformGrid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
<Button Content="STOP" FontSize="13" FontWeight="Bold" Height="36"
|
||||||
|
Foreground="DarkRed"
|
||||||
|
Command="{Binding BenchControl.StopBenchCommand}"/>
|
||||||
|
|
||||||
|
<!-- Oil pump toggle -->
|
||||||
|
<TextBlock Text="Oil Pump" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/>
|
||||||
|
<ToggleButton IsChecked="{Binding BenchControl.IsOilPumpOn}"
|
||||||
|
Height="32" FontSize="12" FontWeight="SemiBold">
|
||||||
|
<ToggleButton.Style>
|
||||||
|
<Style TargetType="ToggleButton">
|
||||||
|
<Setter Property="Content" Value="OIL OFF"/>
|
||||||
|
<Setter Property="Background" Value="LightGray"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<Setter Property="Content" Value="OIL ON"/>
|
||||||
|
<Setter Property="Background" Value="#80FF80"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</ToggleButton.Style>
|
||||||
|
</ToggleButton>
|
||||||
|
|
||||||
|
<!-- Turn counter -->
|
||||||
|
<TextBlock Text="Counter" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/>
|
||||||
|
<ToggleButton Content="Counter"
|
||||||
|
IsChecked="{Binding BenchControl.IsCounterPopupOpen}"
|
||||||
|
Height="28" FontSize="11"/>
|
||||||
|
<Popup StaysOpen="False" Placement="Left"
|
||||||
|
IsOpen="{Binding BenchControl.IsCounterPopupOpen, Mode=TwoWay}">
|
||||||
|
<Border Background="White" BorderBrush="Black" BorderThickness="1" Padding="8">
|
||||||
|
<StackPanel Width="160">
|
||||||
|
<TextBlock Text="Turns:" FontSize="12" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Text="{Binding BenchControl.CounterInputText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
FontSize="16" FontFamily="Consolas" Height="28" Margin="0,0,0,4"/>
|
||||||
|
<Button Content="Send" FontSize="12" Height="28"
|
||||||
|
Command="{Binding BenchControl.SendCounterCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
<TextBlock FontSize="14" FontFamily="Consolas" Margin="0,2"
|
||||||
|
Text="{Binding BenchControl.BenchCounterValue, StringFormat=00000000}"/>
|
||||||
|
|
||||||
|
<!-- Relay toggles -->
|
||||||
|
<TextBlock Text="Relays" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/>
|
||||||
|
<StackPanel>
|
||||||
|
<Button Content="Electronic" Style="{StaticResource RelayButton}" Command="{Binding ToggleElectronicCommand}"/>
|
||||||
|
<Button Content="Deposit Cooler" Style="{StaticResource RelayButton}" Command="{Binding ToggleDepositCoolerCommand}"/>
|
||||||
|
<Button Content="Deposit Heater" Style="{StaticResource RelayButton}" Command="{Binding ToggleDepositHeaterCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ── Row 3: Flowmeter charts ─────────────────────────── -->
|
||||||
|
<StackPanel Grid.Row="3" Margin="0,4">
|
||||||
|
<uc:FlowmeterChartView DataContext="{Binding FlowmeterChart.Delivery}"/>
|
||||||
|
<uc:FlowmeterChartView DataContext="{Binding FlowmeterChart.Over}" Margin="0,4,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
|
|||||||
@@ -113,9 +113,33 @@ namespace HC_APTBS.Services
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Commands the bench motor to the specified RPM by computing and applying
|
/// Commands the bench motor to the specified RPM by computing and applying
|
||||||
/// the corresponding voltage from the RPM-to-voltage lookup table.
|
/// the corresponding voltage from the RPM-to-voltage lookup table.
|
||||||
|
/// Used by automated test execution for direct voltage jumps.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
void SetRpm(double rpm);
|
void SetRpm(double rpm);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the PID-based RPM ramp controller to smoothly reach the target RPM.
|
||||||
|
/// Sends an initial voltage jump from the RPM-voltage lookup table, then hands
|
||||||
|
/// control to the PID loop after an approach delay.
|
||||||
|
/// Used for interactive (manual) bench control.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="targetRpm">Desired RPM setpoint.</param>
|
||||||
|
void StartRpmPid(double targetRpm);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the PID RPM controller, sends 0 V to the motor.
|
||||||
|
/// </summary>
|
||||||
|
void StopRpmPid();
|
||||||
|
|
||||||
|
/// <summary>The last RPM target that was commanded via <see cref="SetRpm"/> or <see cref="StartRpmPid"/>.</summary>
|
||||||
|
double LastTargetRpm { get; }
|
||||||
|
|
||||||
|
/// <summary>The last voltage value sent to the motor CAN parameter.</summary>
|
||||||
|
double LastCommandVoltage { get; }
|
||||||
|
|
||||||
|
/// <summary>Raised after a voltage command is sent to the motor (from <see cref="SetRpm"/> or the PID loop).</summary>
|
||||||
|
event Action? RpmCommandSent;
|
||||||
|
|
||||||
// ── Temperature control ───────────────────────────────────────────────────
|
// ── Temperature control ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -136,6 +160,18 @@ namespace HC_APTBS.Services
|
|||||||
/// <param name="state">True = ON, false = OFF.</param>
|
/// <param name="state">True = ON, false = OFF.</param>
|
||||||
void SetRelay(string relayName, bool state);
|
void SetRelay(string relayName, bool state);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transmits the current relay bitmask once. Called on CAN connect so the
|
||||||
|
/// bench controller receives the initial relay state immediately.
|
||||||
|
/// </summary>
|
||||||
|
void SendInitialRelayState();
|
||||||
|
|
||||||
|
/// <summary>Starts the periodic relay bitmask sender (~21 ms cycle).</summary>
|
||||||
|
void StartRelaySender();
|
||||||
|
|
||||||
|
/// <summary>Stops the periodic relay bitmask sender.</summary>
|
||||||
|
void StopRelaySender();
|
||||||
|
|
||||||
// ── Test execution ────────────────────────────────────────────────────────
|
// ── Test execution ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ namespace HC_APTBS.Services
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
event Action<string, bool>? StatusChanged;
|
event Action<string, bool>? StatusChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the bench controller starts or stops sending CAN frames.
|
||||||
|
/// <c>alive</c> is true when frames are being received, false after a timeout.
|
||||||
|
/// </summary>
|
||||||
|
event Action<bool>? BenchLivenessChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the pump ECU starts or stops sending CAN frames.
|
||||||
|
/// <c>alive</c> is true when frames are being received, false after a timeout.
|
||||||
|
/// </summary>
|
||||||
|
event Action<bool>? PumpLivenessChanged;
|
||||||
|
|
||||||
// ── Properties ────────────────────────────────────────────────────────────
|
// ── Properties ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Most recent PCAN status code.</summary>
|
/// <summary>Most recent PCAN status code.</summary>
|
||||||
@@ -59,6 +71,18 @@ namespace HC_APTBS.Services
|
|||||||
/// <summary>Removes entries whose keys match the supplied dictionary.</summary>
|
/// <summary>Removes entries whose keys match the supplied dictionary.</summary>
|
||||||
void RemoveParameters(Dictionary<uint, List<CanBusParameter>> parameters);
|
void RemoveParameters(Dictionary<uint, List<CanBusParameter>> parameters);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers CAN message IDs that belong to the bench controller.
|
||||||
|
/// Frames with these IDs drive <see cref="BenchLivenessChanged"/>.
|
||||||
|
/// </summary>
|
||||||
|
void RegisterBenchMessageIds(IReadOnlyCollection<uint> ids);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers CAN message IDs that belong to the pump ECU.
|
||||||
|
/// Frames with these IDs drive <see cref="PumpLivenessChanged"/>.
|
||||||
|
/// </summary>
|
||||||
|
void RegisterPumpMessageIds(IReadOnlyCollection<uint> ids);
|
||||||
|
|
||||||
// ── Transmit ──────────────────────────────────────────────────────────────
|
// ── Transmit ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
204
Services/Impl/BenchPidController.cs
Normal file
204
Services/Impl/BenchPidController.cs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace HC_APTBS.Services.Impl
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// PID controller for smooth RPM ramping on the test bench motor.
|
||||||
|
/// Runs a background loop that reads actual RPM, computes the PID output,
|
||||||
|
/// and writes the corresponding motor voltage via a callback.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// All values are normalized to 0–1.0 internally. The output is scaled back
|
||||||
|
/// to the configured voltage range before calling the write delegate.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BenchPidController : IDisposable
|
||||||
|
{
|
||||||
|
// ── Gains ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private readonly double _kp;
|
||||||
|
private readonly double _ki;
|
||||||
|
private readonly double _kd;
|
||||||
|
|
||||||
|
// ── Ranges ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Maximum process variable (RPM).</summary>
|
||||||
|
private readonly double _pvMax;
|
||||||
|
|
||||||
|
/// <summary>Maximum output variable (voltage).</summary>
|
||||||
|
private readonly double _outMax;
|
||||||
|
|
||||||
|
// ── Delegates ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private readonly Func<double> _readActualRpm;
|
||||||
|
private readonly Func<double> _readTargetRpm;
|
||||||
|
private readonly Action<double> _sendVoltage;
|
||||||
|
|
||||||
|
// ── Loop state ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private readonly int _intervalMs;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private Task? _loopTask;
|
||||||
|
|
||||||
|
// ── PID state ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private double _errSum;
|
||||||
|
private double _lastPv;
|
||||||
|
private Stopwatch _sw = new();
|
||||||
|
|
||||||
|
/// <summary>True when the PID loop is actively running.</summary>
|
||||||
|
public bool IsRunning { get; private set; }
|
||||||
|
|
||||||
|
// ── Constructor ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new PID controller for bench RPM control.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="kp">Proportional gain.</param>
|
||||||
|
/// <param name="ki">Integral gain.</param>
|
||||||
|
/// <param name="kd">Derivative gain.</param>
|
||||||
|
/// <param name="intervalMs">Loop interval in milliseconds.</param>
|
||||||
|
/// <param name="pvMax">Maximum process variable (RPM). Minimum is always 0.</param>
|
||||||
|
/// <param name="outMax">Maximum output (voltage). Minimum is always 0.</param>
|
||||||
|
/// <param name="readActualRpm">Delegate to read the current bench RPM.</param>
|
||||||
|
/// <param name="readTargetRpm">Delegate to read the desired RPM setpoint.</param>
|
||||||
|
/// <param name="sendVoltage">Delegate to write the output voltage to the CAN bus.</param>
|
||||||
|
public BenchPidController(
|
||||||
|
double kp, double ki, double kd, int intervalMs,
|
||||||
|
double pvMax, double outMax,
|
||||||
|
Func<double> readActualRpm,
|
||||||
|
Func<double> readTargetRpm,
|
||||||
|
Action<double> sendVoltage)
|
||||||
|
{
|
||||||
|
_kp = kp;
|
||||||
|
_ki = ki;
|
||||||
|
_kd = kd;
|
||||||
|
_intervalMs = Math.Max(1, intervalMs);
|
||||||
|
_pvMax = pvMax;
|
||||||
|
_outMax = outMax;
|
||||||
|
_readActualRpm = readActualRpm;
|
||||||
|
_readTargetRpm = readTargetRpm;
|
||||||
|
_sendVoltage = sendVoltage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the PID loop with bumpless transfer from the given initial conditions.
|
||||||
|
/// If already running, stops first before restarting.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="initialVoltage">Current motor voltage (for bumpless startup).</param>
|
||||||
|
/// <param name="initialRpm">Current actual RPM (for bumpless startup).</param>
|
||||||
|
public void Start(double initialVoltage, double initialRpm)
|
||||||
|
{
|
||||||
|
if (IsRunning) Stop();
|
||||||
|
|
||||||
|
// Bumpless startup: pre-initialize integral sum so the first output
|
||||||
|
// matches the current voltage, avoiding a step response.
|
||||||
|
double normalizedOutput = Scale(initialVoltage, 0, _outMax, 0, 1.0);
|
||||||
|
_errSum = _ki != 0 ? normalizedOutput / _ki : 0;
|
||||||
|
_lastPv = Scale(Clamp(initialRpm, 0, _pvMax), 0, _pvMax, 0, 1.0);
|
||||||
|
_sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
IsRunning = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var ct = _cts.Token;
|
||||||
|
|
||||||
|
_loopTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_intervalMs));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (IsRunning && await timer.WaitForNextTickAsync(ct))
|
||||||
|
{
|
||||||
|
Compute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the PID loop and sends 0 V to the motor.
|
||||||
|
/// </summary>
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (!IsRunning) return;
|
||||||
|
IsRunning = false;
|
||||||
|
_cts?.Cancel();
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
_sendVoltage(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PID core ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void Compute()
|
||||||
|
{
|
||||||
|
double pv = _readActualRpm();
|
||||||
|
double sp = _readTargetRpm();
|
||||||
|
|
||||||
|
// Clamp and normalize to 0–1.0
|
||||||
|
pv = Scale(Clamp(pv, 0, _pvMax), 0, _pvMax, 0, 1.0);
|
||||||
|
sp = Scale(Clamp(sp, 0, _pvMax), 0, _pvMax, 0, 1.0);
|
||||||
|
|
||||||
|
double err = sp - pv;
|
||||||
|
|
||||||
|
// Time delta in seconds
|
||||||
|
double dt = _sw.Elapsed.TotalSeconds;
|
||||||
|
_sw.Restart();
|
||||||
|
if (dt <= 0) dt = _intervalMs / 1000.0;
|
||||||
|
|
||||||
|
// P term
|
||||||
|
double pTerm = _kp * err;
|
||||||
|
|
||||||
|
// I term (with anti-windup: only integrate when PV is in-range)
|
||||||
|
double partialSum = _errSum;
|
||||||
|
double iTerm = 0;
|
||||||
|
if (pv >= 0 && pv <= 1.0)
|
||||||
|
{
|
||||||
|
partialSum = _errSum + dt * err;
|
||||||
|
iTerm = _ki * partialSum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// D term (derivative-on-measurement, not on error)
|
||||||
|
double dTerm = 0;
|
||||||
|
if (dt > 0)
|
||||||
|
dTerm = _kd * (pv - _lastPv) / dt;
|
||||||
|
|
||||||
|
// Combine, clamp, scale to output voltage range
|
||||||
|
double output = Clamp(pTerm + iTerm + dTerm, 0, 1.0);
|
||||||
|
double voltage = Scale(output, 0, 1.0, 0, _outMax);
|
||||||
|
|
||||||
|
_sendVoltage(voltage);
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
_errSum = partialSum;
|
||||||
|
_lastPv = pv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static double Clamp(double value, double min, double max)
|
||||||
|
=> value > max ? max : value < min ? min : value;
|
||||||
|
|
||||||
|
private static double Scale(double value, double fromMin, double fromMax, double toMin, double toMax)
|
||||||
|
{
|
||||||
|
if (Math.Abs(fromMax - fromMin) < 1e-12) return toMin;
|
||||||
|
double pct = (value - fromMin) / (fromMax - fromMin);
|
||||||
|
return toMin + pct * (toMax - toMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
IsRunning = false;
|
||||||
|
_cts?.Cancel();
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,18 @@ namespace HC_APTBS.Services.Impl
|
|||||||
private CancellationTokenSource? _electronicMsgCts;
|
private CancellationTokenSource? _electronicMsgCts;
|
||||||
private volatile bool _electronicMsgActive;
|
private volatile bool _electronicMsgActive;
|
||||||
|
|
||||||
|
// Periodic relay bitmask sender
|
||||||
|
private const int RelaySendIntervalMs = 21;
|
||||||
|
private Task? _relaySendTask;
|
||||||
|
private CancellationTokenSource? _relaySendCts;
|
||||||
|
private volatile bool _relaySendActive;
|
||||||
|
|
||||||
|
// RPM PID ramp controller
|
||||||
|
private BenchPidController? _pidController;
|
||||||
|
private double _lastTargetRpm;
|
||||||
|
private double _lastCommandVoltage;
|
||||||
|
private double _pidTargetRpm;
|
||||||
|
|
||||||
// ── Events ────────────────────────────────────────────────────────────────
|
// ── Events ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -78,6 +90,8 @@ namespace HC_APTBS.Services.Impl
|
|||||||
public event Action<string, bool>? PhaseCompleted;
|
public event Action<string, bool>? PhaseCompleted;
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public event Action<string, double>? PumpControlValueSet;
|
public event Action<string, double>? PumpControlValueSet;
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event Action? RpmCommandSent;
|
||||||
|
|
||||||
// ── Constructor ───────────────────────────────────────────────────────────
|
// ── Constructor ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -96,6 +110,12 @@ namespace HC_APTBS.Services.Impl
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public bool IsAutoDfiEnabled => _autoDfiEnabled;
|
public bool IsAutoDfiEnabled => _autoDfiEnabled;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public double LastTargetRpm => _lastTargetRpm;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public double LastCommandVoltage => _lastCommandVoltage;
|
||||||
|
|
||||||
// ── IBenchService: active pump ────────────────────────────────────────────
|
// ── IBenchService: active pump ────────────────────────────────────────────
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -161,14 +181,93 @@ namespace HC_APTBS.Services.Impl
|
|||||||
double voltage = RpmVoltageRelation.VoltageForRpm(
|
double voltage = RpmVoltageRelation.VoltageForRpm(
|
||||||
(int)safeRpm, _config.Settings.Relations);
|
(int)safeRpm, _config.Settings.Relations);
|
||||||
|
|
||||||
// Write the voltage value into the RPM parameter and transmit.
|
SendRpmVoltage(voltage);
|
||||||
SetParameter(BenchParameterNames.Rpm, voltage);
|
|
||||||
SendParameters(_config.Bench.ParametersByName.TryGetValue(
|
|
||||||
BenchParameterNames.Rpm, out var rpmParam) ? rpmParam.MessageId : 0x0A);
|
|
||||||
|
|
||||||
|
_lastTargetRpm = safeRpm;
|
||||||
|
RpmCommandSent?.Invoke();
|
||||||
_log.Debug(LogId, $"SetRpm({safeRpm}) → voltage={voltage:F3}V");
|
_log.Debug(LogId, $"SetRpm({safeRpm}) → voltage={voltage:F3}V");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void StartRpmPid(double targetRpm)
|
||||||
|
{
|
||||||
|
// Stop any existing PID loop.
|
||||||
|
_pidController?.Stop();
|
||||||
|
|
||||||
|
double safeRpm = Math.Min(targetRpm, _config.Settings.SecurityRpmLimit);
|
||||||
|
_lastTargetRpm = safeRpm;
|
||||||
|
_pidTargetRpm = safeRpm;
|
||||||
|
|
||||||
|
if (safeRpm <= 0)
|
||||||
|
{
|
||||||
|
SendRpmVoltage(0);
|
||||||
|
RpmCommandSent?.Invoke();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Send initial voltage from lookup table (open-loop jump).
|
||||||
|
double initialVoltage = RpmVoltageRelation.VoltageForRpm(
|
||||||
|
(int)safeRpm, _config.Settings.Relations);
|
||||||
|
SendRpmVoltage(initialVoltage);
|
||||||
|
RpmCommandSent?.Invoke();
|
||||||
|
|
||||||
|
// Step 2: Calculate approach delay based on RPM distance.
|
||||||
|
double actualRpm = ReadBenchParameter(BenchParameterNames.BenchRpm);
|
||||||
|
int delaySec = (int)(Math.Abs(safeRpm - actualRpm) * 0.004 + 0.7);
|
||||||
|
|
||||||
|
// Step 3: Create PID controller and start after delay.
|
||||||
|
var settings = _config.Settings;
|
||||||
|
_pidController = new BenchPidController(
|
||||||
|
settings.PidP, settings.PidI, settings.PidD, settings.PidLoopMs,
|
||||||
|
settings.MaxRpm, settings.VoltageForMaxRpm,
|
||||||
|
() => ReadBenchParameter(BenchParameterNames.BenchRpm),
|
||||||
|
() => _pidTargetRpm,
|
||||||
|
SendRpmVoltage);
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(delaySec * 1000);
|
||||||
|
if (_pidTargetRpm > 0)
|
||||||
|
_pidController.Start(initialVoltage, actualRpm);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error(LogId, $"StartRpmPid delay task: {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_log.Info(LogId, $"StartRpmPid({safeRpm}) → initial voltage={initialVoltage:F3}V, PID delay={delaySec}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void StopRpmPid()
|
||||||
|
{
|
||||||
|
_pidTargetRpm = 0;
|
||||||
|
_pidController?.Stop();
|
||||||
|
_pidController?.Dispose();
|
||||||
|
_pidController = null;
|
||||||
|
_lastTargetRpm = 0;
|
||||||
|
SendRpmVoltage(0);
|
||||||
|
RpmCommandSent?.Invoke();
|
||||||
|
_log.Info(LogId, "StopRpmPid: PID stopped, 0V sent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a voltage value to the RPM CAN parameter and transmits it.
|
||||||
|
/// Used as the PID output callback and by <see cref="SetRpm"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void SendRpmVoltage(double volts)
|
||||||
|
{
|
||||||
|
if (volts < 0) volts = 0;
|
||||||
|
_lastCommandVoltage = volts;
|
||||||
|
|
||||||
|
SetParameter(BenchParameterNames.Rpm, volts);
|
||||||
|
SendParameters(_config.Bench.ParametersByName.TryGetValue(
|
||||||
|
BenchParameterNames.Rpm, out var rpmParam) ? rpmParam.MessageId : 0x10);
|
||||||
|
}
|
||||||
|
|
||||||
// ── IBenchService: temperature ────────────────────────────────────────────
|
// ── IBenchService: temperature ────────────────────────────────────────────
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -193,6 +292,58 @@ namespace HC_APTBS.Services.Impl
|
|||||||
TransmitRelayMask(relay.MessageId);
|
TransmitRelayMask(relay.MessageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SendInitialRelayState()
|
||||||
|
{
|
||||||
|
// Collect all distinct relay message IDs and transmit each one.
|
||||||
|
var sent = new HashSet<uint>();
|
||||||
|
foreach (var relay in _config.Bench.Relays.Values)
|
||||||
|
{
|
||||||
|
if (sent.Add(relay.MessageId))
|
||||||
|
TransmitRelayMask(relay.MessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void StartRelaySender()
|
||||||
|
{
|
||||||
|
if (_relaySendActive) return;
|
||||||
|
_relaySendActive = true;
|
||||||
|
|
||||||
|
_relaySendCts = new CancellationTokenSource();
|
||||||
|
var ct = _relaySendCts.Token;
|
||||||
|
|
||||||
|
_relaySendTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(RelaySendIntervalMs));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (_relaySendActive && await timer.WaitForNextTickAsync(ct))
|
||||||
|
{
|
||||||
|
var sent = new HashSet<uint>();
|
||||||
|
foreach (var relay in _config.Bench.Relays.Values)
|
||||||
|
{
|
||||||
|
if (sent.Add(relay.MessageId))
|
||||||
|
TransmitRelayMask(relay.MessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
_log.Debug(LogId, "Relay sender started.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void StopRelaySender()
|
||||||
|
{
|
||||||
|
_relaySendActive = false;
|
||||||
|
_relaySendCts?.Cancel();
|
||||||
|
_relaySendCts?.Dispose();
|
||||||
|
_relaySendCts = null;
|
||||||
|
_log.Debug(LogId, "Relay sender stopped.");
|
||||||
|
}
|
||||||
|
|
||||||
private void TransmitRelayMask(uint messageId)
|
private void TransmitRelayMask(uint messageId)
|
||||||
{
|
{
|
||||||
// Collect all relays mapped to this CAN message ID and pack their bits.
|
// Collect all relays mapped to this CAN message ID and pack their bits.
|
||||||
|
|||||||
@@ -577,38 +577,38 @@ namespace HC_APTBS.Services.Impl
|
|||||||
/// <summary>Returns the factory-default bench parameter XML used when bench.xml is absent.</summary>
|
/// <summary>Returns the factory-default bench parameter XML used when bench.xml is absent.</summary>
|
||||||
private static string DefaultBenchXml() => @"<?xml version=""1.0"" encoding=""utf-8""?>
|
private static string DefaultBenchXml() => @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||||
<Bench>
|
<Bench>
|
||||||
<RPM id=""A"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
<RPM id=""10"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
||||||
<Counter id=""B"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
<Counter id=""11"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
||||||
<BaudRate id=""37"" byteh=""0"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
<BaudRate id=""55"" byteh=""0"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
||||||
<BenchRPM id=""D"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" />
|
<BenchRPM id=""13"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" />
|
||||||
<BenchCounter id=""D"" byteh=""3"" bytel=""2"" filter=""1"" disableparams=""true"" />
|
<BenchCounter id=""13"" byteh=""3"" bytel=""2"" filter=""1"" disableparams=""true"" />
|
||||||
<BenchTemp id=""E"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
<BenchTemp id=""14"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
||||||
<T-in id=""E"" byteh=""3"" bytel=""2"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
<T-in id=""14"" byteh=""3"" bytel=""2"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
||||||
<T-out id=""E"" byteh=""5"" bytel=""4"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
<T-out id=""14"" byteh=""5"" bytel=""4"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
||||||
<T4 id=""E"" byteh=""7"" bytel=""6"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
<T4 id=""14"" byteh=""7"" bytel=""6"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
||||||
<QDelivery id=""8"" byteh=""5"" bytel=""3"" filter=""0.01"" disableparams=""false"" p1=""0"" p2=""2.03"" p3=""1E-06"" p4=""0"" p5=""0"" p6=""0"" />
|
<QDelivery id=""8"" byteh=""5"" bytel=""3"" filter=""0.01"" disableparams=""false"" p1=""0"" p2=""2.03"" p3=""1E-06"" p4=""0"" p5=""0"" p6=""0"" />
|
||||||
<QOver id=""8"" byteh=""2"" bytel=""0"" filter=""0.11"" disableparams=""false"" p1=""0"" p2=""0.51"" p3=""1E-06"" p4=""0"" p5=""0"" p6=""0"" />
|
<QOver id=""8"" byteh=""2"" bytel=""0"" filter=""0.11"" disableparams=""false"" p1=""0"" p2=""0.51"" p3=""1E-06"" p4=""0"" p5=""0"" p6=""0"" />
|
||||||
<PSGEncoderValue id=""32"" byteh=""4"" bytel=""5"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
<PSGEncoderValue id=""50"" byteh=""4"" bytel=""5"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
||||||
<PSGEncoderWorking id=""32"" byteh=""7"" bytel=""7"" filter=""1"" disableparams=""true"" />
|
<PSGEncoderWorking id=""50"" byteh=""7"" bytel=""7"" filter=""1"" disableparams=""true"" />
|
||||||
<InyectorEncoderValue id=""32"" byteh=""2"" bytel=""3"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
<InyectorEncoderValue id=""50"" byteh=""2"" bytel=""3"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
||||||
<InyectorEncoderWorking id=""32"" byteh=""6"" bytel=""6"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
<InyectorEncoderWorking id=""50"" byteh=""6"" bytel=""6"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
||||||
<ManualEncoderValue id=""32"" byteh=""0"" bytel=""1"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
<ManualEncoderValue id=""50"" byteh=""0"" bytel=""1"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
||||||
<EncoderResolution id=""33"" byteh=""6"" bytel=""7"" filter=""1"" disableparams=""true"" send=""true"" />
|
<EncoderResolution id=""51"" byteh=""6"" bytel=""7"" filter=""1"" disableparams=""true"" send=""true"" />
|
||||||
<ElectronicMsg id=""33"" byteh=""0"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
<ElectronicMsg id=""51"" byteh=""0"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
||||||
<Alarms id=""8"" byteh=""7"" bytel=""6"" filter=""1"" disableparams=""true"" />
|
<Alarms id=""8"" byteh=""7"" bytel=""6"" filter=""1"" disableparams=""true"" />
|
||||||
<Pressure id=""D"" byteh=""4"" bytel=""5"" filter=""1"" disableparams=""true"" />
|
<Pressure id=""13"" byteh=""4"" bytel=""5"" filter=""1"" disableparams=""true"" />
|
||||||
<AnalogicSensor2 id=""D"" byteh=""6"" bytel=""7"" filter=""1"" disableparams=""true"" />
|
<AnalogicSensor2 id=""13"" byteh=""6"" bytel=""7"" filter=""1"" disableparams=""true"" />
|
||||||
<Reles>
|
<Reles>
|
||||||
<Rele name=""Electronic"" id=""F"" bit=""0"" />
|
<Rele name=""Electronic"" id=""15"" bit=""0"" />
|
||||||
<Rele name=""OilPump"" id=""F"" bit=""4"" />
|
<Rele name=""OilPump"" id=""15"" bit=""4"" />
|
||||||
<Rele name=""DepositCooler"" id=""F"" bit=""8"" />
|
<Rele name=""DepositCooler"" id=""15"" bit=""8"" />
|
||||||
<Rele name=""DepositHeater"" id=""F"" bit=""12"" />
|
<Rele name=""DepositHeater"" id=""15"" bit=""12"" />
|
||||||
<Rele name=""Reserve"" id=""F"" bit=""16"" />
|
<Rele name=""Reserve"" id=""15"" bit=""16"" />
|
||||||
<Rele name=""Counter"" id=""F"" bit=""20"" />
|
<Rele name=""Counter"" id=""15"" bit=""20"" />
|
||||||
<Rele name=""Direction"" id=""F"" bit=""24"" />
|
<Rele name=""Direction"" id=""15"" bit=""24"" />
|
||||||
<Rele name=""TinCooler"" id=""F"" bit=""28"" />
|
<Rele name=""TinCooler"" id=""15"" bit=""28"" />
|
||||||
<Rele name=""Pulse4Signal"" id=""F"" bit=""32"" />
|
<Rele name=""Pulse4Signal"" id=""15"" bit=""32"" />
|
||||||
<Rele name=""Flasher"" id=""F"" bit=""44"" />
|
<Rele name=""Flasher"" id=""15"" bit=""44"" />
|
||||||
</Reles>
|
</Reles>
|
||||||
</Bench>";
|
</Bench>";
|
||||||
|
|
||||||
|
|||||||
193
ViewModels/BenchControlViewModel.cs
Normal file
193
ViewModels/BenchControlViewModel.cs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using HC_APTBS.Models;
|
||||||
|
using HC_APTBS.Services;
|
||||||
|
|
||||||
|
namespace HC_APTBS.ViewModels
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ViewModel for manual bench controls: direction toggle, RPM start/stop with
|
||||||
|
/// PID ramp, oil pump toggle, and turn downcounter.
|
||||||
|
/// Created by <see cref="MainViewModel"/> as a child ViewModel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class BenchControlViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IBenchService _bench;
|
||||||
|
private readonly IConfigurationService _config;
|
||||||
|
|
||||||
|
// ── Direction ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>True when the bench rotates clockwise (right). False for counter-clockwise (left).</summary>
|
||||||
|
[ObservableProperty] private bool _isDirectionRight = true;
|
||||||
|
|
||||||
|
// ── Oil pump ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>True when the oil pump relay is energised.</summary>
|
||||||
|
[ObservableProperty] private bool _isOilPumpOn;
|
||||||
|
|
||||||
|
// ── RPM control ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>True when the bench motor is running (last RPM command > 0).</summary>
|
||||||
|
[ObservableProperty] private bool _isBenchRunning;
|
||||||
|
|
||||||
|
/// <summary>Controls the RPM quick-select popup visibility.</summary>
|
||||||
|
[ObservableProperty] private bool _isRpmPopupOpen;
|
||||||
|
|
||||||
|
/// <summary>Text in the RPM input field.</summary>
|
||||||
|
[ObservableProperty] private string _rpmInputText = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Last RPM setpoint commanded via PID or direct set.</summary>
|
||||||
|
[ObservableProperty] private double _targetRpm;
|
||||||
|
|
||||||
|
/// <summary>Last voltage sent to the motor CAN parameter.</summary>
|
||||||
|
[ObservableProperty] private double _commandVoltage;
|
||||||
|
|
||||||
|
// ── Counter ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Controls the counter popup visibility.</summary>
|
||||||
|
[ObservableProperty] private bool _isCounterPopupOpen;
|
||||||
|
|
||||||
|
/// <summary>Text in the counter input field.</summary>
|
||||||
|
[ObservableProperty] private string _counterInputText = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Live counter count-down value read from CAN.</summary>
|
||||||
|
[ObservableProperty] private double _benchCounterValue;
|
||||||
|
|
||||||
|
// ── Constructor ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the bench control ViewModel and subscribes to service events.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="benchService">Bench service for RPM, relay, and parameter operations.</param>
|
||||||
|
/// <param name="configService">Configuration service for bench parameters.</param>
|
||||||
|
public BenchControlViewModel(IBenchService benchService, IConfigurationService configService)
|
||||||
|
{
|
||||||
|
_bench = benchService;
|
||||||
|
_config = configService;
|
||||||
|
|
||||||
|
_bench.RpmCommandSent += () =>
|
||||||
|
{
|
||||||
|
TargetRpm = _bench.LastTargetRpm;
|
||||||
|
CommandVoltage = _bench.LastCommandVoltage;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Direction toggle ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
partial void OnIsDirectionRightChanged(bool value)
|
||||||
|
{
|
||||||
|
_bench.SetRelay(RelayNames.DirectionRight, value);
|
||||||
|
_bench.SetRelay(RelayNames.DirectionLeft, !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Oil pump toggle ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
partial void OnIsOilPumpOnChanged(bool value)
|
||||||
|
{
|
||||||
|
_bench.SetRelay(RelayNames.OilPump, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RPM commands ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Opens the RPM quick-select popup.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenRpmPopup()
|
||||||
|
{
|
||||||
|
IsRpmPopupOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the bench motor at the RPM specified in <see cref="RpmInputText"/>.
|
||||||
|
/// Warns the operator if the oil pump is off.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void StartBench()
|
||||||
|
{
|
||||||
|
if (!int.TryParse(RpmInputText, out int rpm) || rpm <= 0) return;
|
||||||
|
|
||||||
|
// Safety warning if oil pump is not running.
|
||||||
|
if (!IsOilPumpOn)
|
||||||
|
{
|
||||||
|
var result = MessageBox.Show(
|
||||||
|
"Oil pump is OFF. Start bench without oil circulation?",
|
||||||
|
"Oil Pump Warning",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
if (result != MessageBoxResult.Yes) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure direction relays are set.
|
||||||
|
_bench.SetRelay(RelayNames.DirectionRight, IsDirectionRight);
|
||||||
|
_bench.SetRelay(RelayNames.DirectionLeft, !IsDirectionRight);
|
||||||
|
|
||||||
|
_bench.StartRpmPid(rpm);
|
||||||
|
IsBenchRunning = true;
|
||||||
|
IsRpmPopupOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the bench motor via PID stop and clears direction relays.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void StopBench()
|
||||||
|
{
|
||||||
|
_bench.StopRpmPid();
|
||||||
|
_bench.SetRelay(RelayNames.DirectionLeft, false);
|
||||||
|
_bench.SetRelay(RelayNames.DirectionRight, false);
|
||||||
|
IsBenchRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quick-select button handler: sets the RPM input and starts the bench.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rpmString">RPM value as a string from the button content.</param>
|
||||||
|
[RelayCommand]
|
||||||
|
private void SetQuickRpm(string rpmString)
|
||||||
|
{
|
||||||
|
RpmInputText = rpmString;
|
||||||
|
StartBench();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Counter commands ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Toggles the counter popup visibility.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleCounterPopup()
|
||||||
|
{
|
||||||
|
IsCounterPopupOpen = !IsCounterPopupOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the counter value over CAN and activates the counter relay.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void SendCounter()
|
||||||
|
{
|
||||||
|
if (!int.TryParse(CounterInputText, out int count) || count <= 0) return;
|
||||||
|
|
||||||
|
// Set the counter parameter value and transmit.
|
||||||
|
_bench.SetParameter(BenchParameterNames.Counter, count);
|
||||||
|
if (_config.Bench.ParametersByName.TryGetValue(
|
||||||
|
BenchParameterNames.Counter, out var counterParam))
|
||||||
|
{
|
||||||
|
_bench.SendParameters(counterParam.MessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate the counter relay.
|
||||||
|
_bench.SetRelay(RelayNames.Counter, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refresh (called from MainViewModel timer tick) ────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates live counter readback from CAN.
|
||||||
|
/// Called on the UI thread from <see cref="MainViewModel.OnRefreshTick"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void RefreshFromTick()
|
||||||
|
{
|
||||||
|
BenchCounterValue = _bench.ReadBenchParameter(BenchParameterNames.BenchCounter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
ViewModels/FlowmeterChartViewModel.cs
Normal file
51
ViewModels/FlowmeterChartViewModel.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
using HC_APTBS.Models;
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace HC_APTBS.ViewModels
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Container ViewModel holding two real-time flowmeter charts:
|
||||||
|
/// one for Q-Delivery and one for Q-Over.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FlowmeterChartViewModel
|
||||||
|
{
|
||||||
|
/// <summary>Chart for the Q-Delivery flowmeter (amber line).</summary>
|
||||||
|
public SingleFlowChartViewModel Delivery { get; }
|
||||||
|
= new("Q-Delivery", new SKColor(0xFF, 0xAE, 0x00));
|
||||||
|
|
||||||
|
/// <summary>Chart for the Q-Over flowmeter (blue line).</summary>
|
||||||
|
public SingleFlowChartViewModel Over { get; }
|
||||||
|
= new("Q-Over", new SKColor(0x40, 0x80, 0xFF));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends a sample pair to both charts.
|
||||||
|
/// Must be called on the UI thread.
|
||||||
|
/// </summary>
|
||||||
|
public void AddSamples(double qDelivery, double qOver)
|
||||||
|
{
|
||||||
|
Delivery.AddValue(qDelivery);
|
||||||
|
Over.AddValue(qOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the tolerance band on the appropriate chart.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="paramName">Parameter name (use <see cref="BenchParameterNames"/>).</param>
|
||||||
|
/// <param name="target">Center value.</param>
|
||||||
|
/// <param name="tolerance">Half-width.</param>
|
||||||
|
public void SetTolerance(string paramName, double target, double tolerance)
|
||||||
|
{
|
||||||
|
if (paramName == BenchParameterNames.QDelivery)
|
||||||
|
Delivery.SetTolerance(target, tolerance);
|
||||||
|
else if (paramName == BenchParameterNames.QOver)
|
||||||
|
Over.SetTolerance(target, tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clears both charts.</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
Delivery.Clear();
|
||||||
|
Over.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
@@ -59,6 +60,12 @@ namespace HC_APTBS.ViewModels
|
|||||||
/// <summary>ViewModel for the manual pump control sliders (FBKW, ME, PreIn).</summary>
|
/// <summary>ViewModel for the manual pump control sliders (FBKW, ME, PreIn).</summary>
|
||||||
public PumpControlViewModel PumpControl { get; private set; } = null!;
|
public PumpControlViewModel PumpControl { get; private set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>ViewModel for manual bench controls (direction, RPM, oil pump, counter).</summary>
|
||||||
|
public BenchControlViewModel BenchControl { get; }
|
||||||
|
|
||||||
|
/// <summary>ViewModel for the two flowmeter real-time charts (Q-Delivery, Q-Over).</summary>
|
||||||
|
public FlowmeterChartViewModel FlowmeterChart { get; } = new();
|
||||||
|
|
||||||
/// <summary>ViewModel for the first pump status display (Status word).</summary>
|
/// <summary>ViewModel for the first pump status display (Status word).</summary>
|
||||||
public StatusDisplayViewModel StatusDisplay1 { get; } = new();
|
public StatusDisplayViewModel StatusDisplay1 { get; } = new();
|
||||||
|
|
||||||
@@ -90,6 +97,7 @@ namespace HC_APTBS.ViewModels
|
|||||||
PumpIdentification = new PumpIdentificationViewModel(kwpService, configService, logger);
|
PumpIdentification = new PumpIdentificationViewModel(kwpService, configService, logger);
|
||||||
DfiViewModel = new DfiManageViewModel(kwpService, configService);
|
DfiViewModel = new DfiManageViewModel(kwpService, configService);
|
||||||
PumpControl = new PumpControlViewModel(benchService);
|
PumpControl = new PumpControlViewModel(benchService);
|
||||||
|
BenchControl = new BenchControlViewModel(benchService, configService);
|
||||||
|
|
||||||
// React to pump changes from the identification child VM.
|
// React to pump changes from the identification child VM.
|
||||||
PumpIdentification.PumpChanged += OnPumpChanged;
|
PumpIdentification.PumpChanged += OnPumpChanged;
|
||||||
@@ -106,6 +114,12 @@ namespace HC_APTBS.ViewModels
|
|||||||
IsCanConnected = ok;
|
IsCanConnected = ok;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bench/pump liveness → connection indicators
|
||||||
|
_can.BenchLivenessChanged += alive =>
|
||||||
|
App.Current.Dispatcher.Invoke(() => IsBenchConnected = alive);
|
||||||
|
_can.PumpLivenessChanged += alive =>
|
||||||
|
App.Current.Dispatcher.Invoke(() => IsPumpConnected = alive);
|
||||||
|
|
||||||
// Bench service events
|
// Bench service events
|
||||||
_bench.TestStarted += OnTestStarted;
|
_bench.TestStarted += OnTestStarted;
|
||||||
_bench.TestFinished += OnTestFinished;
|
_bench.TestFinished += OnTestFinished;
|
||||||
@@ -123,8 +137,12 @@ namespace HC_APTBS.ViewModels
|
|||||||
() => ShowPsgSyncError());
|
() => ShowPsgSyncError());
|
||||||
_bench.PhaseCompleted += (phase, passed) => App.Current.Dispatcher.Invoke(
|
_bench.PhaseCompleted += (phase, passed) => App.Current.Dispatcher.Invoke(
|
||||||
() => TestPanel.SetPhaseResult(phase, passed));
|
() => TestPanel.SetPhaseResult(phase, passed));
|
||||||
_bench.ToleranceUpdated += (paramName, value, _) => App.Current.Dispatcher.Invoke(
|
_bench.ToleranceUpdated += (paramName, value, tolerance) => App.Current.Dispatcher.Invoke(
|
||||||
() => TestPanel.UpdateLiveIndicator(paramName, value));
|
() =>
|
||||||
|
{
|
||||||
|
TestPanel.UpdateLiveIndicator(paramName, value);
|
||||||
|
FlowmeterChart.SetTolerance(paramName, value, tolerance);
|
||||||
|
});
|
||||||
|
|
||||||
// Unlock service status → verbose display
|
// Unlock service status → verbose display
|
||||||
_unlock.StatusChanged += msg => App.Current.Dispatcher.Invoke(
|
_unlock.StatusChanged += msg => App.Current.Dispatcher.Invoke(
|
||||||
@@ -156,6 +174,7 @@ namespace HC_APTBS.ViewModels
|
|||||||
|
|
||||||
// Register the pump's CAN parameters with the bus adapter.
|
// Register the pump's CAN parameters with the bus adapter.
|
||||||
_can.AddParameters(pump.ParametersById);
|
_can.AddParameters(pump.ParametersById);
|
||||||
|
_can.RegisterPumpMessageIds(GetReceiveMessageIds(pump.ParametersById));
|
||||||
|
|
||||||
// Configure pump control sliders.
|
// Configure pump control sliders.
|
||||||
PumpControl.IsPreInVisible = pump.HasPreInjection;
|
PumpControl.IsPreInVisible = pump.HasPreInjection;
|
||||||
@@ -177,8 +196,8 @@ namespace HC_APTBS.ViewModels
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start periodic senders for the new pump.
|
// Start periodic senders for the new pump.
|
||||||
_bench.StartElectronicMsgSender();
|
|
||||||
_bench.StartMemoryRequestSender();
|
_bench.StartMemoryRequestSender();
|
||||||
|
_bench.StartPumpSender();
|
||||||
|
|
||||||
// Notify commands that depend on pump availability.
|
// Notify commands that depend on pump availability.
|
||||||
StartTestCommand.NotifyCanExecuteChanged();
|
StartTestCommand.NotifyCanExecuteChanged();
|
||||||
@@ -198,9 +217,18 @@ namespace HC_APTBS.ViewModels
|
|||||||
private void ConnectCan()
|
private void ConnectCan()
|
||||||
{
|
{
|
||||||
_can.SetParameters(_config.Bench.ParametersById);
|
_can.SetParameters(_config.Bench.ParametersById);
|
||||||
|
_can.RegisterBenchMessageIds(GetReceiveMessageIds(_config.Bench.ParametersById));
|
||||||
bool ok = _can.Connect();
|
bool ok = _can.Connect();
|
||||||
CanStatusText = ok ? "Connected" : "Connection failed";
|
CanStatusText = ok ? "Connected" : "Connection failed";
|
||||||
IsCanConnected = ok;
|
IsCanConnected = ok;
|
||||||
|
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
// ElectronicMsg keepalive (0x51) and relay bitmask (0x15) must
|
||||||
|
// begin transmitting as soon as the CAN bus is up.
|
||||||
|
_bench.StartElectronicMsgSender();
|
||||||
|
_bench.StartRelaySender();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Disconnects from the CAN bus adapter.</summary>
|
/// <summary>Disconnects from the CAN bus adapter.</summary>
|
||||||
@@ -208,6 +236,7 @@ namespace HC_APTBS.ViewModels
|
|||||||
private void DisconnectCan()
|
private void DisconnectCan()
|
||||||
{
|
{
|
||||||
_bench.StopElectronicMsgSender();
|
_bench.StopElectronicMsgSender();
|
||||||
|
_bench.StopRelaySender();
|
||||||
_bench.StopMemoryRequestSender();
|
_bench.StopMemoryRequestSender();
|
||||||
_bench.StopPumpSender();
|
_bench.StopPumpSender();
|
||||||
_can.Disconnect();
|
_can.Disconnect();
|
||||||
@@ -269,6 +298,9 @@ namespace HC_APTBS.ViewModels
|
|||||||
/// <summary>True when oil circulation has been detected.</summary>
|
/// <summary>True when oil circulation has been detected.</summary>
|
||||||
[ObservableProperty] private bool _isOilCirculating;
|
[ObservableProperty] private bool _isOilCirculating;
|
||||||
|
|
||||||
|
/// <summary>True when a K-Line session is active.</summary>
|
||||||
|
[ObservableProperty] private bool _isKLineConnected;
|
||||||
|
|
||||||
// ── Test status ───────────────────────────────────────────────────────────
|
// ── Test status ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>True while a test sequence is running.</summary>
|
/// <summary>True while a test sequence is running.</summary>
|
||||||
@@ -407,7 +439,14 @@ namespace HC_APTBS.ViewModels
|
|||||||
|
|
||||||
// Connect CAN bus.
|
// Connect CAN bus.
|
||||||
_can.SetParameters(_config.Bench.ParametersById);
|
_can.SetParameters(_config.Bench.ParametersById);
|
||||||
_can.Connect();
|
_can.RegisterBenchMessageIds(GetReceiveMessageIds(_config.Bench.ParametersById));
|
||||||
|
bool canOk = _can.Connect();
|
||||||
|
|
||||||
|
if (canOk)
|
||||||
|
{
|
||||||
|
_bench.StartElectronicMsgSender();
|
||||||
|
_bench.StartRelaySender();
|
||||||
|
}
|
||||||
|
|
||||||
// Start the UI refresh timer.
|
// Start the UI refresh timer.
|
||||||
StartRefreshTimer();
|
StartRefreshTimer();
|
||||||
@@ -442,6 +481,10 @@ namespace HC_APTBS.ViewModels
|
|||||||
Pressure = _bench.ReadBenchParameter(BenchParameterNames.Pressure);
|
Pressure = _bench.ReadBenchParameter(BenchParameterNames.Pressure);
|
||||||
PsgEncoderValue = _bench.ReadBenchParameter(BenchParameterNames.PsgEncoderValue);
|
PsgEncoderValue = _bench.ReadBenchParameter(BenchParameterNames.PsgEncoderValue);
|
||||||
|
|
||||||
|
// Feed flowmeter charts and refresh bench controls.
|
||||||
|
FlowmeterChart.AddSamples(QDelivery, QOver);
|
||||||
|
BenchControl.RefreshFromTick();
|
||||||
|
|
||||||
if (CurrentPump != null)
|
if (CurrentPump != null)
|
||||||
{
|
{
|
||||||
PumpRpm = _bench.ReadPumpParameter(PumpParameterNames.Rpm);
|
PumpRpm = _bench.ReadPumpParameter(PumpParameterNames.Rpm);
|
||||||
@@ -520,5 +563,24 @@ namespace HC_APTBS.ViewModels
|
|||||||
=> MessageBox.Show(
|
=> MessageBox.Show(
|
||||||
"PSG sync pulse not detected. Check encoder connection.",
|
"PSG sync pulse not detected. Check encoder connection.",
|
||||||
"PSG Error", MessageBoxButton.OK, MessageBoxImage.Warning);
|
"PSG Error", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns only the message IDs that contain at least one receive parameter.
|
||||||
|
/// Transmit-only IDs (RPM command, ElectronicMsg, etc.) are excluded because
|
||||||
|
/// they are frames we send, not frames the remote device sends to us.
|
||||||
|
/// </summary>
|
||||||
|
private static HashSet<uint> GetReceiveMessageIds(
|
||||||
|
Dictionary<uint, List<CanBusParameter>> parametersById)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<uint>();
|
||||||
|
foreach (var kv in parametersById)
|
||||||
|
{
|
||||||
|
if (kv.Value.Any(p => p.IsReceive))
|
||||||
|
ids.Add(kv.Key);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
122
ViewModels/SingleFlowChartViewModel.cs
Normal file
122
ViewModels/SingleFlowChartViewModel.cs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using LiveChartsCore;
|
||||||
|
using LiveChartsCore.Defaults;
|
||||||
|
using LiveChartsCore.SkiaSharpView;
|
||||||
|
using LiveChartsCore.SkiaSharpView.Painting;
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace HC_APTBS.ViewModels
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reusable ViewModel for a single real-time scrolling line chart.
|
||||||
|
/// Backed by LiveChartsCore with a fixed-width sample window.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class SingleFlowChartViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private const int DefaultMaxSamples = 200;
|
||||||
|
|
||||||
|
private readonly ObservableCollection<double> _values = new();
|
||||||
|
private readonly int _maxSamples;
|
||||||
|
|
||||||
|
/// <summary>Chart title label.</summary>
|
||||||
|
[ObservableProperty] private string _title = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Series array bound to the CartesianChart.</summary>
|
||||||
|
public ISeries[] Series { get; }
|
||||||
|
|
||||||
|
/// <summary>X axes for the chart (auto-scrolling, no labels).</summary>
|
||||||
|
public Axis[] XAxes { get; }
|
||||||
|
|
||||||
|
/// <summary>Y axes for the chart.</summary>
|
||||||
|
public Axis[] YAxes { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tolerance band sections overlaid on the chart.
|
||||||
|
/// Updated when <see cref="SetTolerance"/> is called.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty] private RectangularSection[] _sections = Array.Empty<RectangularSection>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new chart ViewModel.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="title">Display title for the chart.</param>
|
||||||
|
/// <param name="lineColor">SKColor for the line series.</param>
|
||||||
|
/// <param name="maxSamples">Maximum number of samples before the oldest is dropped.</param>
|
||||||
|
public SingleFlowChartViewModel(string title, SKColor lineColor, int maxSamples = DefaultMaxSamples)
|
||||||
|
{
|
||||||
|
_title = title;
|
||||||
|
_maxSamples = maxSamples;
|
||||||
|
|
||||||
|
Series = new ISeries[]
|
||||||
|
{
|
||||||
|
new LineSeries<double>
|
||||||
|
{
|
||||||
|
Values = _values,
|
||||||
|
Fill = null,
|
||||||
|
GeometrySize = 0,
|
||||||
|
Stroke = new SolidColorPaint(lineColor, 2),
|
||||||
|
LineSmoothness = 0,
|
||||||
|
AnimationsSpeed = TimeSpan.Zero
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
XAxes = new Axis[]
|
||||||
|
{
|
||||||
|
new Axis
|
||||||
|
{
|
||||||
|
IsVisible = false,
|
||||||
|
AnimationsSpeed = TimeSpan.Zero
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
YAxes = new Axis[]
|
||||||
|
{
|
||||||
|
new Axis
|
||||||
|
{
|
||||||
|
AnimationsSpeed = TimeSpan.Zero,
|
||||||
|
MinLimit = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends a value to the chart. Drops the oldest value when the window is full.
|
||||||
|
/// Must be called on the UI thread.
|
||||||
|
/// </summary>
|
||||||
|
public void AddValue(double value)
|
||||||
|
{
|
||||||
|
_values.Add(value);
|
||||||
|
if (_values.Count > _maxSamples)
|
||||||
|
_values.RemoveAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the tolerance band (displayed as a shaded region on the chart).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="target">Center value of the tolerance band.</param>
|
||||||
|
/// <param name="tolerance">Half-width of the tolerance band.</param>
|
||||||
|
public void SetTolerance(double target, double tolerance)
|
||||||
|
{
|
||||||
|
Sections = new[]
|
||||||
|
{
|
||||||
|
new RectangularSection
|
||||||
|
{
|
||||||
|
Yi = target - tolerance,
|
||||||
|
Yj = target + tolerance,
|
||||||
|
Fill = new SolidColorPaint(SKColors.LimeGreen.WithAlpha(40))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears all sample data and tolerance bands.
|
||||||
|
/// </summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_values.Clear();
|
||||||
|
Sections = Array.Empty<RectangularSection>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Views/UserControls/FlowmeterChartView.xaml
Normal file
23
Views/UserControls/FlowmeterChartView.xaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<UserControl x:Class="HC_APTBS.Views.UserControls.FlowmeterChartView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF">
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding Title}" FontSize="11" FontWeight="SemiBold"
|
||||||
|
Foreground="DimGray" Margin="4,0"/>
|
||||||
|
|
||||||
|
<lvc:CartesianChart Grid.Row="1" Height="120"
|
||||||
|
Series="{Binding Series}"
|
||||||
|
XAxes="{Binding XAxes}"
|
||||||
|
YAxes="{Binding YAxes}"
|
||||||
|
Sections="{Binding Sections}"
|
||||||
|
TooltipPosition="Hidden"
|
||||||
|
AnimationsSpeed="00:00:00"/>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
16
Views/UserControls/FlowmeterChartView.xaml.cs
Normal file
16
Views/UserControls/FlowmeterChartView.xaml.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace HC_APTBS.Views.UserControls
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// UserControl hosting a single real-time flowmeter chart.
|
||||||
|
/// DataContext is expected to be a <see cref="HC_APTBS.ViewModels.SingleFlowChartViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class FlowmeterChartView : UserControl
|
||||||
|
{
|
||||||
|
public FlowmeterChartView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user