diff --git a/Inc/debug_uart.h b/Inc/debug_uart.h index 23b1b4ab0..fd467d99c 100644 --- a/Inc/debug_uart.h +++ b/Inc/debug_uart.h @@ -19,10 +19,12 @@ * on targets without USE_DEBUG_UART (stubs are no-ops). */ enum { DBG_EVT_BOOT = 1, - DBG_EVT_NFAULT, /* gate-driver nFAULT, unclassified (GDF etc.) */ - DBG_EVT_NFAULT_UVLO, /* nFAULT + low bus → undervoltage class */ - DBG_EVT_NFAULT_OCP, /* nFAULT + high current/drive → overcurrent class */ - DBG_EVT_NFAULT_OTW, /* nFAULT + high temp → overtemperature class */ + DBG_EVT_NFAULT, /* gate-driver nFAULT, unclassified (GDF etc.) */ + DBG_EVT_NFAULT_UVLO, /* nFAULT held, low bus → UVLO */ + DBG_EVT_NFAULT_OCP, /* retry budget exceeded (persistent VDS) */ + DBG_EVT_NFAULT_OTW, /* nFAULT held, bridge live → warning (OTW) */ + DBG_EVT_NFAULT_OTSD, /* nFAULT held, bridge dead, MCU thermal label */ + DBG_EVT_NFAULT_RETRY, /* short nFAULT pulse (VDS auto-retry) */ DBG_EVT_STUCK, DBG_EVT_LVC, DBG_EVT_SIGNAL, diff --git a/Inc/faults.h b/Inc/faults.h index 12d572ce2..10ed9fff6 100644 --- a/Inc/faults.h +++ b/Inc/faults.h @@ -16,25 +16,34 @@ typedef enum { FAULT_SIGNAL_TIMEOUT, FAULT_BEMF_STALL, /* - * Gate-driver nFAULT classes (DRV8350H / DRV8328). The pin is a single - * open-drain OR of UVLO/OCP/OTW/GDF — no SPI status on hardware-interface - * parts — so these are ADC guesses at the latch edge (V / I / temp). + * Gate-driver nFAULT classes. Not DRV status bits (H-interface has no + * SPI). On DRV8350H they come from pin duration + bridge conduction; + * on DRV8328 every nFAULT is a latched cut (UVLO vs other is a V hint). */ - FAULT_GD_UVLO, /* low bus voltage guess */ - FAULT_GD_OCP, /* high current / was-driving guess */ - FAULT_GD_OTW, /* high temperature guess */ - FAULT_GD_UNKNOWN, /* nFAULT with no clear ADC signature */ + FAULT_GD_UVLO, /* bus below useful pack voltage */ + FAULT_GD_OCP, /* VDS retry pulse, or retry budget exceeded */ + FAULT_GD_OTW, /* nFAULT held, bridge still driving (DRV OTW) */ + FAULT_GD_OTSD, /* held + dead, MCU die in thermal band (log label) */ + FAULT_GD_UNKNOWN, /* held + dead, no UVLO/thermal signature (GDF etc.) */ } fault_id_t; /* - * Latched gate-driver fault cause while nFAULT is active or sticky. + * Classified cause while nFAULT warning/Hi-Z/latch is active. * FAULT_NONE when healthy. Best-effort only — not a DRV status register. */ fault_id_t faultGateDriverCause(void); -/* Short name for logs ("UVLO", "OCP", "OTW", "nFAULT", or ""). */ +/* Short name for logs ("UVLO", "OCP", "OTW", "OTSD", "nFAULT", or ""). */ const char *faultGateDriverCauseName(fault_id_t cause); +/* Consume a one-shot gate-driver log queued by faultPollGateDriver. + * Returns 0 if none, FAULT_GD_LOG_WARNING, or FAULT_GD_LOG_ERROR. + * *cause is FAULT_NONE when there is nothing pending. */ +#define FAULT_GD_LOG_NONE 0 +#define FAULT_GD_LOG_WARNING 1 +#define FAULT_GD_LOG_ERROR 2 +uint8_t faultGateDriverConsumeLog(fault_id_t *cause); + /* * Stuck-rotor protection (was the top of setInput after throttle map). * If bemf_timeout_happened has exceeded the threshold, cut drive and latch. @@ -205,23 +214,42 @@ uint32_t faultErrorCount(void); void faultErrorCountReset(void); /* - * Gate-driver nFAULT poll (DRV8350H FAULT_N on ARK_G431_CAN, DRV8328 on - * ARK_4IN1_F051 when the pin is defined). - * - * The DRV pin is a single open-drain OR of VDS OCP, UVLO, OTW, and GDF — - * hardware interface parts (…H) do not expose SPI status. On assert we - * classify a best-effort cause from MCU bus voltage / current / temp - * (faultGateDriverCause), cut PWM, latch ESC_FAULT_STUCK, log the specific - * cause, and at zero throttle pulse DRV ENABLE (or clear the software latch - * after ENABLE-low sleep) so latched trips can clear without reboot. + * Gate-driver nFAULT poll. Two chip policies (see faults.c): + * + * DRV8350H (ARK 12S CAN): pin duration + bridge conducting *while driven*. + * pulse ~8 ms : VDS auto-retry — keep PWM, count pulses if seen + * held + live : OTW warning — keep PWM, log WARNING (rate-limited) + * held + dead : Hi-Z until pin releases; MCU temp is a log label only + * (not HIZ vs latch). Persistent pin-low at zero throttle + * ENABLE tRST (consecutive failed pulses; pin-high + * recovery resets), then latch. + * held + commanded, never confirmed live, ~250 ms : Hi-Z (dwell backstop; + * commanded time accumulates across idle blips below + * the DRV sleep threshold; a sleep re-classifies) + * + * DRV8328 (ARK 4IN1): every nFAULT already Hi-Z's the FETs (no OTW-only, + * no 8 ms retry). Cut PWM, latch stuck until zero throttle; nSLEEP sleep + * clears the DRV latch. * * Call from the main loop (not the 20 kHz path). No-op without the pin. */ void faultPollGateDriver(void); -/* 1 while a real gate-driver trip is latched, or nFAULT is low while the - * driver is awake and past post-wake settle. Sleep (ENABLE/nSLEEP low) does - * not count — the pin is asserted by VCP UVLO then. */ +/* 1 kHz timebase for nFAULT duration (called from tenKhzRoutine's 1 kHz + * branch). No-op without the pin. */ +void faultGateDriverTick1kHz(void); + +/* 1 while PWM must stay off (DRV Hi-Z wait or latched trip). Not set for + * OTW warning or the classify window — those keep driving. Sleep + * (ENABLE/nSLEEP low) does not count: the pin is asserted by VCP UVLO then. */ uint8_t faultGateDriverFaultActive(void); +/* 1 while nFAULT is held and the bridge is still driving (OTW class). */ +uint8_t faultGateDriverWarningActive(void); + +/* 1 while a Hi-Z wait needs ENABLE high so the pin can auto-clear and so + * a zero-throttle ENABLE tRST can unlatch GDF. LATCH does not set this: + * sleep-on-idle is that state's reset. */ +uint8_t faultGateDriverKeepAwake(void); + #endif /* FAULTS_H_ */ diff --git a/Inc/gate_driver.h b/Inc/gate_driver.h index 0ff2a2218..ca74cf00b 100644 --- a/Inc/gate_driver.h +++ b/Inc/gate_driver.h @@ -21,7 +21,12 @@ extern volatile uint8_t gate_driver_awake; void gateDriverInit(void); void gateDriverWakeBlocking(void); void gateDriverSleep(void); +# if defined(USE_DRV_ENABLE) void gateDriverFaultResetPulse(void); +# else +/* DRV8328 never calls this (nSLEEP sleep-on-idle is the latch reset). */ +static inline void gateDriverFaultResetPulse(void) {} +# endif void gateDriverPoll(void); /* 1 when ENABLE/nSLEEP is high and nFAULT is past the post-wake settle. * Pin is asserted in sleep (VCP UVLO) and can glitch on the first PWM. */ diff --git a/Inc/targets.h b/Inc/targets.h index 38e66c93f..70e602940 100644 --- a/Inc/targets.h +++ b/Inc/targets.h @@ -217,9 +217,11 @@ * awake for beeps, drive, and brake. Wake: PWM inactive → * ENABLE high → ~3 ms settle (charge pump) → then PWM. * nFAULT = PA12 (FAULT_N) — open-drain, external 20k to 3.3V; - * asserts on VDS OCP (resistor-set on VDS pin), UVLO, OTW, GDF - * (single wire OR — no SPI status). Firmware guesses cause - * from bus V / current / temp ADC at latch (faults.c). + * asserts on VDS OCP (resistor-set on VDS pin), UVLO, OTW, + * OTSD, GDF (single wire OR — no SPI status). Firmware + * classifies from pin duration + whether the bridge is + * still conducting (faults.c): OTW and 8 ms VDS retries + * keep PWM; Hi-Z faults cut drive. * Mode / IDRIVE / VDS thresholds are hardwired on the board; firmware * only enables the driver and reacts to FAULT_N. */ @@ -252,7 +254,10 @@ # define CURRENT_ADC_CHANNEL LL_ADC_CHANNEL_3 # define CURRENT_ADC_PIN LL_GPIO_PIN_3 # define USE_SERIAL_TELEMETRY -/* DRV8328 nSLEEP (PA15): high = awake. Sleep when idle via gate_driver.c. */ +/* DRV8328 nSLEEP (PA15): high = awake. Sleep when idle via gate_driver.c. + * nFAULT (PB5): every assert Hi-Z's the FETs (no OTW-only / no VDS + * auto-retry). Firmware cuts PWM and latches until zero throttle; + * nSLEEP-low sleep is the fault reset. */ # define USE_DRV8328_NSLEEP # define NSLEEP_PORT GPIOA # define NSLEEP_PIN LL_GPIO_PIN_15 diff --git a/Src/DroneCAN/DroneCAN.c b/Src/DroneCAN/DroneCAN.c index 3dbf183f6..ca17f1a94 100644 --- a/Src/DroneCAN/DroneCAN.c +++ b/Src/DroneCAN/DroneCAN.c @@ -579,21 +579,24 @@ static void can_printf(const char *fmt, ...) } /* - * One-shot fault LogMessages: rising edge of nFAULT latch or stuck-rotor - * latch. nFAULT also forces ESC_FAULT_STUCK — prefer the classified - * gate-driver cause ("UVLO"/"OCP"/"OTW"/"nFAULT") when both rise together. + * One-shot fault LogMessages: gate-driver consume queue, or stuck-rotor + * latch. Prefer the gate-driver line when both rise together — nFAULT + * latch also forces ESC_FAULT_STUCK. */ static void DroneCAN_pollFaultLogMessages(void) { - static uint8_t prev_nfault; static uint8_t prev_stuck; - - const uint8_t nfault = faultGateDriverFaultActive(); + fault_id_t cause = FAULT_NONE; + const uint8_t gd_level = faultGateDriverConsumeLog(&cause); const uint8_t stuck = (uint8_t)(escGetState() == ESC_FAULT_STUCK); - if (nfault && !prev_nfault) { - /* ADC-guessed class (DRV pin is OR-only on hardware-interface parts). */ - const fault_id_t cause = faultGateDriverCause(); + if (gd_level == FAULT_GD_LOG_WARNING) { + if (cause == FAULT_GD_OCP) { + can_log(UAVCAN_PROTOCOL_DEBUG_LOGLEVEL_WARNING, "nFAULT retry"); + } else { + can_log(UAVCAN_PROTOCOL_DEBUG_LOGLEVEL_WARNING, "nFAULT OTW"); + } + } else if (gd_level == FAULT_GD_LOG_ERROR) { switch (cause) { case FAULT_GD_UVLO: can_log(UAVCAN_PROTOCOL_DEBUG_LOGLEVEL_ERROR, "nFAULT UVLO"); @@ -601,8 +604,8 @@ static void DroneCAN_pollFaultLogMessages(void) case FAULT_GD_OCP: can_log(UAVCAN_PROTOCOL_DEBUG_LOGLEVEL_ERROR, "nFAULT OCP"); break; - case FAULT_GD_OTW: - can_log(UAVCAN_PROTOCOL_DEBUG_LOGLEVEL_ERROR, "nFAULT OTW"); + case FAULT_GD_OTSD: + can_log(UAVCAN_PROTOCOL_DEBUG_LOGLEVEL_ERROR, "nFAULT OTSD"); break; default: can_log(UAVCAN_PROTOCOL_DEBUG_LOGLEVEL_ERROR, "nFAULT"); @@ -612,7 +615,6 @@ static void DroneCAN_pollFaultLogMessages(void) can_log(UAVCAN_PROTOCOL_DEBUG_LOGLEVEL_ERROR, "stuck"); } - prev_nfault = nfault; prev_stuck = stuck; } @@ -621,7 +623,7 @@ static uint8_t DroneCAN_nodeHealth(void) { const esc_state_t st = escGetState(); - /* Cannot drive: stuck latch, gate-driver trip, or LVC. */ + /* Cannot drive: stuck latch, gate-driver Hi-Z/latch, or LVC. */ if (st == ESC_FAULT_STUCK || faultGateDriverFaultActive() || st == ESC_FAULT_LVC) { return UAVCAN_PROTOCOL_NODESTATUS_HEALTH_CRITICAL; } @@ -630,11 +632,11 @@ static uint8_t DroneCAN_nodeHealth(void) return UAVCAN_PROTOCOL_NODESTATUS_HEALTH_ERROR; } /* - * WARNING only for established hard trips (faultErrorCount: stall + - * established jump desync) or active post-desync holdoff. Acquisition - * roughness and commanded-stop coast (PX4 disarm) must stay OK. + * WARNING: nFAULT OTW (bridge still driving), established hard trips + * (faultErrorCount: stall + established jump desync), or post-desync + * holdoff. Acquisition roughness and commanded-stop coast stay OK. */ - if (faultErrorCount() > 0 || faultDesyncRestartHoldoffActive()) { + if (faultGateDriverWarningActive() || faultErrorCount() > 0 || faultDesyncRestartHoldoffActive()) { return UAVCAN_PROTOCOL_NODESTATUS_HEALTH_WARNING; } return UAVCAN_PROTOCOL_NODESTATUS_HEALTH_OK; diff --git a/Src/control_loop.c b/Src/control_loop.c index 34298f636..c2dc9a273 100644 --- a/Src/control_loop.c +++ b/Src/control_loop.c @@ -321,6 +321,9 @@ void setInput() /* Wake DRV with IRQs enabled (ENABLE settle is multi-ms on * DRV8350H). comStep gateDriverEnsure() is then a no-op. */ allOff(); + /* 20 kHz writes CCR even while !running. + * startMotor() → comStep() re-enables at that CCR. */ + SET_DUTY_CYCLE_ALL(0); gateDriverWakeBlocking(); if (!old_routine) { startMotor(); @@ -513,9 +516,11 @@ void setInput() } // Missed-ZC power cut (BLHeli-style): while commutating blind the // rotor position is unknown - bound the energy driven into a possibly - // wrong phase. On DRV8328 (F051 4IN1) there is no VDS trip; on - // DRV8350H (ARK_G431_CAN) the board has a resistor-set VDS limit and - // FAULT_N is polled in faultPollGateDriver. Applied here in setInput + // wrong phase. On DRV8328 (F051 4IN1) VDS is latched until nSLEEP + // reset; on DRV8350H (ARK_G431_CAN) the board has a resistor-set VDS + // limit that auto-retries in 8 ms. FAULT_N is polled in + // faultPollGateDriver (OTW / short VDS pulses do not cut PWM). + // Applied here in setInput // (on F051 that is the DShot EXTI IRQ, not the main loop) rather than // inside the O3 RAM_FUNC 20 kHz body (bench bisect showed adding code // there disturbs F051 startup). Pulling last_duty_cycle down as well @@ -609,6 +614,19 @@ RAM_FUNC void tenKhzRoutine() ledcounter++; ramp_count++; one_khz_loop_counter++; +#if defined(USE_DRV_NFAULT) + { + /* nFAULT classify timebase must run during sine start too. The + * PID 1 kHz block is inside !escInSineStart() and would freeze gd_ms. + * >= PID_LOOP_DIVIDER is 20 ticks at 20 kHz = 1 kHz; the PID + * block still uses `>` (21 ticks, ~952 Hz). */ + static uint16_t gd_khz_div; + if (++gd_khz_div >= PID_LOOP_DIVIDER) { + gd_khz_div = 0; + faultGateDriverTick1kHz(); + } + } +#endif if (!escIsArmed()) { if (cell_count == 0) { if (inputSet) { diff --git a/Src/debug_uart.c b/Src/debug_uart.c index 0f1f3eee9..aaea7aea8 100644 --- a/Src/debug_uart.c +++ b/Src/debug_uart.c @@ -164,6 +164,10 @@ static const char *evt_name(uint8_t e) return "nFAULT OCP"; case DBG_EVT_NFAULT_OTW: return "nFAULT OTW"; + case DBG_EVT_NFAULT_OTSD: + return "nFAULT OTSD"; + case DBG_EVT_NFAULT_RETRY: + return "nFAULT retry"; case DBG_EVT_STUCK: return "stuck"; case DBG_EVT_LVC: @@ -202,7 +206,9 @@ void debugUartService(void) const char *to = escStateName((esc_state_t)rec.b); debugUartPrintf("esc: %s -> %s\r\n", from, to); } else if (rec.kind == 2) { - debugUartPrintf("fault: %s\r\n", evt_name(rec.a)); + /* OTW / VDS-retry are warnings: HWCI aborts on "fault: nFAULT". */ + const char *pfx = (rec.a == DBG_EVT_NFAULT_OTW || rec.a == DBG_EVT_NFAULT_RETRY) ? "warn" : "fault"; + debugUartPrintf("%s: %s\r\n", pfx, evt_name(rec.a)); } } } diff --git a/Src/faults.c b/Src/faults.c index 1e08daccd..b0b6379ef 100644 --- a/Src/faults.c +++ b/Src/faults.c @@ -59,75 +59,263 @@ volatile uint8_t fault_acq_resist_events; static uint16_t acq_grace_ms; #endif -/* - * DRV nFAULT is a single open-drain OR (VDS OCP, UVLO, OTW, GDF). Hardware - * interface (DRV8350H / DRV8328 without SPI status) cannot report which bit - * fired — classify from MCU ADC context at the rising edge of the latch. - */ #if defined(USE_DRV_NFAULT) || defined(USE_DRV8328_NFAULT) # define FAULT_HAS_DRV_NFAULT 1 #else # define FAULT_HAS_DRV_NFAULT 0 #endif -#if FAULT_HAS_DRV_NFAULT -/* Sticky until nFAULT releases; keeps FAULT_STUCK if bemf latch is cleared - * by the zero-throttle path while the DRV is still asserting. */ -static uint8_t drv_nfault_latched; -static fault_id_t drv_nfault_cause; -# if defined(USE_DRV_ENABLE) -/* Rate-limit ENABLE recovery pulses (~main-loop iterations). */ -static uint16_t drv_enable_retry_div; -# endif - +#if defined(USE_DRV_NFAULT) /* - * Best-effort guess: the DRV does not tell us which OR-term pulled nFAULT. - * Use MCU ADC at the latch edge (call BEFORE clearing running/duty context). - * - * Priority: UVLO → OTW → OCP → unknown (VDS/GDF/etc.). - * Units: battery_voltage 10 mV, actual_current 10 mA, degrees_celsius °C. + * DRV8350H (ARK 12S CAN). nFAULT is an OR of VDS OCP / UVLO / OTW / OTSD / + * GDF with no SPI status. Do not cut PWM on the falling edge: OTW keeps + * the drivers active, and VDS auto-retries in ~8 ms. Classify from pin + * duration + whether the bridge is still conducting *while drive is + * commanded*. A throttle-idle current drop looks like Hi-Z on the shunt. * - * Thresholds are deliberately coarse — a wrong-but-specific label is more - * useful on the bench/FC than a generic "nFAULT". They are not datasheet - * proofs of the DRV comparator that fired. + * VDS tRETRY is ~8 ms; GD_CLASSIFY_MS is 12 ms (1.5×). Back-to-back retries + * into a persistent short leave nFAULT low with only microsecond-scale + * highs between pulses. A free-running main-loop poll can easily never + * observe a release, so GD_RETRY_BUDGET may never arm. The held path + * (CLASSIFY/WARN → Hi-Z) is the real backstop; the pulse count is + * opportunistic. Host twin: hwci/hwci/gd_nfault_model.py. */ -static fault_id_t fault_classify_drv_nfault(void) -{ - const uint16_t v_cv = battery_voltage; - const int16_t i_ca = actual_current; - const int16_t t_c = degrees_celsius; +# define GD_CLASSIFY_MS 12u +# define GD_UVLO_CV 800u +/* Smoothed centiamps (actual_current). 0.50 A is ~6 raw LSB on G431 + * (MILLIVOLT_PER_AMP 10, CURRENT_OFFSET 0) — conduction vs noise, not a + * "motor is loaded" floor. Bench: capture the actual_current distribution + * at warm idle AND at the lowest nonzero throttle the ESC is commanded at. + * If either straddles 50 cA, raise this — a barely-spinning motor that + * never holds 40 ms consecutive live hits GD_WARN_CEIL_MS and false-drops. */ +# define GD_LIVE_CA_MIN 50 +/* MCU *die* via __LL_ADC_CALC_TEMPERATURE, not the DRV (OTW ~150 C / + * OTSD ~175 C). Log label only — never pick HIZ vs latch from this. */ +# define GD_THERMAL_C 100 +# define GD_RETRY_WINDOW_MS 100u +# define GD_RETRY_BUDGET 8u +# define GD_RETRY_LOG_MS 200u +# define GD_OTW_LOG_MS 200u +/* actual_current is a 50-sample / ~1 kHz boxcar (~50 ms). Dwell past that + * so OTW + re-throttle is not Hi-Z'd on stale idle current. */ +# define GD_DEAD_DWELL_MS 80u +/* Consecutive current_live under drive before we trust OTW (no ceiling). */ +# define GD_OTW_CONFIRM_MS 40u +/* Pin held + drive commanded, never confirmed live → Hi-Z. Closes a + * starvable dwell (one sample ≥ LIVE_CA_MIN resets the 80 ms window). + * Accumulates commanded-held time across idle blips (idle does not + * restart the 250 ms). Per-wake-episode, not per-power-cycle: IDLE→CLASSIFY + * zeroes gd_cmd_held_ms, and a cut long enough for gateDriverPoll to sleep + * (running==0) drops to IDLE, so the next wake is a fresh 250 ms. Blips + * below that sleep threshold still accumulate. */ +# define GD_WARN_CEIL_MS 250u +/* Zero-throttle ENABLE tRST while nFAULT is still held (GDF). + * Consecutive failed pulses; pin-high recovery resets the count. */ +# define GD_HIZ_RST_MS 2000u +# define GD_RESUME_BUDGET 3u +/* Coast after PWM cut before startMotor() (interval=10000, no ZC). + * Clock is gd_hiz_t0 (set once in enter_dead), not gd_t0 — tRST + * rewrites gd_t0 every 2 s. Stall's BEMF_STALL_TICKS (~22.5 ms) is + * lost-sync, not stopped-rotor. 3 s is generous for large props; a + * windmilling disc may never reach the implicit ~300 RPM. Measure + * with profile g431_hiz_spindown. OTW never enters HIZ. */ +# define GD_HIZ_SPINDOWN_MS 3000u - /* ~8 V: below useful pack voltage for these ESCs; near DRV VM UVLO. */ - if (v_cv > 0u && v_cv < 800u) { - return FAULT_GD_UVLO; - } - /* FET NTC / motor path hot — OTW class. */ - if (t_c >= 100) { - return FAULT_GD_OTW; +enum { + GD_NF_IDLE = 0, + GD_NF_CLASSIFY, + GD_NF_WARN, + GD_NF_HIZ, + GD_NF_LATCH, +}; + +static uint8_t gd_state; +static fault_id_t gd_cause; +static uint16_t gd_t0; +static uint16_t gd_hiz_t0; /* enter_dead only; tRST must not rewrite */ +static int16_t gd_snap_ca; +static uint8_t gd_retry_count; +static uint16_t gd_retry_window_t0; +static uint16_t gd_retry_log_t0; +static uint8_t gd_retry_logged; +static uint16_t gd_otw_log_t0; +static uint8_t gd_otw_logged; +static uint8_t gd_dead_arm; +static uint16_t gd_dead_t0; +static uint8_t gd_live_arm; +static uint16_t gd_live_t0; +static uint8_t gd_cmd_arm; +static uint16_t gd_cmd_t0; +static uint16_t gd_cmd_held_ms; +static uint8_t gd_otw_confirmed; +static uint8_t gd_resume_count; +static uint8_t gd_log_level; +static fault_id_t gd_log_cause; +static volatile uint16_t gd_ms; + +static void gd_queue_log(uint8_t level, fault_id_t cause) +{ + /* Upgrade only: same-level must not clobber (ERROR UVLO → ERROR UNKNOWN). */ + if (level > gd_log_level) { + gd_log_level = level; + gd_log_cause = cause; } - /* High current, or was driving with meaningful duty when the pin fell. */ - if (i_ca >= 2000 || (running != 0 && duty_cycle > 200u)) { - return FAULT_GD_OCP; +} + +/* Wrap-safe rate limit. First event always logs (*have_logged == 0); no + * t0==0 sentinel, so a gd_ms wrap cannot look like "never logged". */ +static uint8_t gd_rate_due(uint16_t now, uint8_t *have_logged, uint16_t *t0, uint16_t period) +{ + if (!*have_logged || (uint16_t)(now - *t0) >= period) { + *have_logged = 1; + *t0 = now; + return 1; } - return FAULT_GD_UNKNOWN; + return 0; } # ifdef USE_DEBUG_UART -static uint8_t fault_drv_nfault_dbg_event(fault_id_t cause) +static uint8_t gd_dbg_event(fault_id_t cause, uint8_t warning) { + if (warning) { + return (cause == FAULT_GD_OCP) ? DBG_EVT_NFAULT_RETRY : DBG_EVT_NFAULT_OTW; + } switch (cause) { case FAULT_GD_UVLO: return DBG_EVT_NFAULT_UVLO; case FAULT_GD_OCP: return DBG_EVT_NFAULT_OCP; - case FAULT_GD_OTW: - return DBG_EVT_NFAULT_OTW; + case FAULT_GD_OTSD: + return DBG_EVT_NFAULT_OTSD; default: return DBG_EVT_NFAULT; } } # endif -#endif /* FAULT_HAS_DRV_NFAULT */ + +static void gd_hold_cut(void) +{ + allOff(); + maskPhaseInterrupts(); + SET_DUTY_CYCLE_ALL(0); + running = 0; + stepper_sine = 0; + /* HIZ resume keeps throttle; reset start state the old stop branch + * used to. 20 kHz rewrites CCR — zero it again before startMotor(). */ + duty_cycle_setpoint = 0; + zero_crosses = 0; + bemfZcResetTrend(); +} + +/* Sine start drives the bridge with running==0. */ +static uint8_t gd_drive_commanded(void) +{ + return (uint8_t)(adjusted_input != 0 && (running || stepper_sine)); +} + +static void gd_enter_latch(fault_id_t cause) +{ + gd_hold_cut(); + gd_cause = cause; + gd_state = GD_NF_LATCH; + gd_queue_log(FAULT_GD_LOG_ERROR, cause); +# ifdef USE_DEBUG_UART + debugUartLogEvent(gd_dbg_event(cause, 0)); +# endif + if (escGetState() != ESC_FAULT_STUCK) { + escToFaultStuck(); +# ifdef USE_RGB_LED + setIndividualRGBLed(1, 0, 0); +# endif + } +} + +static void gd_enter_dead(uint16_t now) +{ + gd_hold_cut(); + gd_t0 = now; + gd_hiz_t0 = now; + gd_dead_arm = 0; + gd_live_arm = 0; + gd_cmd_arm = 0; + gd_cmd_held_ms = 0; + gd_state = GD_NF_HIZ; + if (battery_voltage > 0u && battery_voltage < GD_UVLO_CV) { + gd_cause = FAULT_GD_UVLO; + } else if (degrees_celsius >= GD_THERMAL_C) { + gd_cause = FAULT_GD_OTSD; + } else { + gd_cause = FAULT_GD_UNKNOWN; + } + gd_queue_log(FAULT_GD_LOG_ERROR, gd_cause); +# ifdef USE_DEBUG_UART + debugUartLogEvent(gd_dbg_event(gd_cause, 0)); +# endif +} + +/* Elapsed commanded-held time. Idle clears cmd_arm so the next drive + * poll does not add the idle gap; gd_cmd_held_ms is kept. A sleep-to-IDLE + * path (gateDriverPoll) still zeroes it via CLASSIFY — see GD_WARN_CEIL_MS. */ +static void gd_cmd_held_add(uint16_t now) +{ + if (!gd_cmd_arm) { + gd_cmd_arm = 1; + gd_cmd_t0 = now; + return; + } + const uint16_t dt = (uint16_t)(now - gd_cmd_t0); + gd_cmd_t0 = now; + if (dt > (uint16_t)(0xffffu - gd_cmd_held_ms)) { + gd_cmd_held_ms = 0xffffu; + } else { + gd_cmd_held_ms = (uint16_t)(gd_cmd_held_ms + dt); + } +} + +static void gd_enter_warn(uint16_t now) +{ + gd_cause = FAULT_GD_OTW; + gd_state = GD_NF_WARN; + if (gd_rate_due(now, &gd_otw_logged, &gd_otw_log_t0, GD_OTW_LOG_MS)) { + gd_queue_log(FAULT_GD_LOG_WARNING, FAULT_GD_OTW); +# ifdef USE_DEBUG_UART + debugUartLogEvent(gd_dbg_event(FAULT_GD_OTW, 1)); +# endif + } +} + +static void gd_note_retry(uint16_t now) +{ + if ((uint16_t)(now - gd_retry_window_t0) > GD_RETRY_WINDOW_MS) { + gd_retry_count = 0; + gd_retry_window_t0 = now; + } + if (gd_retry_count < 255u) { + gd_retry_count++; + } + if (gd_retry_count >= GD_RETRY_BUDGET) { + gd_retry_count = 0; + gd_enter_latch(FAULT_GD_OCP); + return; + } + if (gd_rate_due(now, &gd_retry_logged, &gd_retry_log_t0, GD_RETRY_LOG_MS)) { + gd_queue_log(FAULT_GD_LOG_WARNING, FAULT_GD_OCP); +# ifdef USE_DEBUG_UART + debugUartLogEvent(gd_dbg_event(FAULT_GD_OCP, 1)); +# endif + } +} + +#elif defined(USE_DRV8328_NFAULT) +/* + * DRV8328 (ARK 4IN1). Every nFAULT disables the gate drivers (no OTW-only + * report, no 8 ms VDS retry). VDS / OTSD / GDF stay latched until nSLEEP + * is pulled low — sleep-on-idle already does that. Firmware: cut PWM, latch + * stuck until zero throttle. Ignore the pin while asleep (nSLEEP UVLO). + */ +static uint8_t drv_nfault_latched; +static fault_id_t drv_nfault_cause; +#endif const char *faultGateDriverCauseName(fault_id_t cause) { @@ -138,6 +326,8 @@ const char *faultGateDriverCauseName(fault_id_t cause) return "OCP"; case FAULT_GD_OTW: return "OTW"; + case FAULT_GD_OTSD: + return "OTSD"; case FAULT_GD_UNKNOWN: return "nFAULT"; default: @@ -147,14 +337,36 @@ const char *faultGateDriverCauseName(fault_id_t cause) fault_id_t faultGateDriverCause(void) { -#if FAULT_HAS_DRV_NFAULT - if (drv_nfault_latched || ((NFAULT_PORT->IDR & NFAULT_PIN) == 0u)) { +#if defined(USE_DRV_NFAULT) + if (gd_state != GD_NF_IDLE) { + return gd_cause; + } +#elif defined(USE_DRV8328_NFAULT) + if (drv_nfault_latched) { return drv_nfault_cause; } #endif return FAULT_NONE; } +uint8_t faultGateDriverConsumeLog(fault_id_t *cause) +{ +#if defined(USE_DRV_NFAULT) + const uint8_t level = gd_log_level; + if (cause) { + *cause = (level != FAULT_GD_LOG_NONE) ? gd_log_cause : FAULT_NONE; + } + gd_log_level = FAULT_GD_LOG_NONE; + gd_log_cause = FAULT_NONE; + return level; +#else + if (cause) { + *cause = FAULT_NONE; + } + return FAULT_GD_LOG_NONE; +#endif +} + uint32_t faultErrorCount(void) { return desync_happened + fault_stall_trips; @@ -172,40 +384,203 @@ void faultErrorCountReset(void) uint8_t faultGateDriverFaultActive(void) { -#if FAULT_HAS_DRV_NFAULT - if (drv_nfault_latched) { - return 1; - } - /* ENABLE/nSLEEP low asserts nFAULT (VCP UVLO). That is sleep, not a - * trip — treating it as one made every arm/beep/idle publish a CAN - * ERROR LogMessage and NodeStatus CRITICAL while the motor was fine. */ - if (!gateDriverNfaultPinTrusted()) { - return 0; - } - return (uint8_t)((NFAULT_PORT->IDR & NFAULT_PIN) == 0u); +#if defined(USE_DRV_NFAULT) + return (uint8_t)(gd_state == GD_NF_HIZ || gd_state == GD_NF_LATCH); +#elif defined(USE_DRV8328_NFAULT) + return drv_nfault_latched; #else return 0; #endif } +uint8_t faultGateDriverWarningActive(void) +{ +#if defined(USE_DRV_NFAULT) + return (uint8_t)(gd_state == GD_NF_WARN); +#else + return 0; +#endif +} + +uint8_t faultGateDriverKeepAwake(void) +{ +#if defined(USE_DRV_NFAULT) + /* HIZ auto-recover needs ENABLE high to see the pin release, and to + * tRST a latched GDF at zero throttle. LATCH lets sleep-on-idle reset. */ + return (uint8_t)(gd_state == GD_NF_HIZ); +#else + return 0; +#endif +} + +void faultGateDriverTick1kHz(void) +{ +#if defined(USE_DRV_NFAULT) + gd_ms++; +#endif +} + void faultPollGateDriver(void) { -#if FAULT_HAS_DRV_NFAULT - /* - * While asleep ENABLE is held low (gate_driver sleep). That already - * satisfies DRV8350H t_RST for latched VDS/GDF faults — but we used to - * return here without ever clearing drv_nfault_latched, so FAULT_STUCK - * survived zero throttle + VM restore until reboot. Clear the software - * sticky at zero demand while asleep; nFAULT is not meaningful with - * ENABLE low, so do not re-assert from the pin in that state. - */ +#if defined(USE_DRV_NFAULT) + if (!gateDriverIsAwake()) { + if (adjusted_input == 0 && gd_state != GD_NF_IDLE) { + gd_state = GD_NF_IDLE; + gd_cause = FAULT_NONE; + gd_dead_arm = 0; + gd_live_arm = 0; + gd_cmd_arm = 0; + /* gd_resume_count is consecutive-failed tRST. Sleep-idle + * does not observe pin-high, so it does not reset. */ + } + return; + } + + gateDriverNfaultGraceTick(); + if (!gateDriverNfaultPinTrusted()) { + return; + } + + const uint8_t pin_low = (uint8_t)((NFAULT_PORT->IDR & NFAULT_PIN) == 0u); + const uint16_t now = gd_ms; + const uint8_t drive_on = gd_drive_commanded(); + const uint8_t current_live = (uint8_t)(actual_current >= GD_LIVE_CA_MIN); + + switch (gd_state) { + case GD_NF_IDLE: + if (pin_low) { + gd_snap_ca = actual_current; + gd_t0 = now; + gd_dead_arm = 0; + gd_live_arm = 0; + gd_cmd_arm = 0; + gd_cmd_held_ms = 0; + gd_otw_confirmed = 0; + gd_cause = FAULT_NONE; + gd_state = GD_NF_CLASSIFY; + } + break; + + case GD_NF_CLASSIFY: + if (!pin_low) { + gd_state = GD_NF_IDLE; + gd_note_retry(now); + break; + } + if ((uint16_t)(now - gd_t0) < GD_CLASSIFY_MS) { + break; + } + if (drive_on && gd_snap_ca >= GD_LIVE_CA_MIN && actual_current >= (int16_t)(gd_snap_ca >> 1)) { + /* Falling-edge snap and this sample are 12 ms and two + * independent boxcar updates apart. Both live and not + * collapsed vs snap is enough to confirm; the 40 ms + * consecutive dwell is for the unconfirmed fallthrough. */ + gd_otw_confirmed = 1; + gd_enter_warn(now); + break; + } + if (drive_on && !current_live) { + gd_enter_dead(now); + break; + } + gd_enter_warn(now); + break; + + case GD_NF_WARN: + if (!pin_low) { + gd_state = GD_NF_IDLE; + gd_cause = FAULT_NONE; + gd_dead_arm = 0; + gd_live_arm = 0; + gd_cmd_arm = 0; + gd_cmd_held_ms = 0; + gd_otw_confirmed = 0; + break; + } + if (!drive_on) { + gd_dead_arm = 0; + gd_live_arm = 0; + gd_cmd_arm = 0; /* pause the ceiling clock; keep held_ms */ + break; + } + gd_cmd_held_add(now); + if (current_live) { + gd_dead_arm = 0; + if (!gd_live_arm) { + gd_live_arm = 1; + gd_live_t0 = now; + } else if ((uint16_t)(now - gd_live_t0) >= GD_OTW_CONFIRM_MS) { + gd_otw_confirmed = 1; + } + } else { + gd_live_arm = 0; + if (!gd_dead_arm) { + gd_dead_arm = 1; + gd_dead_t0 = now; + } else if ((uint16_t)(now - gd_dead_t0) >= GD_DEAD_DWELL_MS) { + gd_enter_dead(now); + break; + } + } + if (!gd_otw_confirmed && gd_cmd_held_ms >= GD_WARN_CEIL_MS) { + gd_enter_dead(now); + } + break; + + case GD_NF_HIZ: + gd_hold_cut(); + if (!pin_low) { + gd_resume_count = 0; /* pin-high: DRV recovered */ + /* startMotor() is a blind start. Short HIZ (12–92 ms) + * would re-enable into a still-spinning rotor. */ + if (adjusted_input == 0 || (uint16_t)(now - gd_hiz_t0) >= GD_HIZ_SPINDOWN_MS) { + gd_state = GD_NF_IDLE; + gd_cause = FAULT_NONE; + } + break; + } + /* Stay while nFAULT is held. Zero throttle must not return + * to IDLE — that re-queued ERROR every classify window + * (prop-brake keeps ENABLE high with adjusted_input == 0). */ + if (battery_voltage > 0u && battery_voltage < GD_UVLO_CV) { + break; + } + if (adjusted_input != 0) { + break; + } + if ((uint16_t)(now - gd_t0) >= GD_HIZ_RST_MS) { + gd_t0 = now; + if (gd_resume_count >= GD_RESUME_BUDGET) { + gd_enter_latch(gd_cause == FAULT_NONE ? FAULT_GD_UNKNOWN : gd_cause); + } else { + gd_resume_count++; + gateDriverFaultResetPulse(); + } + } + break; + + case GD_NF_LATCH: + gd_hold_cut(); + if (!pin_low && adjusted_input == 0) { + gd_resume_count = 0; + gd_state = GD_NF_IDLE; + gd_cause = FAULT_NONE; + } + /* ENABLE tRST is sleep-on-idle (gateDriverPoll), not a + * pulse from this state — KeepAwake is false here. */ + break; + + default: + gd_state = GD_NF_IDLE; + gd_cause = FAULT_NONE; + break; + } + +#elif defined(USE_DRV8328_NFAULT) if (!gateDriverIsAwake()) { if (drv_nfault_latched && adjusted_input == 0) { drv_nfault_latched = 0; drv_nfault_cause = FAULT_NONE; -# if defined(USE_DRV_ENABLE) - drv_enable_retry_div = 0; -# endif } return; } @@ -215,17 +590,10 @@ void faultPollGateDriver(void) return; } - /* Active low (open-drain). High = healthy. */ - const uint8_t pin_ok = (NFAULT_PORT->IDR & NFAULT_PIN) != 0u; - - if (!pin_ok) { + if ((NFAULT_PORT->IDR & NFAULT_PIN) == 0u) { if (!drv_nfault_latched) { - /* Classify while running/duty still reflect the trip context. */ - drv_nfault_cause = fault_classify_drv_nfault(); + drv_nfault_cause = (battery_voltage > 0u && battery_voltage < 800u) ? FAULT_GD_UVLO : FAULT_GD_UNKNOWN; drv_nfault_latched = 1; -# ifdef USE_DEBUG_UART - debugUartLogEvent(fault_drv_nfault_dbg_event(drv_nfault_cause)); -# endif } allOff(); maskPhaseInterrupts(); @@ -233,36 +601,11 @@ void faultPollGateDriver(void) running = 0; stepper_sine = 0; if (escGetState() != ESC_FAULT_STUCK) { - /* Reuses stuck latch: reconcile holds FAULT_STUCK via - * bemf_timeout_happened == ESC_STUCK_LATCH until zero - * throttle clears it — same pilot-clear semantics. */ escToFaultStuck(); -# ifdef USE_RGB_LED - setIndividualRGBLed(1, 0, 0); -# endif } -# if defined(USE_DRV_ENABLE) - /* - * DRV8350H: latched VDS / gate faults stay asserted until - * ENABLE is driven low for t_RST then high again. Only retry - * at zero demand so we never re-arm into a short. - */ - if (adjusted_input == 0) { - if (++drv_enable_retry_div >= 2000u) { - drv_enable_retry_div = 0; - gateDriverFaultResetPulse(); - } - } else { - drv_enable_retry_div = 0; - } -# endif } else if (drv_nfault_latched && adjusted_input == 0) { - /* Pin recovered and pilot at zero: drop sticky so arm can proceed. */ drv_nfault_latched = 0; drv_nfault_cause = FAULT_NONE; -# if defined(USE_DRV_ENABLE) - drv_enable_retry_div = 0; -# endif } #endif } @@ -271,8 +614,7 @@ uint8_t faultHandleStuckRotorIfNeeded(void) { #ifndef BRUSHED_MODE # if FAULT_HAS_DRV_NFAULT - /* Gate-driver trip: do not map throttle back onto the bridge while - * nFAULT is low or still latched (cause in faultGateDriverCause()). */ + /* Gate-driver Hi-Z / latch: do not map throttle onto the bridge. */ if (faultGateDriverFaultActive()) { allOff(); maskPhaseInterrupts(); diff --git a/Src/gate_driver.c b/Src/gate_driver.c index 130e16147..bacb54ccb 100644 --- a/Src/gate_driver.c +++ b/Src/gate_driver.c @@ -10,8 +10,8 @@ # include "main.h" /* GPIO_TypeDef / BRR / BSRR */ # include "motor_runtime.h" # include "targets.h" +# include "faults.h" # if defined(USE_DRV_ENABLE) -/* DRV8350H on ARK 12S CAN: force bridge inputs inactive before ENABLE. */ # include "peripherals.h" # include "phaseouts.h" # endif @@ -27,7 +27,9 @@ # else # define GD_WAKE_US 1000u # endif -# define GD_FAULT_RST_US 50u +# if defined(USE_DRV_ENABLE) +# define GD_FAULT_RST_US 50u +# endif /* Main-loop polls to ignore nFAULT after ENABLE/nSLEEP rises. Covers the * same-loop DroneCAN read after wake, and the first PWM edges. */ # define GD_NFAULT_GRACE_POLLS 8u @@ -107,23 +109,23 @@ void gateDriverSleep(void) gd_nfault_grace = 0; } +# if defined(USE_DRV_ENABLE) void gateDriverFaultResetPulse(void) { - /* DRV8350H ENABLE pulse only; nSLEEP boards do not need t_RST. */ -# if defined(USE_DRV_ENABLE) - /* PWM inactive for the whole reset+wake; wake re-does force-low + t_settle. */ + /* DRV8350H ENABLE t_RST. PWM inactive for the whole pulse+wake so we + * never re-arm into a short. DRV8328 uses the header stub. */ allOff(); SET_DUTY_CYCLE_ALL(0); GD_PORT->BRR = GD_PIN; delayMicros(GD_FAULT_RST_US); gate_driver_awake = 0; gateDriverWakeBlocking(); -# endif } +# endif void gateDriverPoll(void) { - if (running || stepper_sine || prop_brake_active) { + if (running || stepper_sine || prop_brake_active || faultGateDriverKeepAwake()) { gateDriverWakeBlocking(); } else { gateDriverSleep(); diff --git a/hwci/docs/BENCH_SETUPS.md b/hwci/docs/BENCH_SETUPS.md index 7bef68615..1615c5c90 100644 --- a/hwci/docs/BENCH_SETUPS.md +++ b/hwci/docs/BENCH_SETUPS.md @@ -43,6 +43,65 @@ hwci debug-uart --config rig.yaml # and aborts on fault: nFAULT / desync / stuck / stall / acq_desync. ``` +### G4 nFAULT current floor (`GD_LIVE_CA_MIN`) + +`Src/faults.c` treats smoothed `actual_current` ≥ 50 cA (0.50 A) as "bridge +still conducting". That number is ~6 raw LSB on ARK_G431_CAN. Before shipping +the nFAULT classifier, capture the distribution from a real run — not just +warm idle: + +```bash +.venv/bin/python -m hwci run --config rig.yaml --profile g431_current_floor \ + --out runs/g431-current-floor +``` + +In `samples.csv` / `hwci_perf.current_ca`, compare p50/p95 of: + +* `idle` (armed, throttle 0, DRV may be asleep — offset / noise) +* `hold05` / `hold08` (lowest nonzero throttle this ESC is actually commanded) + +If either straddles 50 cA, raise `GD_LIVE_CA_MIN`. A barely-spinning motor +that never holds 40 ms consecutive live hits the 250 ms unconfirmed WARN +ceiling and false-drops (Hi-Z / CRITICAL / FAULT_STUCK). + +### G4 nFAULT HIZ resume (high throttle) + +HIZ pin-high at throttle waits `GD_HIZ_SPINDOWN_MS` (3 s from +`gd_enter_dead`, clock `gd_hiz_t0` — not the tRST `gd_t0`) before +`startMotor()`; zero throttle may resume immediately. 3 s is a guess: +a 5" prop may be down inside 1 s, a 15–18" prop takes several, and a +windmilling disc may never reach the ~300 RPM `startMotor()` assumes. +Same class of number as `GD_LIVE_CA_MIN` — measure it. + +Throttle-cut proxy (sizes the dwell; stop-then-punch, not nFAULT). +`require_eeprom` aborts unless `brake_on_stop=0` and `rc_car_reverse=0`: +HIZ does `allOff()` (no regen), so a braked coast would look too fast +and silently under-size the dwell. The run asserts this against the +live page rather than trusting whatever was last flashed. + +```bash +.venv/bin/python -m hwci run --config rig.yaml --profile g431_hiz_spindown \ + --out runs/g431-hiz-spindown +``` + +For each `re20_*` segment, time from throttle-up to `perf_running==1` +with a stable RPM and no `fault: stall` burst. The shortest clean coast +is a lower bound on `GD_HIZ_SPINDOWN_MS`. Pair with `g431_current_floor` +on the same session. + +Props off is the conservative direction on the bench (bare rotor coasts +longer than a loaded prop) but it is a floor, not an answer — it cannot +see windmilling, which is what the 3 s dwell is hedging. + +nFAULT jumper (the real HIZ path — save the scope trace). This one is +`allOff()` by construction, so `brake_on_stop` does not apply. If the +proxy and the jumper disagree, believe the jumper. + +1. Hold ~20% DShot. Jumper nFAULT low past 12 ms (UART `fault: nFAULT`). +2. Release nFAULT. Do not idle throttle between assert and release. +3. Restart must not be on the pin edge — ~3 s of coast, then a cold + start (no pre-fault CCR spike). Save the 20% phase-current trace. + ## SETUP A — Flight Stand throttle (no PX4 / no BDShot) ``` diff --git a/hwci/hwci/config.py b/hwci/hwci/config.py index 5038547e6..a1eb13d26 100644 --- a/hwci/hwci/config.py +++ b/hwci/hwci/config.py @@ -116,6 +116,10 @@ class Profile: # RigConfig (recorded into the run meta), not from the test profile. pole_pairs: int = 7 smoke_gates: SmokeGates | None = None + # Live EEPROM bytes that must match before the run starts. Empty = no + # check. A mismatch aborts rather than producing a plausible wrong + # number (e.g. brake_on_stop=1 makes a coast look like HIZ allOff()). + require_eeprom: dict[str, int] = field(default_factory=dict) @property def duration_s(self) -> float: @@ -171,6 +175,8 @@ def profile_from_dict(d: dict) -> Profile: demag_rpm_drop_fraction=float(d.get("demag_rpm_drop_fraction", 0.25)), pole_pairs=int(d.get("pole_pairs", 7)), smoke_gates=_smoke_gates_from(d.get("smoke_gates")), + require_eeprom={str(k): int(v) + for k, v in (d.get("require_eeprom") or {}).items()}, ) diff --git a/hwci/hwci/debug_uart.py b/hwci/hwci/debug_uart.py index 56d2cb4e0..2fbe3898e 100644 --- a/hwci/hwci/debug_uart.py +++ b/hwci/hwci/debug_uart.py @@ -5,6 +5,7 @@ * boot banner: ``ARK_G431_CAN debug UART @ 115200 (PB3/USART2)`` * state: ``esc: -> `` * faults: ``fault: nFAULT`` / ``fault: desync`` / … +* warnings: ``warn: nFAULT OTW`` / ``warn: nFAULT retry`` (do not abort) This is **not** KISS telemetry (that is a separate optional wire). On the thrust-stand bench the TX pin is wired to the ST-Link V3 Virtual COM Port; @@ -22,7 +23,8 @@ # Host hard-abort on console faults. Free-run BEMF/startup campaigns log # many "fault: desync" / "fault: stall" lines during open-loop acquire and # map-tier crawl — those are data, not aborts. LiveDesyncWatch + stand -# safety remain authoritative. Only gate-driver latch aborts the run. +# safety remain authoritative. Only a gate-driver *latch* (``fault: nFAULT``) +# aborts the run; OTW / VDS-retry warnings are ``warn:`` and do not abort. _ABORT_FAULTS = frozenset({ "nFAULT", }) diff --git a/hwci/hwci/gd_nfault_model.py b/hwci/hwci/gd_nfault_model.py new file mode 100644 index 000000000..e8847c64a --- /dev/null +++ b/hwci/hwci/gd_nfault_model.py @@ -0,0 +1,338 @@ +"""Host twin of the DRV8350H nFAULT state machine in Src/faults.c. + +Constants are parsed from firmware so Python cannot silently drift. The +step() body is a *transcription* of faultPollGateDriver()'s USE_DRV_NFAULT +switch, not a specification: edit the C control flow and these tests keep +passing against the old logic. run_ms() polls at 1 kHz; the firmware poll +is a free-running main loop, so this model cannot exercise µs-scale VDS +retry-edge visibility (documented as opportunistic) or the LATCH → sleep → +IDLE hand-off that resolves inside one loop iteration. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +GD_NF_IDLE = 0 +GD_NF_CLASSIFY = 1 +GD_NF_WARN = 2 +GD_NF_HIZ = 3 +GD_NF_LATCH = 4 + +GD_NAMES = ("IDLE", "CLASSIFY", "WARN", "HIZ", "LATCH") + +FAULT_NONE = 0 +FAULT_GD_UVLO = 4 +FAULT_GD_OCP = 5 +FAULT_GD_OTW = 6 +FAULT_GD_OTSD = 7 +FAULT_GD_UNKNOWN = 8 + +LOG_NONE = 0 +LOG_WARNING = 1 +LOG_ERROR = 2 + +_DEFINE_NAMES = ( + "GD_CLASSIFY_MS", + "GD_UVLO_CV", + "GD_LIVE_CA_MIN", + "GD_THERMAL_C", + "GD_RETRY_WINDOW_MS", + "GD_RETRY_BUDGET", + "GD_RETRY_LOG_MS", + "GD_OTW_LOG_MS", + "GD_DEAD_DWELL_MS", + "GD_OTW_CONFIRM_MS", + "GD_WARN_CEIL_MS", + "GD_HIZ_RST_MS", + "GD_RESUME_BUDGET", + "GD_HIZ_SPINDOWN_MS", +) + + +def _repo_faults_c() -> Path: + return Path(__file__).resolve().parents[2] / "Src" / "faults.c" + + +def parse_gd_defines_from_c(path: Path | None = None) -> dict[str, int]: + src = (path or _repo_faults_c()).read_text() + block = src.split("#if defined(USE_DRV_NFAULT)", 1) + if len(block) < 2: + raise ValueError("USE_DRV_NFAULT block not found in faults.c") + body = block[1].split("#elif defined(USE_DRV8328_NFAULT)", 1)[0] + out: dict[str, int] = {} + for name in _DEFINE_NAMES: + m = re.search(rf"#\s*define\s+{name}\s+(\d+)", body) + if not m: + raise ValueError(f"{name} not found in faults.c nFAULT block") + out[name] = int(m.group(1)) + return out + + +GD: dict[str, int] = parse_gd_defines_from_c() + + +def _u16(x: int) -> int: + return x & 0xFFFF + + +@dataclass +class GdNfaultMachine: + """Behavioural twin of the DRV8350H poll in faults.c.""" + + state: int = GD_NF_IDLE + cause: int = FAULT_NONE + t0: int = 0 + hiz_t0: int = 0 + snap_ca: int = 0 + retry_count: int = 0 + retry_window_t0: int = 0 + retry_log_t0: int = 0 + retry_logged: int = 0 + otw_log_t0: int = 0 + otw_logged: int = 0 + dead_arm: int = 0 + dead_t0: int = 0 + live_arm: int = 0 + live_t0: int = 0 + cmd_arm: int = 0 + cmd_t0: int = 0 + cmd_held_ms: int = 0 + otw_confirmed: int = 0 + resume_count: int = 0 + log_level: int = LOG_NONE + log_cause: int = FAULT_NONE + ms: int = 0 + rst_pulses: int = 0 + running: int = 0 + + pin_low: int = 0 + adjusted_input: int = 0 + stepper_sine: int = 0 + actual_current: int = 0 + battery_voltage: int = 3200 + degrees_celsius: int = 40 + awake: int = 1 + pin_trusted: int = 1 + + def drive_commanded(self) -> bool: + return self.adjusted_input != 0 and (self.running or self.stepper_sine) + + def queue_log(self, level: int, cause: int) -> None: + if level > self.log_level: + self.log_level = level + self.log_cause = cause + + def consume_log(self) -> tuple[int, int]: + level, cause = self.log_level, self.log_cause + self.log_level = LOG_NONE + self.log_cause = FAULT_NONE + return level, cause + + def rate_due(self, have_logged: int, t0: int, period: int) -> tuple[bool, int, int]: + if not have_logged or _u16(self.ms - t0) >= period: + return True, 1, self.ms + return False, have_logged, t0 + + def hold_cut(self) -> None: + self.running = 0 + self.stepper_sine = 0 + # C also zeros duty_cycle_setpoint / zero_crosses / ZC trend so a + # high-throttle HIZ resume is a cold start. Those live in + # control_loop.c (20 kHz CCR race); this twin is the poll SM only. + + def enter_latch(self, cause: int) -> None: + self.hold_cut() + self.cause = cause + self.state = GD_NF_LATCH + self.queue_log(LOG_ERROR, cause) + + def enter_dead(self) -> None: + self.hold_cut() + self.t0 = self.ms + self.hiz_t0 = self.ms + self.dead_arm = 0 + self.live_arm = 0 + self.cmd_arm = 0 + self.cmd_held_ms = 0 + self.state = GD_NF_HIZ + if 0 < self.battery_voltage < GD["GD_UVLO_CV"]: + self.cause = FAULT_GD_UVLO + elif self.degrees_celsius >= GD["GD_THERMAL_C"]: + self.cause = FAULT_GD_OTSD + else: + self.cause = FAULT_GD_UNKNOWN + self.queue_log(LOG_ERROR, self.cause) + + def cmd_held_add(self) -> None: + if not self.cmd_arm: + self.cmd_arm = 1 + self.cmd_t0 = self.ms + return + dt = _u16(self.ms - self.cmd_t0) + self.cmd_t0 = self.ms + if dt > 0xFFFF - self.cmd_held_ms: + self.cmd_held_ms = 0xFFFF + else: + self.cmd_held_ms += dt + + def enter_warn(self) -> None: + self.cause = FAULT_GD_OTW + self.state = GD_NF_WARN + due, self.otw_logged, self.otw_log_t0 = self.rate_due( + self.otw_logged, self.otw_log_t0, GD["GD_OTW_LOG_MS"]) + if due: + self.queue_log(LOG_WARNING, FAULT_GD_OTW) + + def note_retry(self) -> None: + if _u16(self.ms - self.retry_window_t0) > GD["GD_RETRY_WINDOW_MS"]: + self.retry_count = 0 + self.retry_window_t0 = self.ms + if self.retry_count < 255: + self.retry_count += 1 + if self.retry_count >= GD["GD_RETRY_BUDGET"]: + self.retry_count = 0 + self.enter_latch(FAULT_GD_OCP) + return + due, self.retry_logged, self.retry_log_t0 = self.rate_due( + self.retry_logged, self.retry_log_t0, GD["GD_RETRY_LOG_MS"]) + if due: + self.queue_log(LOG_WARNING, FAULT_GD_OCP) + + def fault_active(self) -> bool: + return self.state in (GD_NF_HIZ, GD_NF_LATCH) + + def warning_active(self) -> bool: + return self.state == GD_NF_WARN + + def keep_awake(self) -> bool: + return self.state == GD_NF_HIZ + + def tick_ms(self, n: int = 1) -> None: + self.ms = _u16(self.ms + n) + + def step(self) -> None: + if not self.awake: + if self.adjusted_input == 0 and self.state != GD_NF_IDLE: + self.state = GD_NF_IDLE + self.cause = FAULT_NONE + self.dead_arm = 0 + self.live_arm = 0 + self.cmd_arm = 0 + return + if not self.pin_trusted: + return + + pin_low = bool(self.pin_low) + now = self.ms + drive_on = self.drive_commanded() + current_live = self.actual_current >= GD["GD_LIVE_CA_MIN"] + + if self.state == GD_NF_IDLE: + if pin_low: + self.snap_ca = self.actual_current + self.t0 = now + self.dead_arm = 0 + self.live_arm = 0 + self.cmd_arm = 0 + self.cmd_held_ms = 0 + self.otw_confirmed = 0 + self.cause = FAULT_NONE + self.state = GD_NF_CLASSIFY + return + + if self.state == GD_NF_CLASSIFY: + if not pin_low: + self.state = GD_NF_IDLE + self.note_retry() + return + if _u16(now - self.t0) < GD["GD_CLASSIFY_MS"]: + return + if ( + drive_on + and self.snap_ca >= GD["GD_LIVE_CA_MIN"] + and self.actual_current >= (self.snap_ca >> 1) + ): + self.otw_confirmed = 1 + self.enter_warn() + return + if drive_on and not current_live: + self.enter_dead() + return + self.enter_warn() + return + + if self.state == GD_NF_WARN: + if not pin_low: + self.state = GD_NF_IDLE + self.cause = FAULT_NONE + self.dead_arm = 0 + self.live_arm = 0 + self.cmd_arm = 0 + self.cmd_held_ms = 0 + self.otw_confirmed = 0 + return + if not drive_on: + self.dead_arm = 0 + self.live_arm = 0 + self.cmd_arm = 0 + return + self.cmd_held_add() + if current_live: + self.dead_arm = 0 + if not self.live_arm: + self.live_arm = 1 + self.live_t0 = now + elif _u16(now - self.live_t0) >= GD["GD_OTW_CONFIRM_MS"]: + self.otw_confirmed = 1 + else: + self.live_arm = 0 + if not self.dead_arm: + self.dead_arm = 1 + self.dead_t0 = now + elif _u16(now - self.dead_t0) >= GD["GD_DEAD_DWELL_MS"]: + self.enter_dead() + return + if not self.otw_confirmed and self.cmd_held_ms >= GD["GD_WARN_CEIL_MS"]: + self.enter_dead() + return + + if self.state == GD_NF_HIZ: + self.hold_cut() + if not pin_low: + self.resume_count = 0 + if self.adjusted_input == 0 or _u16(now - self.hiz_t0) >= GD["GD_HIZ_SPINDOWN_MS"]: + self.state = GD_NF_IDLE + self.cause = FAULT_NONE + return + if 0 < self.battery_voltage < GD["GD_UVLO_CV"]: + return + if self.adjusted_input != 0: + return + if _u16(now - self.t0) >= GD["GD_HIZ_RST_MS"]: + self.t0 = now + if self.resume_count >= GD["GD_RESUME_BUDGET"]: + self.enter_latch( + FAULT_GD_UNKNOWN if self.cause == FAULT_NONE else self.cause + ) + else: + self.resume_count += 1 + self.rst_pulses += 1 + return + + if self.state == GD_NF_LATCH: + self.hold_cut() + if (not pin_low) and self.adjusted_input == 0: + self.resume_count = 0 + self.state = GD_NF_IDLE + self.cause = FAULT_NONE + return + + self.state = GD_NF_IDLE + self.cause = FAULT_NONE + + def run_ms(self, n: int) -> None: + for _ in range(n): + self.tick_ms(1) + self.step() diff --git a/hwci/hwci/profiles/g431_current_floor.yaml b/hwci/hwci/profiles/g431_current_floor.yaml new file mode 100644 index 000000000..7529d4391 --- /dev/null +++ b/hwci/hwci/profiles/g431_current_floor.yaml @@ -0,0 +1,27 @@ +name: g431_current_floor +description: > + ARK_G431_CAN nFAULT GD_LIVE_CA_MIN datapoint. Armed idle, then the lowest + nonzero throttles this ESC is actually commanded at (5% / 8%), no-prop. + After the run, p50/p95 hwci_perf.current_ca on idle vs hold05/hold08 + against 50 cA (0.50 A). If either straddles 50, raise GD_LIVE_CA_MIN — + unconfirmed WARN + 250 ms ceiling would false-drop a barely-spinning motor. + Props off. See hwci/docs/BENCH_SETUPS.md (G4 nFAULT current floor). +sample_rate_hz: 100 +arm_settle_s: 3.0 +steady_tail_fraction: 0.5 + +safety: + max_current_a: 80.0 + max_motor_temp_c: 80.0 + max_rpm: 4000 + max_thrust_n: 2.0 + +segments: + - {label: idle, throttle: 0.00, duration_s: 4.0, steady: true} + - {label: ramp05, throttle: 0.05, duration_s: 3.0, ramp: true} + - {label: hold05, throttle: 0.05, duration_s: 6.0, steady: true} + - {label: off05, throttle: 0.00, duration_s: 2.0} + - {label: ramp08, throttle: 0.08, duration_s: 3.0, ramp: true} + - {label: hold08, throttle: 0.08, duration_s: 6.0, steady: true} + - {label: rampdn, throttle: 0.00, duration_s: 3.0, ramp: true} + - {label: stop, throttle: 0.00, duration_s: 2.0} diff --git a/hwci/hwci/profiles/g431_hiz_spindown.yaml b/hwci/hwci/profiles/g431_hiz_spindown.yaml new file mode 100644 index 000000000..62586f161 --- /dev/null +++ b/hwci/hwci/profiles/g431_hiz_spindown.yaml @@ -0,0 +1,41 @@ +name: g431_hiz_spindown +description: > + Size GD_HIZ_SPINDOWN_MS. Hold 20% (same point as the HIZ jumper scope), + cut drive, wait, re-apply 20%. The property that matters is time-to-clean + acquire on the blind startMotor() (interval=10000), not RPM per se. + Short coasts that stall or take many open-loop retries mean the dwell + must be at least that long. Throttle-cut proxy (stop branch then punch): + require_eeprom pins brake_on_stop=0 and rc_car_reverse=0 so the coast is + allOff(), matching HIZ, not a phase-short. The jumper procedure in + BENCH_SETUPS.md is the real nFAULT path — if brake is on, the two + disagree and the jumper is the one to believe. Props off is conservative + (bare rotor coasts longer than a loaded prop on the bench) but a floor, + not an answer: no bench run reproduces windmilling. Pair with + g431_current_floor on the same rig session. +require_eeprom: + brake_on_stop: 0 + rc_car_reverse: 0 +sample_rate_hz: 100 +arm_settle_s: 3.0 +steady_tail_fraction: 0.5 + +safety: + max_current_a: 8.0 + max_motor_temp_c: 80.0 + max_rpm: 4000 + max_thrust_n: 2.0 + +segments: + - {label: idle, throttle: 0.00, duration_s: 2.0, steady: true} + - {label: ramp20, throttle: 0.20, duration_s: 2.0, ramp: true} + - {label: hold20, throttle: 0.20, duration_s: 4.0, steady: true} + - {label: coast05, throttle: 0.00, duration_s: 0.5} + - {label: re20_05, throttle: 0.20, duration_s: 4.0, steady: true} + - {label: coast10, throttle: 0.00, duration_s: 1.0} + - {label: re20_10, throttle: 0.20, duration_s: 4.0, steady: true} + - {label: coast20, throttle: 0.00, duration_s: 2.0} + - {label: re20_20, throttle: 0.20, duration_s: 4.0, steady: true} + - {label: coast30, throttle: 0.00, duration_s: 3.0} + - {label: re20_30, throttle: 0.20, duration_s: 4.0, steady: true} + - {label: rampdn, throttle: 0.00, duration_s: 2.0, ramp: true} + - {label: stop, throttle: 0.00, duration_s: 2.0} diff --git a/hwci/hwci/runner.py b/hwci/hwci/runner.py index 467ec54db..3436cb818 100644 --- a/hwci/hwci/runner.py +++ b/hwci/hwci/runner.py @@ -469,6 +469,25 @@ def _safe(fn): return None +def _enforce_require_eeprom(profile: Profile, blob: bytes | None) -> None: + if not profile.require_eeprom: + return + from .settings import Settings, assert_required, default_blob + assert_required(Settings(blob or default_blob()), profile.require_eeprom) + + +def _enforce_require_eeprom_live(profile: Profile, dbg, elf_path: str) -> None: + if not profile.require_eeprom: + return + from .settings import Settings, assert_required, resolve_eeprom_address + addr = resolve_eeprom_address(dbg, elf_path) + page = Settings.from_device(dbg, addr) + assert_required(page, profile.require_eeprom) + print("eeprom precondition: " + + ", ".join(f"{k}={v}" for k, v in sorted(profile.require_eeprom.items())), + file=sys.stderr) + + # -------------------------------------------------------------------------- # Source builders # -------------------------------------------------------------------------- @@ -493,6 +512,7 @@ def build_sim_sources(rig: RigConfig, profile: Profile, *, demag_prone=demag_prone)) if settings_blob is not None: sim.set_settings(SimSettings.from_blob(settings_blob)) + _enforce_require_eeprom(profile, settings_blob) stand = SimulatedStand(sim, fixed_dt=period).open() stand.set_safety_limits(profile.safety) throttle = FlightStandThrottle(stand, arm_settle_s=0.0) @@ -738,6 +758,11 @@ def build_live_sources(rig: RigConfig, profile: Profile, *, try: if perf_reader is not None: _ensure_app_alive(dbg, perf_reader, throttle, elf=str(elf)) + _enforce_require_eeprom_live(profile, dbg, str(elf)) + elif profile.require_eeprom: + raise ValueError( + f"profile {profile.name!r} requires EEPROM preconditions " + "but debugger_backend is not openocd") if battery_cells is not None: check_battery(_live_voltage(stand, perf_source), battery_cells, min_cell_voltage) diff --git a/hwci/hwci/settings.py b/hwci/hwci/settings.py index d52bc110d..d3bfea5e7 100644 --- a/hwci/hwci/settings.py +++ b/hwci/hwci/settings.py @@ -86,6 +86,10 @@ class Field: "startup duty boost; firmware accepts 50..150"), Field("auto_advance", 47, 0, 1, "1 = firmware maps advance from duty cycle, ignoring advance_level"), + Field("brake_on_stop", 28, 0, 2, + "0=coast (allOff), 1=full brake, 2=active brake"), + Field("rc_car_reverse", 38, 0, 1, + "1 = RC-car reverse / proportional prop brake"), ]} # Identity/version bytes that a settings write must NEVER change: a mismatch @@ -103,6 +107,25 @@ class Field: } +def assert_required(page: Settings, required: dict[str, int]) -> None: + """Refuse a run whose EEPROM would silently invalidate the measurement. + + Empty ``required`` is a no-op. Names must be in :data:`EEPROM_FIELDS`. + """ + if not required: + return + bad = [] + for name, want in required.items(): + got = page.get(name) + if got != want: + bad.append(f"{name}={got} (need {want})") + if bad: + raise SettingsError( + "EEPROM precondition failed: " + ", ".join(bad) + + "; refusing to run rather than produce a plausible wrong number. " + "Inspect with `hwci settings read` and write a corrected page.") + + def resolve_field(name: str, offset: int | None = None) -> Field: """Resolve a tune parameter to a :class:`Field`. diff --git a/hwci/tests/test_config.py b/hwci/tests/test_config.py index 6874fb2f3..69f76dd6f 100644 --- a/hwci/tests/test_config.py +++ b/hwci/tests/test_config.py @@ -121,3 +121,10 @@ def test_profile_roundtrips_through_dict(): p = load_profile("demag_step_stress") q = profile_from_dict(profile_to_dict(p)) assert q == p + + +def test_hiz_spindown_requires_coast_eeprom(): + p = load_profile("g431_hiz_spindown") + assert p.require_eeprom == {"brake_on_stop": 0, "rc_car_reverse": 0} + q = profile_from_dict(profile_to_dict(p)) + assert q.require_eeprom == p.require_eeprom diff --git a/hwci/tests/test_debug_uart.py b/hwci/tests/test_debug_uart.py index 9bbfa9016..2f795a1ec 100644 --- a/hwci/tests/test_debug_uart.py +++ b/hwci/tests/test_debug_uart.py @@ -8,6 +8,18 @@ def test_classify_fault(): assert r.fault == "nFAULT" +def test_classify_warn_nfault_otw_is_not_fault(): + r = _classify("warn: nFAULT OTW", 1.0) + assert r.kind == "raw" + assert r.fault is None + + +def test_classify_warn_nfault_retry_is_not_fault(): + r = _classify("warn: nFAULT retry", 1.5) + assert r.kind == "raw" + assert r.fault is None + + def test_classify_desync(): r = _classify("fault: desync", 2.0) assert r.kind == "fault" diff --git a/hwci/tests/test_gd_nfault.py b/hwci/tests/test_gd_nfault.py new file mode 100644 index 000000000..2f6f913ae --- /dev/null +++ b/hwci/tests/test_gd_nfault.py @@ -0,0 +1,438 @@ +"""DRV8350H nFAULT classifier: host twin of Src/faults.c (no MCU).""" +from __future__ import annotations + +from hwci.gd_nfault_model import ( + FAULT_GD_OCP, + FAULT_GD_OTSD, + FAULT_GD_OTW, + FAULT_GD_UNKNOWN, + FAULT_GD_UVLO, + GD, + GD_NAMES, + GD_NF_CLASSIFY, + GD_NF_HIZ, + GD_NF_IDLE, + GD_NF_LATCH, + GD_NF_WARN, + LOG_ERROR, + LOG_NONE, + LOG_WARNING, + GdNfaultMachine, + parse_gd_defines_from_c, +) + + +def _spinning(**kw) -> GdNfaultMachine: + m = GdNfaultMachine(running=1, adjusted_input=500, actual_current=2000) + for key, value in kw.items(): + setattr(m, key, value) + return m + + +def test_defines_parsed_from_firmware(): + parsed = parse_gd_defines_from_c() + assert parsed == GD + assert GD["GD_CLASSIFY_MS"] == 12 + assert GD["GD_LIVE_CA_MIN"] == 50 + assert GD["GD_DEAD_DWELL_MS"] >= 50 + assert GD["GD_OTW_CONFIRM_MS"] >= 1 + assert GD["GD_WARN_CEIL_MS"] >= GD["GD_DEAD_DWELL_MS"] + assert GD["GD_RESUME_BUDGET"] >= 1 + assert GD["GD_HIZ_SPINDOWN_MS"] >= 2000 + + +def test_short_pulse_is_retry_not_hiz(): + m = _spinning() + m.pin_low = 1 + m.step() + assert m.state == GD_NF_CLASSIFY + m.run_ms(GD["GD_CLASSIFY_MS"] - 1) + assert m.state == GD_NF_CLASSIFY + m.pin_low = 0 + m.step() + assert m.state == GD_NF_IDLE + assert not m.fault_active() + assert m.retry_count == 1 + + +def test_held_live_is_otw_warning(): + m = _spinning() + m.pin_low = 1 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_WARN + assert m.cause == FAULT_GD_OTW + assert m.warning_active() + assert not m.fault_active() + level, cause = m.consume_log() + assert level == LOG_WARNING + assert cause == FAULT_GD_OTW + + +def test_idle_during_classify_is_warn_not_dead(): + m = _spinning() + m.pin_low = 1 + m.step() + m.adjusted_input = 0 + m.actual_current = 0 + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_WARN + assert not m.fault_active() + level, cause = m.consume_log() + assert level == LOG_WARNING + assert cause == FAULT_GD_OTW + + +def test_otw_idle_current_drop_does_not_hiz(): + """Customer symptom: OTW + pilot idle must not latch / Hi-Z.""" + m = _spinning() + m.pin_low = 1 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_WARN + m.adjusted_input = 0 + m.running = 1 + m.actual_current = 0 + m.run_ms(GD["GD_DEAD_DWELL_MS"] + 20) + assert m.state == GD_NF_WARN + assert not m.fault_active() + assert GD_NAMES[m.state] == "WARN" + + +def test_otw_rethrottle_boxcar_does_not_false_hiz(): + m = _spinning() + m.pin_low = 1 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + m.adjusted_input = 0 + m.actual_current = 0 + m.step() + m.adjusted_input = 500 + m.running = 1 + m.actual_current = 0 + m.run_ms(GD["GD_DEAD_DWELL_MS"] - 1) + assert m.state == GD_NF_WARN + m.actual_current = 200 + m.step() + assert m.state == GD_NF_WARN + assert not m.fault_active() + + +def test_commanded_collapse_after_dwell_is_hiz_not_latch(): + m = _spinning() + m.pin_low = 1 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_WARN + m.actual_current = 0 + m.run_ms(GD["GD_DEAD_DWELL_MS"] + 1) + assert m.state == GD_NF_HIZ + assert m.cause == FAULT_GD_UNKNOWN + assert m.fault_active() + assert m.state != GD_NF_LATCH + + +def test_mcu_hot_is_log_label_not_hiz_vs_latch(): + m = _spinning(degrees_celsius=120) + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_HIZ + assert m.cause == FAULT_GD_OTSD + assert m.state != GD_NF_LATCH + + +def test_mcu_cool_held_dead_is_hiz_not_gdf_latch(): + m = _spinning(degrees_celsius=25) + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_HIZ + assert m.cause == FAULT_GD_UNKNOWN + assert m.state != GD_NF_LATCH + + +def test_uvlo_is_hiz(): + m = _spinning(battery_voltage=600) + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_HIZ + assert m.cause == FAULT_GD_UVLO + + +def test_hiz_zero_throttle_stays_hiz_while_pin_low(): + m = _spinning() + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_HIZ + m.adjusted_input = 0 + m.step() + assert m.state == GD_NF_HIZ + assert m.keep_awake() + + +def test_hiz_pin_release_at_throttle_waits_spindown(): + """Short HIZ must not blind-start into a spinning rotor.""" + m = _spinning() + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.adjusted_input != 0 + m.pin_low = 0 + m.step() + assert m.state == GD_NF_HIZ + assert m.fault_active() + m.run_ms(GD["GD_HIZ_SPINDOWN_MS"] - 1) + assert m.state == GD_NF_HIZ + m.run_ms(1) + assert m.state == GD_NF_IDLE + assert not m.fault_active() + assert m.adjusted_input != 0 + + +def test_hiz_pin_release_at_zero_throttle_is_immediate(): + m = _spinning() + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + m.adjusted_input = 0 + m.pin_low = 0 + m.step() + assert m.state == GD_NF_IDLE + assert not m.fault_active() + + +def test_hiz_pin_release_idles_when_throttle_drops(): + """Zero throttle unblocks resume without waiting out the coast dwell.""" + m = _spinning() + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + m.pin_low = 0 + m.step() + assert m.state == GD_NF_HIZ + m.adjusted_input = 0 + m.step() + assert m.state == GD_NF_IDLE + assert not m.fault_active() + + +def test_hiz_long_coast_resumes_at_throttle_on_pin_high(): + """OTSD/UVLO that already coasted: pin-high at throttle is IDLE.""" + m = _spinning() + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_HIZ + m.run_ms(GD["GD_HIZ_SPINDOWN_MS"]) + assert m.state == GD_NF_HIZ + assert m.adjusted_input != 0 + m.pin_low = 0 + m.step() + assert m.state == GD_NF_IDLE + assert not m.fault_active() + + +def test_hiz_spindown_survives_trst_pulse(): + """gd_hiz_t0 is from enter_dead; tRST rewriting gd_t0 must not stretch it.""" + m = _spinning() + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_HIZ + hiz_t0 = m.hiz_t0 + m.adjusted_input = 0 + m.run_ms(GD["GD_HIZ_RST_MS"]) + assert m.rst_pulses == 1 + assert m.t0 != hiz_t0 + assert m.hiz_t0 == hiz_t0 + m.adjusted_input = 500 + m.pin_low = 0 + m.step() + elapsed = (m.ms - m.hiz_t0) & 0xFFFF + assert elapsed < GD["GD_HIZ_SPINDOWN_MS"] + assert m.state == GD_NF_HIZ + m.run_ms(GD["GD_HIZ_SPINDOWN_MS"] - elapsed) + assert m.state == GD_NF_IDLE + assert not m.fault_active() + + +def test_hiz_trst_budget_latches(): + m = _spinning() + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + m.adjusted_input = 0 + for _ in range(GD["GD_RESUME_BUDGET"]): + m.run_ms(GD["GD_HIZ_RST_MS"]) + assert m.state == GD_NF_HIZ + m.run_ms(GD["GD_HIZ_RST_MS"]) + assert m.state == GD_NF_LATCH + assert m.rst_pulses == GD["GD_RESUME_BUDGET"] + + +def test_retry_budget_latches_when_pulses_are_visible(): + m = _spinning() + for _ in range(GD["GD_RETRY_BUDGET"]): + m.pin_low = 1 + m.step() + assert m.state == GD_NF_CLASSIFY + m.pin_low = 0 + m.ms = (m.ms + 1) & 0xFFFF + m.step() + if m.state == GD_NF_LATCH: + break + assert m.state == GD_NF_LATCH + assert m.cause == FAULT_GD_OCP + + +def test_queue_log_does_not_clobber_same_level(): + m = GdNfaultMachine() + m.queue_log(LOG_ERROR, FAULT_GD_UVLO) + m.queue_log(LOG_ERROR, FAULT_GD_UNKNOWN) + m.queue_log(LOG_WARNING, FAULT_GD_OTW) + level, cause = m.consume_log() + assert level == LOG_ERROR + assert cause == FAULT_GD_UVLO + level, _ = m.consume_log() + assert level == LOG_NONE + + +def test_confirmed_otw_survives_warn_ceiling(): + m = _spinning() + m.pin_low = 1 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_WARN + assert m.otw_confirmed + m.run_ms(GD["GD_WARN_CEIL_MS"] + 50) + assert m.state == GD_NF_WARN + assert not m.fault_active() + + +def test_unconfirmed_flicker_hits_warn_ceiling(): + """Intermittent ≥ LIVE_CA_MIN must not starve the dwell forever.""" + m = _spinning() + m.pin_low = 1 + m.step() + m.actual_current = 80 # live vs 50, but < snap/2 → unconfirmed WARN + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_WARN + assert not m.otw_confirmed + for _ in range(GD["GD_WARN_CEIL_MS"] + 30): + m.actual_current = 80 if ((m.ms // 20) % 2) == 0 else 0 + m.tick_ms(1) + m.step() + assert m.state == GD_NF_HIZ + assert m.fault_active() + + +def test_hiz_pin_high_resets_resume_budget(): + """Consecutive-failed tRST: a real pin-high recovery starts a new streak.""" + m = _spinning() + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + m.adjusted_input = 0 + m.run_ms(GD["GD_HIZ_RST_MS"]) + m.run_ms(GD["GD_HIZ_RST_MS"]) + assert m.resume_count == 2 + m.pin_low = 0 + m.step() + assert m.state == GD_NF_IDLE + assert m.resume_count == 0 + m.running = 1 + m.adjusted_input = 500 + m.actual_current = 0 + m.pin_low = 1 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_HIZ + assert m.state != GD_NF_LATCH + + +def test_resume_budget_survives_sleep_idle(): + m = _spinning() + m.pin_low = 1 + m.actual_current = 0 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + m.adjusted_input = 0 + m.run_ms(GD["GD_HIZ_RST_MS"]) + assert m.rst_pulses == 1 + m.awake = 0 + m.step() + assert m.state == GD_NF_IDLE + assert m.resume_count == 1 + + +def test_cmd_held_accumulates_across_idle_blips(): + """Unconfirmed ceiling is total commanded-held, not one continuous burst.""" + m = _spinning() + m.pin_low = 1 + m.step() + m.actual_current = 80 + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.state == GD_NF_WARN + assert not m.otw_confirmed + + def flicker(n: int) -> None: + for _ in range(n): + m.actual_current = 80 if ((m.ms // 20) % 2) == 0 else 0 + m.tick_ms(1) + m.step() + + flicker(120) + assert m.state == GD_NF_WARN + m.adjusted_input = 0 + m.run_ms(40) + assert m.state == GD_NF_WARN + m.adjusted_input = 500 + m.running = 1 + flicker(GD["GD_WARN_CEIL_MS"] - 120 + 20) + assert m.state == GD_NF_HIZ + + +def test_first_retry_logs(): + m = _spinning() + m.pin_low = 1 + m.step() + m.pin_low = 0 + m.step() + level, cause = m.consume_log() + assert level == LOG_WARNING + assert cause == FAULT_GD_OCP + + +def test_otw_log_is_rate_limited(): + m = _spinning() + m.pin_low = 1 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.consume_log()[0] == LOG_WARNING + m.pin_low = 0 + m.step() + m.pin_low = 1 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.consume_log()[0] == LOG_NONE + m.pin_low = 0 + m.step() + m.tick_ms(GD["GD_OTW_LOG_MS"]) + m.pin_low = 1 + m.step() + m.run_ms(GD["GD_CLASSIFY_MS"]) + assert m.consume_log()[0] == LOG_WARNING diff --git a/hwci/tests/test_runner_sim.py b/hwci/tests/test_runner_sim.py index 813e646dc..104208046 100644 --- a/hwci/tests/test_runner_sim.py +++ b/hwci/tests/test_runner_sim.py @@ -16,6 +16,26 @@ def _run(profile_name, demag_prone=True): sources.close() +def test_hiz_spindown_sim_accepts_default_coast_eeprom(): + profile = load_profile("g431_hiz_spindown") + sources = build_sim_sources(RigConfig(), profile) + sources.close() + assert profile.require_eeprom["brake_on_stop"] == 0 + + +def test_hiz_spindown_sim_rejects_brake_on_stop(): + from hwci import settings as st + import pytest + from hwci.runner import build_sim_sources + + rig = RigConfig() + profile = load_profile("g431_hiz_spindown") + blob = bytearray(st.default_blob()) + blob[28] = 1 + with pytest.raises(st.SettingsError, match="brake_on_stop"): + build_sim_sources(rig, profile, settings_blob=bytes(blob)) + + def test_ci_smoke_runs_and_has_steady_points(): result, profile = _run("ci_smoke") assert result.meta["aborted"] is None diff --git a/hwci/tests/test_settings.py b/hwci/tests/test_settings.py index 84151bd44..aa09ab9de 100644 --- a/hwci/tests/test_settings.py +++ b/hwci/tests/test_settings.py @@ -15,6 +15,8 @@ def test_default_blob_round_trips_known_fields(): assert s.get("max_ramp") == 160 assert s.get("minimum_duty_cycle") == 1 assert s.get("startup_power") == 100 + assert s.get("brake_on_stop") == 0 + assert s.get("rc_car_reverse") == 0 def test_apply_returns_mutated_copy_and_diff_names_fields(): @@ -52,6 +54,8 @@ def test_bin_round_trip(tmp_path): ("max_ramp", 256), ("minimum_duty_cycle", 0), ("minimum_duty_cycle", 51), + ("brake_on_stop", 3), + ("rc_car_reverse", 2), ]) def test_out_of_range_values_are_refused_not_clamped(name, value): base = st.Settings(st.default_blob()) @@ -59,6 +63,18 @@ def test_out_of_range_values_are_refused_not_clamped(name, value): base.apply({name: value}) +def test_assert_required_passes_on_match_and_empty(): + s = st.Settings(st.default_blob()) + st.assert_required(s, {}) + st.assert_required(s, {"brake_on_stop": 0, "rc_car_reverse": 0}) + + +def test_assert_required_refuses_mismatch(): + s = st.Settings(st.default_blob()).apply({"brake_on_stop": 1}) + with pytest.raises(st.SettingsError, match="brake_on_stop=1"): + st.assert_required(s, {"brake_on_stop": 0}) + + def test_unknown_name_requires_explicit_offset(): base = st.Settings(st.default_blob()) with pytest.raises(st.SettingsError, match="unknown setting"):