Skip to content

feat(limits): smooth composable thermal + current ceilings, armed on ARK_G431_CAN - #89

Merged
AlexKlimaj merged 5 commits into
feat/embed-g431-can-bootloaderfrom
claude/ark32-am32-temp-current-limiting-8fl2tw
Aug 9, 2026
Merged

feat(limits): smooth composable thermal + current ceilings, armed on ARK_G431_CAN#89
AlexKlimaj merged 5 commits into
feat/embed-g431-can-bootloaderfrom
claude/ark32-am32-temp-current-limiting-8fl2tw

Conversation

@AlexKlimaj

@AlexKlimaj AlexKlimaj commented Aug 9, 2026

Copy link
Copy Markdown
Member

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_CAN target and its factory JSON only exist there, so this targets that branch rather than ark-release.

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

  • Thermal is now a foldback, not a threshold. It 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 (fix(limits): hold the low-rpm throttle ceiling across a desync #62). Now runtimeThermalLimitTick() 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.
  • Tunable slope. Foldback width is eeprom byte 184 / DroneCAN 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. 0xFF on any page that never wrote the CAN block is coerced in settings.c.
  • Current ceiling is released when not driving. It is an integrator with 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.
  • Ceilings compose by min() in setInput(), 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%.
  • Current sense saturates instead of wrapping. actual_current 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 — 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.
  • Observability. ZC_STATS v7 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. the dcm_hold no-op in test_ceiling_hold.py). check-factory-image-ark.sh now gates bytes 9/10/11/43/44/184 against the product JSON and prints the armed state.

ARK_G431_CAN defaults

Setting Value
Thermal foldback 105 → 120 °C (die), 10% floor
TEMP_DERATE_BAND 15 °C
Current limit 200 A (stored 100, 2 A/count)
Current PID 100 / 0 / 50 (unchanged)

200 A is the most settings.c arms, ~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 / 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:

gains regulated current
P=100/D=50 (shipped) 7.7 A
P=50/D=25 7.1 A
P=25/D=10 6.5 A
P=5/D=5 14.7 A — does not hold the 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

  • The 105 °C onset needs a bench number. It follows professional 12S practice (APD / industrial T-Motor fold back around 110 °C) and sits inside the G4 die sensor's 30..110 °C factory calibration span, with full derate below the part's 125 °C junction limit. But those vendors read an NTC on the power stage and this reads the MCU die — if the die-to-FET delta on this board turns out large, the onset belongs lower. It is a DroneCAN parameter, so that is a config change, not a reflash.
  • Current-sense calibration against a real load (SITL only here).
  • faultErrorCount() is desync_happened + fault_stall_trips, so a gate-driver trip appears in the one-shot nFAULT LogMessage but never in the periodic esc.Status.error_count. Left alone deliberately — it changes an existing field's semantics.
  • The 4IN1 F051 factory defaults (still off).
  • default_settings[] is still upstream's everywhere else, so an erase also reverts e.g. max_ramp to 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; reports thermal=derate 105->120 C current=200 A for G431 and thermal=OFF (141) current=OFF (102) for the 4IN1
  • make sitl + 5 new tests in Mcu/SITL/tests/test_duty_limits.py
  • Mutation checks: restoring the old threshold map drives the ceiling to 1 and fails the continuity test; setting CURRENT_P=5 fails the load test at 15.0 A against an 8 A limit
  • Full SITL suite A/B vs stock — no regression attributable to this change, details below
  • Hardware: die-to-FET delta under sustained load; confirm the foldback engages and recovers smoothly; confirm current-sense calibration at load

SITL A/B detail

Both binaries built from the same tree (stock = #79 head), run back to back on the same host:

failed passed
stock #79 14 32
this branch 14 37 (32 + the 5 new tests)

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=0 spin-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_noprop vs 900kv_10inch).

Worth re-running on CI hardware where the suite is green at baseline.

claude added 4 commits August 9, 2026 20:07
…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
AlexKlimaj force-pushed the claude/ark32-am32-temp-current-limiting-8fl2tw branch from 6294207 to 8468c25 Compare August 9, 2026 20:10
… 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.
@AlexKlimaj
AlexKlimaj merged commit 14823c5 into feat/embed-g431-can-bootloader Aug 9, 2026
@AlexKlimaj
AlexKlimaj deleted the claude/ark32-am32-temp-current-limiting-8fl2tw branch August 9, 2026 22:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants