From 8c6656b118ab390e1e0511d7bd5d7e250ede4b71 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 01:03:13 +0000 Subject: [PATCH] fix(dronecan): count stall-rail trips in esc.Status error_count error_count was desync_happened alone, which is incremented in exactly one place - the jump-desync check in runtimeProcessDesyncCheck. Every other way an established run dies left it untouched: - the INTERVAL_TIMER stall rail (faultHandleBemfIntervalStall) bumps bemf_timeout_happened and restarts the loop - the blind-grind rail kicks INTERVAL_TIMER to 46000 so that stall rail is guaranteed to trip on the next main-loop pass - the blind-step / miss-bucket limit in bemf_zc.c hands off the same way So the continuous partial desync documented at the top of the grind rail (423 blind steps/s at a 19.5% miss rate, duty cut engaging and releasing in a limit cycle to 102 A) reported error_count 0 to the flight controller for the entire event. Add fault_stall_trips, a monotonic since-boot counter incremented at the stall rail under the existing zero_crosses > 100 gate, and sum it with desync_happened in the new faultErrorCount(). The gate matters: a start attempt that never got going is the stuck-rotor rail's job, with its throttle-scaled tolerance, and heavy props legitimately kick many times at low throttle - counting those would make error_count climb on every normal start. The stall rail is the single aggregation point for the grind and blind/miss-limit paths, so counting it once is what keeps one physical failure from being reported as two or three events. The episode-rail latch is likewise not a separate addend: it is a consequence of accumulating the above and is a state (ESC_FAULT_STUCK), not an event. Attribution between the sub-causes belongs in the AM32-reserved FlexDebug payload and is left to a follow-up. The counter is volatile: it is read from the main loop for telemetry while the grind rail that forces the trip runs in tenKhzRoutine, i.e. ISR context. Extend the existing SITL esc.Status test to assert a healthy spool-up to a steady 4-6k rpm reports error_count 0 - the regression guard for the zero_crosses gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HoD65bRT7unXhETnZ13WEq --- Inc/faults.h | 30 ++++++++++++++++++++++++++++++ Mcu/SITL/tests/test_dronecan.py | 7 +++++++ Src/DroneCAN/DroneCAN.c | 9 ++++++--- Src/faults.c | 14 ++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Inc/faults.h b/Inc/faults.h index d73425922..724ca85ec 100644 --- a/Inc/faults.h +++ b/Inc/faults.h @@ -74,4 +74,34 @@ void faultDesyncEpisodeTick1kHz(void); /* True while a post-desync coast is mandatory (caller must not re-start). */ uint8_t faultDesyncRestartHoldoffActive(void); +/* + * Monotonic since-boot count of stall-rail trips on an ESTABLISHED run - the + * same zero_crosses > 100 gate the episode charge uses, so the many legitimate + * kicks a heavy prop needs at low throttle are not counted as errors. + * + * volatile: read from the main loop (telemetry) while the grind rail that + * forces this trip runs in tenKhzRoutine (ISR context). + */ +extern volatile uint32_t fault_stall_trips; + +/* + * Total hard-error events since boot, for uavcan.equipment.esc.Status + * error_count ("errors since start-up"). + * + * Sums the two PRIMITIVE run-killers, which are mutually exclusive: + * - desync_happened : the jump-desync check in runtimeProcessDesyncCheck + * - fault_stall_trips : the INTERVAL_TIMER stall rail + * + * Deliberately NOT additional addends, because each already funnels into the + * stall rail and would double-count one physical failure: + * - blind-grind rail (faultDesyncEpisodeTick1kHz) kicks INTERVAL_TIMER to + * 46000 so the stall rail is guaranteed to trip on the next main pass + * - blind-step / miss-bucket limit (bemf_zc.c) hands off the same way + * - the episode-rail latch is a CONSEQUENCE of accumulating the above, and + * is a state (ESC_FAULT_STUCK), not an independent event + * Attribution between those sub-causes belongs in the AM32-reserved FlexDebug + * payload, not in this scalar. + */ +uint32_t faultErrorCount(void); + #endif /* FAULTS_H_ */ diff --git a/Mcu/SITL/tests/test_dronecan.py b/Mcu/SITL/tests/test_dronecan.py index ef3d3e090..5a23108ca 100644 --- a/Mcu/SITL/tests/test_dronecan.py +++ b/Mcu/SITL/tests/test_dronecan.py @@ -35,6 +35,7 @@ def test_dronecan_throttle_and_esc_status(sitl_can_factory, state_stream, mcast_ def on_esc(e): status['rpm'] = e.message.rpm status['voltage'] = e.message.voltage + status['errors'] = e.message.error_count status['count'] = status.get('count', 0) + 1 node.add_handler(dronecan.uavcan.equipment.esc.Status, on_esc) @@ -59,6 +60,12 @@ def on_esc(e): assert 3500 <= status.get('rpm', -1) <= 6500, status assert 15 < status.get('voltage', 0) < 18, status assert status.get('count', 0) >= 3, 'too few esc.Status: %s' % status + # error_count aggregates hard-error events (jump desync + stall rail). + # An honest spool-up to a steady 4-6k rpm must report none: the stall + # rail is gated on zero_crosses > 100 precisely so the kicks a normal + # start needs are not counted as errors. + assert status.get('errors', -1) == 0, \ + 'healthy run reported error_count=%s' % status.get('errors') finally: # stop motor for _ in range(10): diff --git a/Src/DroneCAN/DroneCAN.c b/Src/DroneCAN/DroneCAN.c index 5390ee5f7..94472b12c 100644 --- a/Src/DroneCAN/DroneCAN.c +++ b/Src/DroneCAN/DroneCAN.c @@ -14,6 +14,7 @@ # include "signal.h" # include "version.h" # include "eeprom.h" +# include "faults.h" # include # include # include @@ -107,7 +108,6 @@ extern volatile char armed; extern volatile uint32_t commutation_interval; extern uint8_t auto_advance_level; extern uint16_t low_cell_volt_cutoff; -extern uint32_t desync_happened; static uint16_t last_can_input; static uint64_t last_heartbeat_us; @@ -1006,8 +1006,11 @@ static void send_ESCStatus(void) struct uavcan_equipment_esc_Status pkt; uint8_t buffer[UAVCAN_EQUIPMENT_ESC_STATUS_MAX_SIZE]; - // make up some synthetic status data - pkt.error_count = desync_happened; // fill desync count here + /* Hard-error events since boot. Was desync_happened alone, which missed + * every run killed by the stall rail (and so also the blind-grind and + * blind/miss-limit paths that funnel into it) - a grinding motor + * reported error_count 0. See faultErrorCount(). */ + pkt.error_count = faultErrorCount(); pkt.voltage = battery_voltage * 0.01; pkt.current = (current.sum / (float)current.count) * 0.01; diff --git a/Src/faults.c b/Src/faults.c index f70f0ef47..619c76ab0 100644 --- a/Src/faults.c +++ b/Src/faults.c @@ -27,6 +27,14 @@ extern volatile uint16_t zero_input_count; extern volatile uint32_t dma_buffer[64]; extern void resetInputCaptureTimer(void); +/* See the comments on the declarations in faults.h. */ +volatile uint32_t fault_stall_trips = 0; + +uint32_t faultErrorCount(void) +{ + return desync_happened + fault_stall_trips; +} + uint8_t faultHandleStuckRotorIfNeeded(void) { #ifndef BRUSHED_MODE @@ -327,6 +335,12 @@ void faultHandleBemfIntervalStall(void) // this rail is guaranteed to run next pass): blind stepping only // arms at zero_crosses >= 100, so those episodes always charge. if (zero_crosses > 100) { + /* Established run died here. This is the ONLY place the + * stall rail is counted, and it is the aggregation point + * for the grind rail and the blind/miss-limit handoff, + * both of which reach the loop through this trip - see + * faultErrorCount(). */ + fault_stall_trips++; faultDesyncEpisodeCharge(DESYNC_EPISODE_STALL_RAIL); } if (escIsFault()) {