Files
HC_APTBS/Services/Impl/BenchPidController.cs
LucianoDev e343006f45 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>
2026-04-11 14:24:59 +02:00

205 lines
8.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 01.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 01.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;
}
}
}