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; } } }