Files
HC_APTBS/Services/Impl/BenchService.cs
LucianoDev d9775b48be feat: chart grid, pressure tolerance bands, QDelivery RPM normalization, pump page polish
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>
2026-04-20 21:42:30 +02:00

1154 lines
47 KiB
C#
Raw Permalink 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.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;
}
}
}