Charts - Add faint background grid (0.75px, #E0E0E0) to all live charts; matches PDF report style - Show min/max tolerance bands on P1/P2 pressure charts during test runs (previously only Q-Delivery/Q-Over) - Broaden BenchService.ToleranceUpdated to fire for every phase receive; UI routes by name - Clear P1/P2 traces on PhaseChanged alongside Delivery/Over CAN - Normalize QDelivery flow rate to 1000 RPM reference before IIR filter so RPM spikes are low-pass filtered with flow-rate transients (matches old_source behavior) Pump page - Reorder columns: identification left, commands center, live data right - PreIn control always visible; disabled when pump lacks pre-injection (rename IsPreInVisible -> IsPreInAvailable) - Swap value/label order in command cards - Remove redundant KlineErrors row from identification card Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1154 lines
47 KiB
C#
1154 lines
47 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Diagnostics;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using HC_APTBS.Models;
|
||
|
||
namespace HC_APTBS.Services.Impl
|
||
{
|
||
/// <summary>
|
||
/// Implements the full bench test orchestration: RPM control, temperature PID,
|
||
/// relay management, and phase-by-phase test execution.
|
||
///
|
||
/// <para>
|
||
/// Test execution runs in a <see cref="Task"/> (background thread). All events
|
||
/// are raised on that thread — consumers (ViewModels) must marshal to the UI thread.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class BenchService : IBenchService
|
||
{
|
||
// ── Dependencies ──────────────────────────────────────────────────────────
|
||
|
||
private readonly ICanService _can;
|
||
private readonly IConfigurationService _config;
|
||
private readonly IAppLogger _log;
|
||
private const string LogId = "BenchService";
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────
|
||
|
||
private PumpDefinition? _activePump;
|
||
private CancellationTokenSource? _cts;
|
||
private volatile bool _running;
|
||
|
||
// Temperature PID state
|
||
private double _temperatureSetpoint;
|
||
private double _temperatureTolerance;
|
||
|
||
// DFI auto-adjust flag (set by the UI)
|
||
private volatile bool _autoDfiEnabled;
|
||
|
||
// Periodic pump parameter CAN sender
|
||
private Task? _pumpSendTask;
|
||
private CancellationTokenSource? _pumpSendCts;
|
||
private volatile bool _pumpSendingActive;
|
||
private double _targetMe;
|
||
private double _targetFbkw;
|
||
private double _targetPreIn;
|
||
|
||
// Periodic MemoryRequest sender (for Tein acquisition)
|
||
private Task? _memoryRequestTask;
|
||
private CancellationTokenSource? _memoryRequestCts;
|
||
private volatile bool _memoryRequestActive;
|
||
|
||
// Periodic ElectronicMsg keepalive sender
|
||
private Task? _electronicMsgTask;
|
||
private CancellationTokenSource? _electronicMsgCts;
|
||
private volatile bool _electronicMsgActive;
|
||
|
||
// Periodic relay bitmask sender
|
||
private const int RelaySendIntervalMs = 21;
|
||
private Task? _relaySendTask;
|
||
private CancellationTokenSource? _relaySendCts;
|
||
private volatile bool _relaySendActive;
|
||
|
||
// Alarm bitmask snapshot for edge detection during test phases
|
||
private int _lastAlarmMask;
|
||
|
||
// Active pump's status-word definition, cached when a test starts.
|
||
// Used by PollPumpStatusReactions to evaluate reaction codes per (bit,state).
|
||
private PumpStatusDefinition? _activeStatusDef;
|
||
|
||
// Tracks which (bit, state) pairs have already fired their reaction during
|
||
// the current phase so we don't re-fire every tick. Entry value true = fired.
|
||
private readonly Dictionary<(int bit, int state), bool> _statusReactionEdge = new();
|
||
|
||
// QOver zero-flow safety debounce (elapsed ms from phase stopwatch)
|
||
private long _qOverZeroSinceMs;
|
||
private const int QOverDebounceSec = 3;
|
||
|
||
// RPM PID ramp controller
|
||
private BenchPidController? _pidController;
|
||
private double _lastTargetRpm;
|
||
private double _lastCommandVoltage;
|
||
private double _pidTargetRpm;
|
||
|
||
// ── Events ────────────────────────────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public event Action? TestStarted;
|
||
/// <inheritdoc/>
|
||
public event Action<bool, bool>? TestFinished;
|
||
/// <inheritdoc/>
|
||
public event Action<string>? PhaseChanged;
|
||
/// <inheritdoc/>
|
||
public event Action<string>? VerboseMessage;
|
||
/// <inheritdoc/>
|
||
public event Action? PsgSyncError;
|
||
/// <inheritdoc/>
|
||
public event Action? LockAngleFaseReady;
|
||
/// <inheritdoc/>
|
||
public event Action? PsgModeFaseReady;
|
||
/// <inheritdoc/>
|
||
public event Action<string, double, double>? ToleranceUpdated;
|
||
/// <inheritdoc/>
|
||
public event Action<string, bool>? PhaseCompleted;
|
||
/// <inheritdoc/>
|
||
public event Action<string, double>? PumpControlValueSet;
|
||
/// <inheritdoc/>
|
||
public event Action? RpmCommandSent;
|
||
/// <inheritdoc/>
|
||
public event Action<string, double>? MeasurementSampled;
|
||
/// <inheritdoc/>
|
||
public event Action<string>? EmergencyStopTriggered;
|
||
/// <inheritdoc/>
|
||
public event Action<string, int, int>? PhaseTimerTick;
|
||
/// <inheritdoc/>
|
||
public event Action<int, int, string>? StatusReactionTriggered;
|
||
|
||
// Section labels for PhaseTimerTick — constants avoid per-tick string allocations.
|
||
private const string SectionConditioning = "Conditioning";
|
||
private const string SectionMeasuring = "Measuring";
|
||
|
||
// ── Constructor ───────────────────────────────────────────────────────────
|
||
|
||
/// <param name="canService">CAN bus service for parameter I/O.</param>
|
||
/// <param name="configService">Configuration providing bench parameters and app settings.</param>
|
||
/// <param name="logger">Application logger.</param>
|
||
public BenchService(ICanService canService, IConfigurationService configService, IAppLogger logger)
|
||
{
|
||
_can = canService;
|
||
_config = configService;
|
||
_log = logger;
|
||
}
|
||
|
||
// ── IBenchService: properties ─────────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public bool IsAutoDfiEnabled => _autoDfiEnabled;
|
||
|
||
/// <inheritdoc/>
|
||
public double LastTargetRpm => _lastTargetRpm;
|
||
|
||
/// <inheritdoc/>
|
||
public double LastCommandVoltage => _lastCommandVoltage;
|
||
|
||
// ── IBenchService: active pump ────────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public void SetActivePump(PumpDefinition? pump)
|
||
{
|
||
_activePump = pump;
|
||
_log.Debug(LogId, $"Active pump set: {pump?.Id ?? "(none)"}");
|
||
}
|
||
|
||
// ── IBenchService: parameter access ───────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public double ReadBenchParameter(string parameterName)
|
||
{
|
||
if (_config.Bench.ParametersByName.TryGetValue(parameterName, out var benchParam))
|
||
return benchParam.Value;
|
||
return 0;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public double ReadPumpParameter(string parameterName)
|
||
{
|
||
if (_activePump != null &&
|
||
_activePump.ParametersByName.TryGetValue(parameterName, out var pumpParam))
|
||
return pumpParam.Value;
|
||
return 0;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public double ReadParameter(string parameterName)
|
||
{
|
||
if (_activePump != null &&
|
||
_activePump.ParametersByName.TryGetValue(parameterName, out var pumpParam))
|
||
return pumpParam.Value;
|
||
if (_config.Bench.ParametersByName.TryGetValue(parameterName, out var benchParam))
|
||
return benchParam.Value;
|
||
return 0;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public void SetParameter(string parameterName, double value)
|
||
{
|
||
if (_config.Bench.ParametersByName.TryGetValue(parameterName, out var benchParam))
|
||
benchParam.Value = value;
|
||
else if (_activePump != null &&
|
||
_activePump.ParametersByName.TryGetValue(parameterName, out var pumpParam))
|
||
pumpParam.Value = value;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public void SendParameters(uint messageId)
|
||
=> _can.SendMessageById(messageId);
|
||
|
||
// ── IBenchService: RPM control ────────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public void SetRpm(double rpm)
|
||
{
|
||
// Clamp to configured safety limit.
|
||
double safeRpm = Math.Min(rpm, _config.Settings.SecurityRpmLimit);
|
||
|
||
// Look up the required motor control voltage from the RPM-to-voltage table.
|
||
double voltage = RpmVoltageRelation.VoltageForRpm(
|
||
(int)safeRpm, _config.Settings.Relations);
|
||
|
||
SendRpmVoltage(voltage);
|
||
|
||
_lastTargetRpm = safeRpm;
|
||
_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);
|
||
return;
|
||
}
|
||
|
||
// Step 1: Send initial voltage from lookup table (open-loop jump).
|
||
double initialVoltage = RpmVoltageRelation.VoltageForRpm(
|
||
(int)safeRpm, _config.Settings.Relations);
|
||
SendRpmVoltage(initialVoltage);
|
||
|
||
// 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);
|
||
_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;
|
||
|
||
// Scale voltage (0-10V) to 12-bit DAC value (0-4095).
|
||
// The embedded motor controller has a 12-bit DAC: 0 = 0V, 4095 = 10V.
|
||
double dacValue = (volts * 4095.0) / 10.0;
|
||
|
||
SetParameter(BenchParameterNames.Rpm, dacValue);
|
||
SendParameters(_config.Bench.ParametersByName.TryGetValue(
|
||
BenchParameterNames.Rpm, out var rpmParam) ? rpmParam.MessageId : 0x10);
|
||
|
||
RpmCommandSent?.Invoke();
|
||
}
|
||
|
||
// ── IBenchService: temperature ────────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public bool SetTemperatureSetpoint(double setpointCelsius, double toleranceCelsius)
|
||
{
|
||
_temperatureSetpoint = setpointCelsius;
|
||
_temperatureTolerance = toleranceCelsius;
|
||
|
||
// Return true (ignore temperature) if DefaultIgnoreTin is configured.
|
||
return _config.Settings.DefaultIgnoreTin;
|
||
}
|
||
|
||
// ── IBenchService: relay control ──────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public void SetRelay(string relayName, bool state)
|
||
{
|
||
if (!_config.Bench.Relays.TryGetValue(relayName, out var relay)) return;
|
||
|
||
relay.State = state;
|
||
// Rebuild the relay bitmask and transmit.
|
||
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)
|
||
{
|
||
// Collect all relays mapped to this CAN message ID and pack their bits.
|
||
long mask = 0;
|
||
foreach (var relay in _config.Bench.Relays.Values)
|
||
{
|
||
if (relay.MessageId == messageId && relay.State)
|
||
mask |= (1L << relay.Bit);
|
||
}
|
||
|
||
byte[] data = new byte[8];
|
||
for (int i = 0; i < 8; i++)
|
||
data[i] = (byte)((mask >> (i * 8)) & 0xFF);
|
||
|
||
_can.SendRawMessage(messageId, data);
|
||
}
|
||
|
||
// ── IBenchService: pump control sender ─────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public void SetPumpControlValue(string parameterName, double targetValue)
|
||
{
|
||
if (_activePump == null) return;
|
||
|
||
switch (parameterName)
|
||
{
|
||
case PumpParameterNames.Me:
|
||
_targetMe = targetValue;
|
||
break;
|
||
case PumpParameterNames.Fbkw:
|
||
_targetFbkw = targetValue;
|
||
// FBKW is written directly with no slew-rate filtering.
|
||
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Fbkw, out var fbkwParam))
|
||
fbkwParam.Value = targetValue;
|
||
break;
|
||
case PumpParameterNames.PreIn:
|
||
_targetPreIn = targetValue;
|
||
break;
|
||
}
|
||
|
||
StartPumpSender();
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public void StartPumpSender()
|
||
{
|
||
if (_pumpSendingActive) return;
|
||
_pumpSendingActive = true;
|
||
|
||
_pumpSendCts = new CancellationTokenSource();
|
||
var ct = _pumpSendCts.Token;
|
||
int intervalMs = Math.Max(1, _config.Settings.RefreshPumpParamsMs);
|
||
|
||
_pumpSendTask = Task.Run(async () =>
|
||
{
|
||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(intervalMs));
|
||
try
|
||
{
|
||
while (_pumpSendingActive && await timer.WaitForNextTickAsync(ct))
|
||
{
|
||
PumpSendTick();
|
||
}
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
}, ct);
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public void StopPumpSender()
|
||
{
|
||
_pumpSendingActive = false;
|
||
_pumpSendCts?.Cancel();
|
||
_pumpSendCts?.Dispose();
|
||
_pumpSendCts = null;
|
||
}
|
||
|
||
private void PumpSendTick()
|
||
{
|
||
if (_activePump == null) return;
|
||
|
||
// ME: apply slew-rate IIR filter (alpha * 0.2) before sending.
|
||
CanBusParameter? meParam = null;
|
||
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Me, out meParam))
|
||
{
|
||
double alpha = meParam.Alpha * 0.2;
|
||
meParam.Value = Math.Round(meParam.Value + alpha * (_targetMe - meParam.Value), 4);
|
||
_can.SendMessageById(meParam.MessageId);
|
||
}
|
||
|
||
// FBKW: send its message if it uses a different CAN ID than ME.
|
||
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Fbkw, out var fbkwParam))
|
||
{
|
||
if (meParam == null || fbkwParam.MessageId != meParam.MessageId)
|
||
_can.SendMessageById(fbkwParam.MessageId);
|
||
}
|
||
|
||
// PreIn: apply slew-rate IIR filter (full alpha) before sending.
|
||
if (_activePump.HasPreInjection &&
|
||
_activePump.ParametersByName.TryGetValue(PumpParameterNames.PreIn, out var preinParam))
|
||
{
|
||
preinParam.Value = Math.Round(
|
||
preinParam.Value + preinParam.Alpha * (_targetPreIn - preinParam.Value), 4);
|
||
_can.SendMessageById(preinParam.MessageId);
|
||
}
|
||
}
|
||
|
||
// ── IBenchService: MemoryRequest sender ────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public void StartMemoryRequestSender()
|
||
{
|
||
if (_memoryRequestActive) return;
|
||
if (_activePump == null ||
|
||
!_activePump.ParametersByName.ContainsKey(PumpParameterNames.MemoryRequest))
|
||
return;
|
||
|
||
_memoryRequestActive = true;
|
||
_memoryRequestCts = new CancellationTokenSource();
|
||
var ct = _memoryRequestCts.Token;
|
||
int intervalMs = Math.Max(1, _config.Settings.RefreshPumpRequestMs);
|
||
|
||
_memoryRequestTask = Task.Run(async () =>
|
||
{
|
||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(intervalMs));
|
||
try
|
||
{
|
||
while (_memoryRequestActive && await timer.WaitForNextTickAsync(ct))
|
||
{
|
||
if (_activePump?.ParametersByName.TryGetValue(
|
||
PumpParameterNames.MemoryRequest, out var memReqParam) == true)
|
||
{
|
||
_can.SendMessageById(memReqParam.MessageId);
|
||
}
|
||
}
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
}, ct);
|
||
|
||
_log.Debug(LogId, "MemoryRequest sender started.");
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public void StopMemoryRequestSender()
|
||
{
|
||
_memoryRequestActive = false;
|
||
_memoryRequestCts?.Cancel();
|
||
_memoryRequestCts?.Dispose();
|
||
_memoryRequestCts = null;
|
||
_log.Debug(LogId, "MemoryRequest sender stopped.");
|
||
}
|
||
|
||
// ── IBenchService: ElectronicMsg keepalive sender ─────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public void StartElectronicMsgSender()
|
||
{
|
||
if (_electronicMsgActive) return;
|
||
_electronicMsgActive = true;
|
||
|
||
_electronicMsgCts = new CancellationTokenSource();
|
||
var ct = _electronicMsgCts.Token;
|
||
|
||
_electronicMsgTask = Task.Run(async () =>
|
||
{
|
||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1000));
|
||
try
|
||
{
|
||
while (_electronicMsgActive && await timer.WaitForNextTickAsync(ct))
|
||
{
|
||
ElectronicMsgTick();
|
||
}
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
}, ct);
|
||
|
||
_log.Debug(LogId, "ElectronicMsg keepalive sender started.");
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public void StopElectronicMsgSender()
|
||
{
|
||
_electronicMsgActive = false;
|
||
_electronicMsgCts?.Cancel();
|
||
_electronicMsgCts?.Dispose();
|
||
_electronicMsgCts = null;
|
||
_log.Debug(LogId, "ElectronicMsg keepalive sender stopped.");
|
||
}
|
||
|
||
private void ElectronicMsgTick()
|
||
{
|
||
if (!_config.Bench.ParametersByName.TryGetValue(
|
||
BenchParameterNames.ElectronicMsg, out var electronicParam))
|
||
return;
|
||
|
||
byte[] data = new byte[8];
|
||
data[0] = 1;
|
||
|
||
// Encode the encoder resolution into its assigned byte positions.
|
||
if (_config.Bench.ParametersByName.TryGetValue(
|
||
BenchParameterNames.EncoderResolution, out var encoderParam))
|
||
{
|
||
int resolution = (int)_config.Settings.EncoderResolution;
|
||
data[encoderParam.ByteH] = (byte)((resolution & 0xFF00) >> 8);
|
||
data[encoderParam.ByteL] = (byte)(resolution & 0x00FF);
|
||
}
|
||
|
||
_can.SendRawMessage(electronicParam.MessageId, data);
|
||
}
|
||
|
||
// ── IBenchService: DFI ────────────────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public void SetDfi(double dfi)
|
||
{
|
||
_log.Debug(LogId, $"SetDfi({dfi:F4}) — forwarded to KWP service via event.");
|
||
// The ViewModel wires this to IKwpService.WriteDfiAsync in the test flow.
|
||
}
|
||
|
||
// ── IBenchService: test execution ─────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public async Task RunTestsAsync(PumpDefinition pump, CancellationToken ct)
|
||
{
|
||
_running = true;
|
||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
|
||
try
|
||
{
|
||
TestStarted?.Invoke();
|
||
|
||
// Cache the active pump's status-word definition so PollPumpStatusReactions
|
||
// can evaluate reaction codes without repeating the config lookup each tick.
|
||
_activeStatusDef = null;
|
||
_statusReactionEdge.Clear();
|
||
if (pump.ParametersByName.TryGetValue(PumpParameterNames.Status, out var statusParam))
|
||
{
|
||
_activeStatusDef = _config.LoadPumpStatus(statusParam.Type);
|
||
}
|
||
|
||
// Small delay to allow oil circulation to stabilise before the first test.
|
||
await Task.Delay(2000, _cts.Token);
|
||
|
||
bool overallSuccess = true;
|
||
TestDefinition? lastTest = null;
|
||
|
||
foreach (var test in pump.Tests)
|
||
{
|
||
if (_cts.Token.IsCancellationRequested) break;
|
||
if (!test.HasActivePhase()) continue;
|
||
|
||
lastTest = test;
|
||
bool testSuccess = await RunSingleTestAsync(test, pump, _cts.Token);
|
||
overallSuccess = overallSuccess && testSuccess;
|
||
|
||
if (!testSuccess && ShouldHaltOnFailure(test)) break;
|
||
}
|
||
|
||
bool wasInterrupted = _cts.Token.IsCancellationRequested;
|
||
TestFinished?.Invoke(wasInterrupted, wasInterrupted ? false : overallSuccess);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
TestFinished?.Invoke(true, false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.Error(LogId, $"RunTestsAsync exception: {ex}");
|
||
TestFinished?.Invoke(true, false);
|
||
}
|
||
finally
|
||
{
|
||
_running = false;
|
||
_activeStatusDef = null;
|
||
_statusReactionEdge.Clear();
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public void StopTests()
|
||
{
|
||
_cts?.Cancel();
|
||
SetRpm(0);
|
||
ZeroPumpParameters();
|
||
_log.Info(LogId, "Test sequence stopped by operator.");
|
||
}
|
||
|
||
// ── Private: test execution ───────────────────────────────────────────────
|
||
|
||
private async Task<bool> RunSingleTestAsync(
|
||
TestDefinition test, PumpDefinition pump, CancellationToken ct)
|
||
{
|
||
bool success = true;
|
||
|
||
foreach (var phase in test.Phases)
|
||
{
|
||
if (!phase.Enabled || ct.IsCancellationRequested) break;
|
||
|
||
PhaseChanged?.Invoke(phase.Name);
|
||
phase.Success = true;
|
||
phase.ClearResults();
|
||
phase.ErrorBits.Clear();
|
||
_lastAlarmMask = (int)ReadBenchParameter(BenchParameterNames.Alarms);
|
||
_qOverZeroSinceMs = 0;
|
||
|
||
// SVME test: check that the PSG encoder sync pulse is present before proceeding.
|
||
if (!phase.Name.Contains("Lock Angle") &&
|
||
test.Name == TestType.Svme &&
|
||
ReadBenchParameter(BenchParameterNames.PsgEncoderWorking) == 0)
|
||
{
|
||
VerboseMessage?.Invoke($"{phase.Name} — PSG Sync Pulse Error");
|
||
PsgSyncError?.Invoke();
|
||
return false;
|
||
}
|
||
|
||
// ── Step 1: Set RPM and wait for bench to reach setpoint ──────────
|
||
var rpmSetpoint = phase.GetRpmSetpoint();
|
||
if (rpmSetpoint != null)
|
||
{
|
||
SetRpm(rpmSetpoint.Value);
|
||
VerboseMessage?.Invoke($"{phase.Name} — Revving to {rpmSetpoint.Value} RPM...");
|
||
|
||
// Wait on the bench motor encoder feedback (BenchRPM), not the
|
||
// ambiguous "RPM" which resolves to pump ECU RPM when a pump is loaded.
|
||
await WaitForParameter(
|
||
BenchParameterNames.BenchRpm, rpmSetpoint.Value, rpmSetpoint.Tolerance, ct);
|
||
|
||
// 2-second stabilisation delay after reaching target RPM.
|
||
await Task.Delay(2000, ct);
|
||
}
|
||
|
||
ct.ThrowIfCancellationRequested();
|
||
var sw = Stopwatch.StartNew();
|
||
|
||
// ── Step 2: Send pump parameters ──────────────────────────────────
|
||
VerboseMessage?.Invoke($"{phase.Name} — Sending pump parameters...");
|
||
foreach (var send in phase.Sends)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
if (send.Name == BenchParameterNames.Rpm) continue; // already handled
|
||
|
||
// Pump control params route through the periodic sender with slew-rate filtering.
|
||
if (send.Name == PumpParameterNames.Me ||
|
||
send.Name == PumpParameterNames.Fbkw ||
|
||
send.Name == PumpParameterNames.PreIn)
|
||
{
|
||
SetPumpControlValue(send.Name, send.Value);
|
||
PumpControlValueSet?.Invoke(send.Name, send.Value);
|
||
}
|
||
else
|
||
{
|
||
SetParameter(send.Name, send.Value);
|
||
}
|
||
}
|
||
// Transmit bench parameters that were set above.
|
||
foreach (var send in phase.Sends)
|
||
{
|
||
if (send.Name == BenchParameterNames.Rpm ||
|
||
send.Name == PumpParameterNames.Me ||
|
||
send.Name == PumpParameterNames.Fbkw ||
|
||
send.Name == PumpParameterNames.PreIn) continue;
|
||
|
||
if (_config.Bench.ParametersByName.TryGetValue(send.Name, out var sp))
|
||
{
|
||
SendParameters(sp.MessageId);
|
||
break; // one frame per message ID group is enough
|
||
}
|
||
}
|
||
|
||
// ── Step 3: Wait for temperature setpoint ─────────────────────────
|
||
if (phase.Readies.Count > 0)
|
||
{
|
||
var tempReady = phase.Readies[0];
|
||
bool ignoreTin = SetTemperatureSetpoint(tempReady.Value, tempReady.Tolerance);
|
||
if (!ignoreTin)
|
||
{
|
||
VerboseMessage?.Invoke($"{phase.Name} — Adjusting temperature...");
|
||
await WaitForParameter(
|
||
tempReady.Name, tempReady.Value, tempReady.Tolerance, ct);
|
||
}
|
||
}
|
||
|
||
// Notify chart view of expected tolerance bands for every receive.
|
||
// The UI layer routes to the appropriate chart by parameter name.
|
||
foreach (var recv in phase.Receives)
|
||
ToleranceUpdated?.Invoke(recv.Name, recv.Value, recv.Tolerance);
|
||
|
||
// ── Step 4: Conditioning time countdown ───────────────────────────
|
||
sw.Stop();
|
||
long conditioningRemainMs = (long)test.ConditioningTimeSec * 1000 - sw.ElapsedMilliseconds;
|
||
_log.Debug(LogId, $"{phase.Name}: conditioning remaining={conditioningRemainMs}ms");
|
||
|
||
int conditioningTotalSec = (int)(conditioningRemainMs / 1000);
|
||
for (int i = 0; i * 1000 < conditioningRemainMs; i++)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
CheckQOverSafety(i * 1000L);
|
||
PollAlarms(phase);
|
||
int remaining = conditioningTotalSec - i;
|
||
VerboseMessage?.Invoke($"{phase.Name} — Conditioning... {remaining}s");
|
||
PhaseTimerTick?.Invoke(SectionConditioning, remaining, conditioningTotalSec);
|
||
await Task.Delay(1000, ct);
|
||
}
|
||
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
// ── Step 5: Special phase handling ────────────────────────────────
|
||
if (phase.Name.Contains("Lock Angle"))
|
||
{
|
||
VerboseMessage?.Invoke($"{phase.Name} — Acquiring Lock Angle...");
|
||
LockAngleFaseReady?.Invoke();
|
||
await Task.Delay(200, ct);
|
||
continue; // No measurement collection for lock angle phases
|
||
}
|
||
|
||
if (phase.Name.Contains(TestDefinition.XmlPhase) || phase.Name.Contains("PSG(0)"))
|
||
{
|
||
VerboseMessage?.Invoke($"{phase.Name} — PSG Mode...");
|
||
PsgModeFaseReady?.Invoke();
|
||
await Task.Delay(4000, ct);
|
||
continue;
|
||
}
|
||
|
||
// ── Step 6: DFI auto-adjust loop ──────────────────────────────────
|
||
if (test.Name == TestType.Dfi && _autoDfiEnabled)
|
||
{
|
||
phase.Success = await RunDfiAutoAdjustAsync(test, phase, ct);
|
||
}
|
||
else
|
||
{
|
||
phase.Success = await MeasurePhaseAsync(test, phase, ct);
|
||
}
|
||
|
||
success = success && phase.Success;
|
||
PhaseCompleted?.Invoke(phase.Name, phase.Success);
|
||
|
||
// Critical phase failure halts the entire test.
|
||
if (phase.IsCritical && !phase.Success)
|
||
{
|
||
SetRpm(0);
|
||
ZeroPumpParameters();
|
||
VerboseMessage?.Invoke($"CRITICAL failure in {phase.Name} — test halted.");
|
||
return false;
|
||
}
|
||
|
||
// Stop pump between phases (motor cool-down).
|
||
SetRpm(0);
|
||
ZeroPumpParameters();
|
||
}
|
||
|
||
return success;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Collects measurements for <paramref name="phase"/> over the measurement window,
|
||
/// then evaluates pass/fail for each receive parameter.
|
||
/// </summary>
|
||
private async Task<bool> MeasurePhaseAsync(
|
||
TestDefinition test, PhaseDefinition phase, CancellationToken ct)
|
||
{
|
||
VerboseMessage?.Invoke($"{phase.Name} — Collecting measurements...");
|
||
|
||
// Clear previous results.
|
||
foreach (var tp in phase.Receives)
|
||
tp.Result = new TestResult { TestType = test.Name };
|
||
|
||
long measureMs = (long)test.MeasurementTimeSec * 1000;
|
||
int sleepMs = (int)(1000.0 / Math.Max(test.MeasurementsPerSecond, 0.1));
|
||
int measureTotalSec = (int)(measureMs / 1000);
|
||
var sw = Stopwatch.StartNew();
|
||
|
||
// Initial tick so the UI captures total + full remaining at t=0.
|
||
PhaseTimerTick?.Invoke(SectionMeasuring, measureTotalSec, measureTotalSec);
|
||
int lastRemaining = measureTotalSec;
|
||
|
||
while (sw.ElapsedMilliseconds <= measureMs)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
CheckQOverSafety(sw.ElapsedMilliseconds);
|
||
|
||
foreach (var tp in phase.Receives)
|
||
{
|
||
var sample = new MeasurementSample
|
||
{
|
||
Value = ReadParameter(tp.Name),
|
||
Timestamp = DateTime.Now.ToString(TestDefinition.TimestampFormat)
|
||
};
|
||
tp.Result!.AddSample(sample);
|
||
MeasurementSampled?.Invoke(tp.Name, sample.Value);
|
||
}
|
||
|
||
// Emit countdown only when the integer-second value changes.
|
||
int remaining = (int)Math.Max(0, (measureMs - sw.ElapsedMilliseconds + 999) / 1000);
|
||
if (remaining != lastRemaining)
|
||
{
|
||
PhaseTimerTick?.Invoke(SectionMeasuring, remaining, measureTotalSec);
|
||
lastRemaining = remaining;
|
||
}
|
||
|
||
PollAlarms(phase);
|
||
PollPumpStatusReactions();
|
||
await Task.Delay(sleepMs, ct);
|
||
}
|
||
|
||
// Evaluate pass/fail.
|
||
bool phaseSuccess = true;
|
||
foreach (var tp in phase.Receives)
|
||
{
|
||
tp.Result!.Evaluate(
|
||
tp.Value, tp.Tolerance,
|
||
_config.Settings.ToleranceUpExtension,
|
||
_config.Settings.TolerancePfpExtension);
|
||
phaseSuccess = phaseSuccess && tp.Result.Passed;
|
||
}
|
||
|
||
return phaseSuccess;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Auto-adjusts the DFI calibration angle iteratively until the measured delivery
|
||
/// matches the target, or the test is cancelled.
|
||
///
|
||
/// <para>
|
||
/// The relationship between DFI angle and delivery quantity was determined
|
||
/// experimentally: <c>delivery = 15.5 × DFI + 32.3</c>.
|
||
/// The inverse gives the required DFI to achieve a target delivery.
|
||
/// </para>
|
||
/// </summary>
|
||
private async Task<bool> RunDfiAutoAdjustAsync(
|
||
TestDefinition test, PhaseDefinition phase, CancellationToken ct)
|
||
{
|
||
// Baseline measurement.
|
||
await MeasurePhaseAsync(test, phase, ct);
|
||
if (phase.Receives.Count == 0) return false;
|
||
|
||
var target = phase.Receives[0];
|
||
if (target.Result == null || Math.Abs(target.Result.Average - target.Value) <= target.Tolerance)
|
||
return target.Result?.Passed ?? false;
|
||
|
||
double initialDfi = ReadParameter(KlineKeys.Dfi);
|
||
// Empirically derived linear model: delivery = 15.5 × DFI + 32.3
|
||
double displacement = 15.5 * initialDfi + 32.3 - target.Result.Average;
|
||
double lastError = 0;
|
||
|
||
while (!ct.IsCancellationRequested)
|
||
{
|
||
if (Math.Abs(target.Result!.Average - target.Value) <= target.Tolerance)
|
||
break;
|
||
|
||
double sendDfi = (target.Value + displacement - 32.3 + lastError) / 15.5;
|
||
sendDfi = Math.Clamp(sendDfi, -1.45, 1.45); // Hardware DFI range ±1.45°
|
||
|
||
VerboseMessage?.Invoke($"{phase.Name} — Writing DFI: {sendDfi:F4}°");
|
||
SetDfi(sendDfi);
|
||
|
||
// Re-conditioning period before re-measuring.
|
||
for (int i = 0; i < test.ConditioningTimeSec; i++)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
VerboseMessage?.Invoke($"{phase.Name} — Conditioning... {test.ConditioningTimeSec - i}s");
|
||
await Task.Delay(2000, ct); // 2 s steps — gives ECU time to reinitialise K-Line
|
||
}
|
||
|
||
await MeasurePhaseAsync(test, phase, ct);
|
||
lastError = target.Value - target.Result.Average;
|
||
}
|
||
|
||
return target.Result?.Passed ?? false;
|
||
}
|
||
|
||
// ── Safety helpers ─────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Immediately zeroes all pump control parameters (ME, FBKW, PreIn) and
|
||
/// transmits the zero values over CAN. Bypasses the slew-rate IIR filter
|
||
/// by writing directly to parameter values and clearing the target fields.
|
||
/// </summary>
|
||
private void ZeroPumpParameters()
|
||
{
|
||
if (_activePump == null) return;
|
||
|
||
// Zero the slew-rate targets so the periodic sender doesn't ramp back up.
|
||
_targetMe = 0;
|
||
_targetFbkw = 0;
|
||
_targetPreIn = 0;
|
||
|
||
// Write zero directly to the parameter values (bypassing the IIR filter).
|
||
CanBusParameter? meParam = null;
|
||
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Me, out meParam))
|
||
{
|
||
meParam.Value = 0;
|
||
_can.SendMessageById(meParam.MessageId);
|
||
}
|
||
|
||
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Fbkw, out var fbkwParam))
|
||
{
|
||
fbkwParam.Value = 0;
|
||
if (meParam == null || fbkwParam.MessageId != meParam.MessageId)
|
||
_can.SendMessageById(fbkwParam.MessageId);
|
||
}
|
||
|
||
if (_activePump.HasPreInjection &&
|
||
_activePump.ParametersByName.TryGetValue(PumpParameterNames.PreIn, out var preinParam))
|
||
{
|
||
preinParam.Value = 0;
|
||
_can.SendMessageById(preinParam.MessageId);
|
||
}
|
||
|
||
_log.Debug(LogId, "ZeroPumpParameters: ME, FBKW, PreIn zeroed and transmitted.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Checks the QOver zero-flow condition. If QOver reads 0 while the bench
|
||
/// motor is above 300 RPM and the oil pump relay is energised, and this
|
||
/// condition persists for <see cref="QOverDebounceSec"/> seconds, triggers
|
||
/// an emergency stop.
|
||
/// </summary>
|
||
/// <param name="elapsedMs">Current elapsed milliseconds (from the phase stopwatch or loop counter).</param>
|
||
private void CheckQOverSafety(long elapsedMs)
|
||
{
|
||
double qOver = ReadBenchParameter(BenchParameterNames.QOver);
|
||
double benchRpm = ReadBenchParameter(BenchParameterNames.BenchRpm);
|
||
bool oilPumpOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var relay)
|
||
&& relay.State;
|
||
|
||
if (qOver == 0 && benchRpm > 300 && oilPumpOn)
|
||
{
|
||
if (_qOverZeroSinceMs == 0)
|
||
_qOverZeroSinceMs = elapsedMs;
|
||
else if (elapsedMs - _qOverZeroSinceMs >= QOverDebounceSec * 1000)
|
||
{
|
||
_log.Error(LogId,
|
||
$"QOver zero-flow safety: QOver=0, BenchRPM={benchRpm:F0}, " +
|
||
$"OilPump=ON for {QOverDebounceSec}s — emergency stop.");
|
||
PerformEmergencyStop("QOver zero-flow: oil flow blocked while motor running");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
_qOverZeroSinceMs = 0;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Immediately stops the bench motor, zeroes pump parameters, cancels the
|
||
/// test sequence, and fires <see cref="EmergencyStopTriggered"/>.
|
||
/// </summary>
|
||
private void PerformEmergencyStop(string reason)
|
||
{
|
||
SetRpm(0);
|
||
ZeroPumpParameters();
|
||
_cts?.Cancel();
|
||
_statusReactionEdge.Clear();
|
||
EmergencyStopTriggered?.Invoke(reason);
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public void RequestEmergencyStop(string reason)
|
||
{
|
||
_log.Warning(LogId, $"Operator-initiated emergency stop: {reason}");
|
||
PerformEmergencyStop(reason);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads the current alarm bitmask from the Alarms CAN parameter, detects
|
||
/// bits that transitioned 0→1 since the last snapshot, and records them
|
||
/// in the given phase via <see cref="PhaseDefinition.RecordErrorBit"/>.
|
||
/// </summary>
|
||
private void PollAlarms(PhaseDefinition phase)
|
||
{
|
||
int currentMask = (int)ReadBenchParameter(BenchParameterNames.Alarms);
|
||
int newBits = currentMask & ~_lastAlarmMask;
|
||
if (newBits != 0)
|
||
{
|
||
for (int bit = 0; bit < 16; bit++)
|
||
{
|
||
if ((newBits & (1 << bit)) != 0)
|
||
{
|
||
phase.RecordErrorBit(bit);
|
||
_log.Debug(LogId, $"Alarm bit {bit} recorded in phase {phase.Name}");
|
||
}
|
||
}
|
||
}
|
||
_lastAlarmMask = currentMask;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads the current pump status word, evaluates each enabled status bit
|
||
/// against its <see cref="PumpStatusDefinition"/>, and dispatches the
|
||
/// configured reaction (abort / warning / log) on a 0→1 transition to a
|
||
/// state whose <see cref="StatusBitValue.Reaction"/> is non-zero.
|
||
///
|
||
/// <para>
|
||
/// v1 treats every status bit as single-bit (state 0 or 1); multi-bit grouped
|
||
/// status fields are not yet supported — extend the <c>currentState</c>
|
||
/// computation when needed.
|
||
/// </para>
|
||
/// </summary>
|
||
private void PollPumpStatusReactions()
|
||
{
|
||
if (_activeStatusDef == null) return;
|
||
|
||
uint raw = (uint)ReadParameter(PumpParameterNames.Status);
|
||
|
||
foreach (var bit in _activeStatusDef.Bits)
|
||
{
|
||
if (!bit.Enabled) continue;
|
||
|
||
int currentState = (int)((raw >> bit.Bit) & 1u);
|
||
|
||
// Find the StatusBitValue matching the current state.
|
||
StatusBitValue? match = null;
|
||
foreach (var v in bit.Values)
|
||
{
|
||
if (v.State == currentState) { match = v; break; }
|
||
}
|
||
if (match == null || match.Reaction == 0) continue;
|
||
|
||
var key = (bit.Bit, currentState);
|
||
if (_statusReactionEdge.TryGetValue(key, out var fired) && fired)
|
||
continue;
|
||
|
||
// Clear any sibling (same bit, different state) entries so the next
|
||
// transition re-arms.
|
||
foreach (var v in bit.Values)
|
||
{
|
||
if (v.State != currentState)
|
||
_statusReactionEdge[(bit.Bit, v.State)] = false;
|
||
}
|
||
|
||
_statusReactionEdge[key] = true;
|
||
HandleStatusReaction(bit.Bit, match.Reaction, match.Description);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Dispatches a status-bit reaction: 1 = abort (emergency stop),
|
||
/// 2 = warning (log + event), 3 = log-only (log + event).
|
||
/// </summary>
|
||
private void HandleStatusReaction(int bit, int reaction, string description)
|
||
{
|
||
switch (reaction)
|
||
{
|
||
case 1:
|
||
StatusReactionTriggered?.Invoke(bit, reaction, description);
|
||
PerformEmergencyStop($"Pump status bit {bit} ({description})");
|
||
break;
|
||
case 2:
|
||
_log.Warning(LogId, $"Status warning bit {bit}: {description}");
|
||
StatusReactionTriggered?.Invoke(bit, reaction, description);
|
||
break;
|
||
case 3:
|
||
_log.Warning(LogId, $"Status log bit {bit}: {description}");
|
||
StatusReactionTriggered?.Invoke(bit, reaction, description);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
private async Task WaitForParameter(
|
||
string name, double target, double tolerance, CancellationToken ct)
|
||
{
|
||
while (!ct.IsCancellationRequested)
|
||
{
|
||
double current = ReadParameter(name);
|
||
if (Math.Abs(current - target) <= tolerance) return;
|
||
await Task.Delay(5, ct);
|
||
}
|
||
}
|
||
|
||
private static bool ShouldHaltOnFailure(TestDefinition test)
|
||
{
|
||
// Currently no test-level "critical" flag; only phase-level.
|
||
// Preserve this as an extension point.
|
||
return false;
|
||
}
|
||
}
|
||
}
|