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
{
///
/// Implements the full bench test orchestration: RPM control, temperature PID,
/// relay management, and phase-by-phase test execution.
///
///
/// Test execution runs in a (background thread). All events
/// are raised on that thread — consumers (ViewModels) must marshal to the UI thread.
///
///
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 ────────────────────────────────────────────────────────────────
///
public event Action? TestStarted;
///
public event Action? TestFinished;
///
public event Action? PhaseChanged;
///
public event Action? VerboseMessage;
///
public event Action? PsgSyncError;
///
public event Action? LockAngleFaseReady;
///
public event Action? PsgModeFaseReady;
///
public event Action? ToleranceUpdated;
///
public event Action? PhaseCompleted;
///
public event Action? PumpControlValueSet;
///
public event Action? RpmCommandSent;
///
public event Action? MeasurementSampled;
///
public event Action? EmergencyStopTriggered;
///
public event Action? PhaseTimerTick;
///
public event Action? StatusReactionTriggered;
// Section labels for PhaseTimerTick — constants avoid per-tick string allocations.
private const string SectionConditioning = "Conditioning";
private const string SectionMeasuring = "Measuring";
// ── Constructor ───────────────────────────────────────────────────────────
/// CAN bus service for parameter I/O.
/// Configuration providing bench parameters and app settings.
/// Application logger.
public BenchService(ICanService canService, IConfigurationService configService, IAppLogger logger)
{
_can = canService;
_config = configService;
_log = logger;
}
// ── IBenchService: properties ─────────────────────────────────────────────
///
public bool IsAutoDfiEnabled => _autoDfiEnabled;
///
public double LastTargetRpm => _lastTargetRpm;
///
public double LastCommandVoltage => _lastCommandVoltage;
// ── IBenchService: active pump ────────────────────────────────────────────
///
public void SetActivePump(PumpDefinition? pump)
{
_activePump = pump;
_log.Debug(LogId, $"Active pump set: {pump?.Id ?? "(none)"}");
}
// ── IBenchService: parameter access ───────────────────────────────────────
///
public double ReadBenchParameter(string parameterName)
{
if (_config.Bench.ParametersByName.TryGetValue(parameterName, out var benchParam))
return benchParam.Value;
return 0;
}
///
public double ReadPumpParameter(string parameterName)
{
if (_activePump != null &&
_activePump.ParametersByName.TryGetValue(parameterName, out var pumpParam))
return pumpParam.Value;
return 0;
}
///
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;
}
///
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;
}
///
public void SendParameters(uint messageId)
=> _can.SendMessageById(messageId);
// ── IBenchService: RPM control ────────────────────────────────────────────
///
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");
}
///
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");
}
///
public void StopRpmPid()
{
_pidTargetRpm = 0;
_pidController?.Stop();
_pidController?.Dispose();
_pidController = null;
_lastTargetRpm = 0;
SendRpmVoltage(0);
_log.Info(LogId, "StopRpmPid: PID stopped, 0V sent.");
}
///
/// Writes a voltage value to the RPM CAN parameter and transmits it.
/// Used as the PID output callback and by .
///
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 ────────────────────────────────────────────
///
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 ──────────────────────────────────────────
///
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);
}
///
public void SendInitialRelayState()
{
// Collect all distinct relay message IDs and transmit each one.
var sent = new HashSet();
foreach (var relay in _config.Bench.Relays.Values)
{
if (sent.Add(relay.MessageId))
TransmitRelayMask(relay.MessageId);
}
}
///
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();
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.");
}
///
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 ─────────────────────────────────────
///
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();
}
///
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);
}
///
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 ────────────────────────────────────
///
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.");
}
///
public void StopMemoryRequestSender()
{
_memoryRequestActive = false;
_memoryRequestCts?.Cancel();
_memoryRequestCts?.Dispose();
_memoryRequestCts = null;
_log.Debug(LogId, "MemoryRequest sender stopped.");
}
// ── IBenchService: ElectronicMsg keepalive sender ─────────────────────────
///
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.");
}
///
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 ────────────────────────────────────────────────────
///
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 ─────────────────────────────────────────
///
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();
}
}
///
public void StopTests()
{
_cts?.Cancel();
SetRpm(0);
ZeroPumpParameters();
_log.Info(LogId, "Test sequence stopped by operator.");
}
// ── Private: test execution ───────────────────────────────────────────────
private async Task 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;
}
///
/// Collects measurements for over the measurement window,
/// then evaluates pass/fail for each receive parameter.
///
private async Task 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;
}
///
/// Auto-adjusts the DFI calibration angle iteratively until the measured delivery
/// matches the target, or the test is cancelled.
///
///
/// The relationship between DFI angle and delivery quantity was determined
/// experimentally: delivery = 15.5 × DFI + 32.3.
/// The inverse gives the required DFI to achieve a target delivery.
///
///
private async Task 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 ─────────────────────────────────────────────────────────
///
/// 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.
///
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.");
}
///
/// 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 seconds, triggers
/// an emergency stop.
///
/// Current elapsed milliseconds (from the phase stopwatch or loop counter).
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;
}
}
///
/// Immediately stops the bench motor, zeroes pump parameters, cancels the
/// test sequence, and fires .
///
private void PerformEmergencyStop(string reason)
{
SetRpm(0);
ZeroPumpParameters();
_cts?.Cancel();
_statusReactionEdge.Clear();
EmergencyStopTriggered?.Invoke(reason);
}
///
public void RequestEmergencyStop(string reason)
{
_log.Warning(LogId, $"Operator-initiated emergency stop: {reason}");
PerformEmergencyStop(reason);
}
///
/// 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 .
///
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;
}
///
/// Reads the current pump status word, evaluates each enabled status bit
/// against its , and dispatches the
/// configured reaction (abort / warning / log) on a 0→1 transition to a
/// state whose is non-zero.
///
///
/// v1 treats every status bit as single-bit (state 0 or 1); multi-bit grouped
/// status fields are not yet supported — extend the currentState
/// computation when needed.
///
///
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);
}
}
///
/// Dispatches a status-bit reaction: 1 = abort (emergency stop),
/// 2 = warning (log + event), 3 = log-only (log + event).
///
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;
}
}
}