feat(limits): smooth composable thermal + current ceilings, armed on ARK_G431_CAN - #89
Merged
AlexKlimaj merged 5 commits intoAug 9, 2026
Conversation
…ARK_G431_CAN Both protections shipped disabled, and the thermal one was a cliff rather than a derate. This makes them two smooth ceilings that compose, and turns them on for the 12S CAN board. WHY THEY WERE OFF: default_settings[] in DroneCAN.c is the AM32 configurator skeleton, and both factory JSONs were seeded from it. Its bytes 43/44 are 141 and 102 - each just outside the range settings.c arms (70..140 C, 1..100 raw = 2..200 A) - so every ARK ESC has shipped with the thermal derate and the current limiter silently inert. A DroneCAN param ERASE memcpy's that array back over the page, so the defaults had to move too or "restore defaults" would quietly disarm a shipped ESC. THERMAL: was map([limit-10, limit+10] -> [max/2, 1]) entered only at temp > limit, so one degree of overshoot stepped the ceiling from full authority to ~22% and one degree of recovery restored it - a bang-bang across the whole derate range, cycling on the board's thermal time constant. It also overwrote duty_cycle_maximum, discarding the low-rpm map and the post-desync ceiling hold. Now runtimeThermalLimitTick() runs at 1 kHz off a Q12 IIR of the die sensor (tau ~64 ms - the raw reading is a whole-degree unaveraged conversion that dithers +-1 C) and produces its own ceiling: full authority at the limit, falling linearly to 10% over 20 C. A proportional derate settles; a threshold cannot. CURRENT: unchanged in mechanism, but the integrating ceiling is now released whenever the loop that owns it is not driving. It had no other reset, so a run that ended current-limited left it low and the next start was capped below min_startup_duty until the PID walked it back up. COMPOSITION: both ceilings are min()-combined in setInput() and applied after the stall-protection boost, so a boost can no longer push duty back through a protection ceiling. min() not a product - each is an independent "do not exceed", and scaling them together would derate to 25% when each asked for 50%. Neither can step thrust: the 20 kHz ramp limiter slews applied duty in both directions. CURRENT SENSE: actual_current now saturates instead of wrapping. It is int16_t centiamps (327.67 A) while a 10 mV/A chain reads to 329.6 A, so raw >= 4071 overflowed negative and hit the "< 0" clamp as ZERO amps - inverting the limiter, the nFAULT OCP classifier and telemetry at >326 A. TUNABLE: the foldback width is eeprom byte 184 / DroneCAN TEMP_DERATE_BAND (5..40 C, default 15). There is no hard-cut response mode to select - dropping a motor outright on a multirotor is worse than flying on a derated one - so the slope is the knob. 0xFF on any page that never wrote the CAN block is coerced to the default in settings.c. ARK_G431_CAN defaults: foldback 105 -> 120 C, current limit 200 A (the most settings.c arms, ~80% of the 250 A shunt rating, a backstop under the DRV8350 VDS trip), current PID left at 100/0/50. 105 C matches professional 12S practice and sits inside the G4 die sensor's 30..110 C factory calibration; the caveat to settle on the bench is that APD/T-Motor read an NTC on the power stage while this reads the MCU die, so if the die-to-FET delta is large the onset belongs lower. GAINS: measured, not reasoned. Softening the current loop looks right and is wrong: the ceiling moves by pid_output/10000 duty units per tick and that integer divide is a DEAD ZONE - the loop is inert until the overshoot exceeds 5000/Kp centiamps, so Kp 10 would need 10 A before the ceiling moved at all. SITL heavy_13inch at an 8 A limit: P=100 holds 7.7 A, P=50 7.1 A, P=25 6.5 A, P=5 14.7 A. Upstream's gains stay. ARK_4IN1_F051 stays off: its shunt is shared across all four ESCs and its thermal placement has not been benched. Observability: ZC_STATS v7 publishes both ceilings, the applied one, raw and filtered die temperature, and current, because these limiters only ever LOWER duty - from outside, a derating ESC and a weak plant look identical. test_duty_limits.py walks the derate band a degree at a time and fails if any single-degree step exceeds 200 counts (the old map steps ~1550); asserts the shipped defaults arm it, that 255 still disables it, that the band knob changes the slope, and that the applied ceiling is min() of the two. The current loop is asserted on REGULATED CURRENT against a plant pulling twice the limit, because only that catches the gain mistake above - it reports 15.0 A against an 8 A limit at P=5. check-factory-image-ark.sh gates bytes 9/10/11/43/44/184 against the product JSON and prints the armed state. Verified: make ARK_G431_CAN (flash 41.9%), make size-check-ark PASS (F051 27080/27592), make factory-image-check PASS for both products, SITL suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fj2YRWedfZ53FSwDTdsfLF
A param ERASE memcpy's default_settings[] over the whole eeprom page. Now that both limiters ship armed, that array decides whether a field "restore defaults" leaves an ESC protected - and it is the one config change nobody re-checks afterwards. Bytes 43/44 already carried the shipped 105/100. Two gaps remained: - Byte 184 (foldback band) lives PAST the 48-byte configurator skeleton, so an erase left it 0xFF and settings.c coerced it to the compile-time default. That happens to equal what ARK_G431_CAN ships, so the behaviour was right by coincidence and would have broken silently the first time a product shipped a different band. apply_post_skeleton_defaults() now writes it explicitly from TARGET_DEFAULT_TEMP_DERATE_BAND. - The three copies of these numbers (targets.h macros, default_settings[], the product JSON) had nothing keeping them in step. scripts/check-erase-defaults.py gates all three, and additionally fails if either default falls outside the range settings.c arms - so "erase leaves the limiters off" cannot return as a silent regression. Wired into make factory-image-check. Bytes 43/44 stay hex literals in the array on purpose: Mcu/SITL/sitl_params.py parses it to build eeprom images, so it has to stay pure hex. test_param_erase_restores_armed_protections drives the real opcode over CAN: clears all three params, erases, then requires (105, 200, 15) on read-back plus the armed ranges. Restoring the upstream 0x8d/0x66 bytes makes it fail with TEMPERATURE_LIMIT=255. max_ramp is NOT fixed here: an erase still reverts it to upstream's 160 (16 %/ms) rather than the ARK 20 (2 %/ms). Same class of hole, wants its own change.
The curve check compared the ceiling to the temperature the test ASKED for, so a host slow enough to leave the filter a degree short failed as a wrong derate. Check against degrees_celsius_filt instead - whatever degree the firmware reports, the ceiling has to be on the curve for it - and require the reading to persist across two samples so a mid-transition sample cannot be mistaken for a settled one. Net effect is a TIGHTER assertion: the old window was -110/+15 counts to absorb the lag, the new one is a symmetric 75 for the rounding residue alone. Three consecutive full runs green.
The gain is now settable over CAN, so the one thing an integrator needs to know before turning it down belongs next to the parameter: the ceiling update divides by 10000 with integer arithmetic, so lowering Kp widens a dead zone rather than softening a response. Measured numbers are in control_loop.c.
AlexKlimaj
force-pushed
the
claude/ark32-am32-temp-current-limiting-8fl2tw
branch
from
August 9, 2026 20:10
6294207 to
8468c25
Compare
… of it The 12S CAN board now ramps at 0.5 %/ms (full scale in 200 ms), matching what larger 12S ESCs ship - APD and Hargrave default to 50 % per 100 ms. The ARK 4IN1 keeps its bench-derived 2.0 %/ms; only the G431 product moves. Stored as 5, which puts the firmware in FINE mode (ramp_divider 9, one step every 500 us, so the number means a tenth of what it means in coarse mode). That exposed a real bug, not just a slower ramp. Fine mode applied the eeprom value to all three regimes, which handed the spool-up ramp to a cruise setting: at 0.5 %/ms the racer plant could not start AT ALL in SITL (test_acq_desync_rail, "motor never entered running", 12 s, deterministic - clean at the old 16 %/ms). That contradicts the ramp schedule's own documented intent, which is that RAMP_SPEED_STARTUP governs spool-up reliability and is deliberately not a vehicle-tuning knob (targets.h). settings.c now scales the coarse startup ceiling into fine-cadence units (x10), so startup slews at exactly the rate it would in coarse mode while low/high rpm honour the requested value. Nothing here lets the eeprom RAISE a regime past its targets.h ceiling. test_fine_ramp_keeps_the_startup_rate reads all four ramp fields straight out of ZC_STATS - no motor, no timing, so it cannot go flaky or pass by accident. NOT changed: default_settings[5] and the DroneCAN MAX_RAMP default stay at upstream's 160, so a param erase still widens the ramp 32x on this board. Moving them also moves the SITL seed image, and six fault-injection tests provoke desync by slewing fast - at 0.5 %/ms they cannot create the condition they assert about. Pinning the ramp inside those tests is the right fix and wants its own change. check-erase-defaults.py now gates the macro against the product JSON and prints the divergence explicitly rather than implying the ramp is restored. Verified: make ARK_G431_CAN (flash 42.4%), ARK_4IN1_F051, size-check-ark PASS, factory-image-check PASS both products (G431 max_ramp=5, F051 max_ramp=20), erase-defaults gate, 6 duty-limit tests. Full-suite counts on this host are noise-dominated (base 2 and 5 failures, this branch 4 and 3, alternating runs of the same two binaries, every failing test appearing on both) - CI is the real gate.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes the thermal and current limiters two soft, composable, tunable duty ceilings, and turns both on by default for the ARK 12S CAN board. Stacked on #79 — the
ARK_G431_CANtarget and its factory JSON only exist there, so this targets that branch rather thanark-release.Why they were off.
default_settings[]inDroneCAN.cis the AM32 configurator skeleton, and both factory JSONs were seeded from it. Its bytes 43/44 are141and102— each just outside the rangesettings.carms (70..140 °C, 1..100 raw = 2..200 A) — so every ARK ESC has shipped with both limiters silently inert. A DroneCAN param erase memcpy's that array back over the page, so the defaults had to move too, or "restore defaults" would quietly disarm a shipped ESC.Changes
map([limit-10, limit+10] → [max/2, 1])entered only attemp > limit, so one degree of overshoot stepped the ceiling from full authority to ~22% and one degree of recovery restored it — a bang-bang across the whole derate range, cycling on the board's thermal time constant. It also overwroteduty_cycle_maximum, discarding the low-rpm map and the post-desync ceiling hold (fix(limits): hold the low-rpm throttle ceiling across a desync #62). NowruntimeThermalLimitTick()runs at 1 kHz off a Q12 IIR of the die sensor (τ ≈ 64 ms — the raw reading is a whole-degree unaveraged conversion that dithers ±1 °C) and produces its own ceiling, falling linearly from the onset to a 10% floor. A proportional derate settles; a threshold cannot.TEMP_DERATE_BAND(5..40 °C, default 15). There is deliberately no hard-cut mode to select — dropping a motor outright on a multirotor is worse than flying on a derated one — so the slope is the knob.0xFFon any page that never wrote the CAN block is coerced insettings.c.min_startup_dutyuntil the PID walked it back up.min()insetInput(), applied after the stall-protection boost — a boost that can push duty back through a protection ceiling is not a ceiling. Not a product: thermal at 50% and current at 50% must give 50%, not 25%.actual_currentisint16_tcentiamps (327.67 A) while a 10 mV/A chain reads to 329.6 A, soraw >= 4071overflowed negative and hit the< 0clamp as zero amps — releasing the limiter, blinding the nFAULT OCP classifier and reporting an idle ESC, all at >326 A, i.e. exactly the currents the VDS trip exists to catch.ZC_STATSv7 publishes both ceilings, the applied one, raw + filtered die temperature and current. These limiters only ever lower duty, so from outside a derating ESC and a weak plant look identical (cf. thedcm_holdno-op intest_ceiling_hold.py).check-factory-image-ark.shnow gates bytes 9/10/11/43/44/184 against the product JSON and prints the armed state.ARK_G431_CAN defaults
TEMP_DERATE_BAND100, 2 A/count)200 A is the most
settings.carms, ~80% of the 250 A shunt rating: a backstop under the DRV8350 VDS trip, not a flight limiter. The 4IN1 F051 stays off — its shunt is shared by all four ESCs, so per-motor current limiting is not meaningful there, and its thermal placement has not been benched.Gains: measured, not reasoned
Softening the current loop looks right and is wrong. The ceiling moves by
pid_output / 10000duty units per tick, and that integer divide is a dead zone — the loop is inert until the overshoot exceeds5000/Kpcentiamps, soKp10 would need 10 A before the ceiling moved at all. SITLheavy_13inchat an 8 A limit:Upstream's gains stay, and the dead zone is documented at the call site so this is not re-litigated from theory.
Not in this PR
faultErrorCount()isdesync_happened + fault_stall_trips, so a gate-driver trip appears in the one-shot nFAULT LogMessage but never in the periodicesc.Status.error_count. Left alone deliberately — it changes an existing field's semantics.default_settings[]is still upstream's everywhere else, so an erase also reverts e.g.max_rampto 16 %/ms rather than the ARK 2 %/ms. Pre-existing hole in the erase path; wants its own fix.Test plan
make ARK_G431_CAN— flash 46757 / 111584 B (41.9%)make size-check-ark— PASS, F051 27080 / 27592 (this change costs +264 B of a tight budget)make factory-image-check— PASS both products; reportsthermal=derate 105->120 C current=200 Afor G431 andthermal=OFF (141) current=OFF (102)for the 4IN1make sitl+ 5 new tests inMcu/SITL/tests/test_duty_limits.pyCURRENT_P=5fails the load test at 15.0 A against an 8 A limitSITL A/B detail
Both binaries built from the same tree (stock = #79 head), run back to back on the same host:
Identical failure counts, but the failing sets differ by 5 in both directions — the signature of host flakiness, not a regression (a real regression adds failures without removing any). Every failure is a
rpm=0spin-timing or bench-map assertion on a slow container. The two failures that appeared only on this branch were re-run against both binaries:test_safety.py::test_dronecan_disarm_zeros_input— passes on both.test_bench_models.py::test_bench_steady_map_matches_hardware— fails on both, and on a different plant parametrization each run (900kv_nopropvs900kv_10inch).Worth re-running on CI hardware where the suite is green at baseline.