Skip to content

hwci: auto settings tuner — find the best AM32 settings for a motor on the rig#26

Merged
AlexKlimaj merged 11 commits into
ark-releasefrom
feat/hwci-autotuner
Jul 6, 2026
Merged

hwci: auto settings tuner — find the best AM32 settings for a motor on the rig#26
AlexKlimaj merged 11 commits into
ark-releasefrom
feat/hwci-autotuner

Conversation

@AlexKlimaj

Copy link
Copy Markdown
Member

Summary

hwci tune: put a motor/prop on the rig, point it at a tune spec, and it finds the best AM32 eeprom settings for that motor — advance_level, pwm_frequency, variable_pwm, auto_advance, max_ramp, extensible by explicit byte offset (e.g. the demag level once #24 lands). Objective: weighted g/W across the throttle range, subject to hard constraints (zero demag/bemf-timeouts, jitter not regressed vs the session's default anchor, FET/motor temps bounded, startup reliability preserved).

Settings write path — no rebuild per trial

AM32 reads its 192-byte settings page once at boot, so a trial is: write the eeprom flash page over SWD → reset → run. This reuses the existing one-shot OpenOCD program verify reset flow (~4 s/trial). Details that matter:

  • The effective eeprom address is resolved from the live eeprom_address global (ELF symbol + SWD read, sanity-checked against the known AM32 addresses) — the bootloader devinfo can relocate it, so it's never hard-coded.
  • Each trial blob is seeded from the device's current page, mutating only tuned bytes — version bytes, motor_kv, input_type, servo cal all preserved.
  • Field offsets are DWARF-cross-checked against EEprom_u in the ELF at tune start (same pattern as the perf-struct layout check), and every write is read back and verified before a trial scores.
  • Out-of-range values are refused, never clamped — the firmware silently falls back on bad bytes (e.g. pwm_frequency), which would make a "trial" a lie.
  • hwci settings read|write|diff exposes the primitive standalone (e.g. apply a winner to the other 3 MCUs of the 4-in-1).

Search strategy — designed for a noisy bench

The rig's own history says same-firmware efficiency spread reaches ~9.8% at ≥20 W and pack state drifts within a session, so:

  • ~28 s probe profile (4 steady points) for the inner loop; full efficiency_sweep only for finals.
  • Staged coordinate sweeps (advance → PWM frequency → mode A/Bs built on the winners → max_ramp as a constraint-only startup/step stage), with refine-around-argmax.
  • Anchor runs of the incumbent every N trials; scores reported raw and drift-normalized against anchor interpolation.
  • Interleaved ABBA finals vs default (the PR fix(f051): glitch-tolerant zero-cross confirm to cut commutation jitter #21 methodology): a winner must show a positive median paired delta with zero constraint failures, or the tuner honestly keeps the defaults.
  • Pack management: resting-voltage gate with pack-swap prompts (--no-prompt for unattended exit/resume); ABBA blocks never straddle a swap. Optional FET-temp cool-down gate between trials.
  • Crash-safe: atomic manifest, per-trial run dirs, --resume replays the ledger exactly (complete trials reused, partial ones quarantined, incumbent settings re-programmed to the device).

Budget: ~30–40 trials ≈ 35–45 min motor time and 1–2 pack swaps for the example spec.

Verified

  • 191 offline tests pass (115 base + 76 new): settings encode/DWARF cross-check, injected-optimum recovery, drift normalization, constraint disqualification, crash→resume identical-winner, pack-swap path, CLI round-trips.
  • End-to-end sim run (hwci tune --sim --spec tunes/example.yaml): 40 trials, staged sweep converges on the injected optimum (advance 26 / 24 kHz), and the ABBA finals correctly refuse to confirm a within-noise delta — "default kept". The honest-negative path works.
  • Also fixes a pre-existing circular import (hwci.simflightstand/simulator.py).

Not verified (needs the rig)

  • Real SWD page write + readback on the bench (mechanism is the existing flash path, but the first hardware tune should start with hwci settings read/write/diff round-trips).
  • Per-trial reset overhead assumption (~10 s incl. app-alive + tare) — worth timing in the first session.
  • The startup-reliability stage inlines a minimal metric with a TODO to delegate to hwci: jitter gate, fresh-pack baseline, startup + weak-BEMF stress profiles #22's startup_stats once that merges.

🤖 Generated with Claude Code

https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6


Generated by Claude Code

claude added 5 commits July 6, 2026 13:57
AM32 reads its 192-byte EEprom_t page once at boot, so settings trials
need no rebuild: seed a blob from the device's current page, mutate only
the tuned bytes (identity bytes at offsets 1/3/4 preserved so the boot
version-mismatch rewrite never fires), validate against firmware-accepted
ranges (out-of-range values are REFUSED, not clamped - the firmware would
silently fall back to defaults and the trial would lie), and program it
with the existing one-shot OpenOCD flash+reset flow.

The live eeprom_address global (bootloader devinfo can move the page) is
resolved via ELF symbol + 4-byte live read and sanity-checked against the
known per-target page addresses.

elf.union_layout() walks DWARF unions recursively (EEprom_u wraps all
named settings in an anonymous first struct member, which struct_layout's
named-struct walk cannot see) so EEPROM_FIELDS offsets are cross-checked
against the flashed ELF, mirroring PerfReader._check_layout policy.
Offline test compiles the real Inc/eeprom.h with a stub main.h.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6
RigSimulator now optionally decodes the SAME 192-byte EEprom blob the
hardware path flashes (SimSettings.from_blob -> hwci.settings decoder)
and responds phenomenologically: efficiency has injectable interior
optima in timing advance (gaussian) and log2 PWM frequency, auto_advance
scores as a fixed equivalent advance, variable_pwm trades a small
low-throttle efficiency bonus for extra zero-cross jitter, low
startup_power fails start attempts, and low max_ramp raises the
throttle-jump threshold of the existing desync machinery (deterministic
slew-limit realization of 'reduces desync probability'). settings=None
keeps behaviour bit-identical for every existing caller.

build_sim_sources() gains optional sim/settings_blob params so one
simulator (pack, temperature, RNG state) persists across tuner trials.

Also fixes a pre-existing circular import: flightstand/simulator.py
imported hwci.sim at module top while hwci.sim imports the flightstand
package, so 'import hwci.sim' failed whenever it ran first (the full
test suite only passed because alphabetically-earlier test modules
imported flightstand first). The sim import is now lazy in __init__.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6
Coordinate-descent search over AM32 EEPROM settings, maximizing weighted
g/W over steady probe points (>= min_power_w only - low-power points are
bench noise) subject to hard constraints that DISQUALIFY, never score:
demag/bemf timeouts, aborted runs, settings readback mismatch, temp
bounds, failed starts, and zero-cross jitter regressed vs the session's
default-settings anchor. Ties within the noise floor break toward lower
jitter, then lower FET temp, then closest-to-default.

Noise handling per hwci/baseline.py reality (9.8% same-firmware spread,
in-session pack drift): incumbent anchor runs every anchors_every trials,
scores reported raw AND normalized to the linear interpolation between
surrounding anchors; finals run winner-vs-default in interleaved ABBA
blocks on the full sweep and confirm only on a positive median paired
delta with zero constraint failures (plus a startup-reliability check).

Sessions checkpoint manifest.json atomically after every trial; --resume
replays the deterministic plan, reuses completed trials (trial.json +
metrics.json), quarantines partial dirs as *.incomplete, re-programs the
incumbent page (a crash can leave arbitrary settings flashed), and
restarts any ABBA block that straddled a pack swap. Low pack voltage
prompts for a swap (recorded as a pack_event) or exits cleanly under
--no-prompt for a later resume.

Tune specs are strict YAML (unknown keys/params fail loudly, values
validated against firmware-accepted ranges); see hwci/tunes/example.yaml.

Deviations from the reviewed design, forced by the host demag detector's
semantics (observed in the offline sim):
- tune_probe gains a w10/w20 warmup staircase (mirroring efficiency_sweep)
  because ramping from a stop through the detector's 0.2 'running'
  threshold reads spool-up commutation intervals as spike events;
- the max_ramp constraint-only stage defaults to an inline 'tune_step'
  profile that snaps from a SPINNING state (demag_step_stress snaps from
  ~stop and self-flags 3 spike events even with nothing desynced);
- constraint-only sweeps try values in listed order and stop at the first
  pass (list values best-first) rather than scoring all;
- sim zc-jitter deviations use stochastic rounding: plain int()+floor-at-1
  quantized every configuration to the same 1-tick deviation, hiding
  variable_pwm's jitter penalty from the accumulated means.

TODO: startup_stats/startup_profile delegate to PR #22's
startup_reliability metric when it lands on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6
hwci tune --spec X.yaml --config rig.yaml --out runs/tune-1
          [--sim] [--battery-cells N] [--no-prompt]
hwci tune --resume runs/tune-1
hwci settings read|write|diff --config rig.yaml [--bin file] [--sim]

Mode selection follows the existing rule (--sim or no config = simulator);
per-trial hardware flow mirrors cmd_ci: one-shot settings flash first,
then build_live_sources. Resume reloads the spec/mode/config from the
session dir. A pack-too-low pause under --no-prompt exits with code 3.

'hwci settings' is the standalone primitive: read prints/saves the live
page, diff exits 1 on any byte difference, write flashes + verifies (on
hardware the live Tcl-RPC session is closed around the one-shot flash so
two openocd processes never race on one ST-Link; --sim uses a persistent
in-process mock page).

README gains a tune section: workflow, spec format, noise handling,
session dir/resume, pack-swap behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6
When a `hwci tune` session finishes, write a multi-page `tune_report.pdf`
alongside the existing `report.md`:

- cover page: run metadata, a CONFIRMED / DEFAULT-KEPT verdict banner,
  paired-delta summary, and the default->best settings diff table
- progress page: per-trial raw + drift-normalized objective (with
  disqualified trials marked) and the finals ABBA paired-delta bars
- stage-winner and full (paginated) trial tables

Rendered via matplotlib's PdfPages, so it needs nothing beyond the
existing optional `plot` extra. Best-effort: if matplotlib is missing the
Markdown report is still written and the tune never fails. The CLI now
surfaces the PDF path when present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsAdzs6F2KczTZz4uLtYdg
@AlexKlimaj AlexKlimaj force-pushed the feat/hwci-autotuner branch from 53a9460 to 93213da Compare July 6, 2026 20:00
AlexKlimaj and others added 6 commits July 6, 2026 16:02
First hardware tune session findings, each with tests:

- probe_profile/tune_probe.yaml: ramp into any measured point >10%
  above the previous throttle. Unramped 0.5->0.7 snaps drew 43-75 A
  one-sample transients on the 6S bench (harness abort at 40 A, and
  the tripping sample is raised before it reaches samples.csv, so the
  recorded peaks looked innocently low). 10% steps stay unramped -
  efficiency_sweep's staircase is bench-proven.
- step_profile: snap-transient current headroom (max(spec, 55 A),
  the demag_step_stress.yaml precedent). Its deliberate 0.3->0.95
  snaps peak ~50 A and would abort at the probe's 40 A limit before
  demag was even assessed.
- Tuner.run: flash best_settings back to the device at session end.
  The last finals trial leaves the unconfirmed winner active while
  the report says "default kept".
- SimTuneBackend: pack gate only fires with an injected voltage_fn -
  a hardware spec's battery_cells must not block a --sim dry-run
  (sim nominal battery is not a real cell count; mirrors run/ci).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sweep stages can hill-climb the sorted value list from the incumbent
instead of testing the whole grid: walk in the first improving
direction, stop at the first non-improvement. Valid for unimodal
responses (efficiency vs advance or pwm frequency) - a 7-value grid
typically costs 3-4 trials and never visits the far side. Disqualified
values read as worse than any qualified one, so the climb walks away
from constraint violations instead of stopping on them. refine_step
still applies around the resulting argmax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The session baseline anchors everything downstream: the zc-jitter
regression gate's reference, drift normalization, and the finals'
default legs. On the bench, a session that soldiered past an aborted
baseline burned all 19 trials and ended unconfirmable - and silently
took its jitter reference from the disqualified run's partial data
(an earlier abort would have left the gate OFF all session).

Now: retry the baseline once; if it disqualifies again, quarantine the
baseline ledger entries and pause (TunePaused) so a resume re-runs
them fresh after the fix. The jitter reference is only taken from a
qualified baseline, with an explicit warning when no zc data exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace sweep-the-list ramp tuning with a measurement: one trial runs
a 200 Hz step profile (two 0.20->0.55 snaps from a spinning state)
with max_ramp at the field max, so firmware duty slew is not the
limiter and the eRPM rise time is the powertrain's own. From it:
mech time constant tau (63.2% crossing), slew, and k = transient
current per % of commanded duty step.

During a firmware-limited ramp at rate r the duty leads the mechanical
state by ~r*tau, and transient current ~ k*lead, so the fastest ramp
whose worst-case transient stays inside the current budget is
r = budget/(k*tau); a spec margin (default 0.8) keeps a fraction. The
computed value is VERIFIED on the step-stress profile with 0.7x
back-off on failure; measured constants land in the manifest/report.

Bench-validated (ARK 4IN1 + JS 2306 + HQ5136, 6S): tau 26 ms,
k 0.70 A/% -> max_ramp 15 (1.5%/ms), verify clean on first try -
next to the hand-tuned 20 on the device page and ~10x below the
firmware default of 160.

The measurement profile tops out at 0.55: this bench desyncs arriving
at fresh-pack t70 (~172-176k eRPM), independent of this stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rendered from a real bench session, the trials table clipped off BOTH
page edges (auto_set_column_width lets a wide table overflow: the
index/stage/kind columns vanished left, abort reasons ran off right).

- Trials: landscape page, explicit column-width fractions, wrapped
  overrides/disqualification text, per-row heights from wrapped line
  counts, pagination by a line budget instead of a fixed row count.
- Progress chart: labeled alternating stage bands, anchors as open
  circles (pack drift readable at a glance), disqualified markers in
  their own band below the data, integer trial/ABBA ticks.
- Cover: stage-winners table folded in (its old page was ~empty),
  winner overrides in compact k=v form, proportional table widths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tunes/quick.yaml (new): ~10 min coarse pass, bench-validated at 10.4
min wall / 19 trials (runs/tune-quick-3, confirmed variable_pwm 0->1,
median ABBA delta +0.042 g/W). Coarse 3-point advance climb, 2-point
pwm, one variable_pwm A/B, mech-ramp measure stage, short finals on
the probe profile. Single-shot scoring - indicative, not confirmatory.

tunes/example.yaml: battery_cells 4->6 (this is a 6S bench - the 4S
resting gate was uselessly low), t60 instead of t70 probe points
(fresh-pack t70 arrives at ~172-176k eRPM where default advance
desynced 8/8; t60 holds and t90 arrivals are clean), 4 s dwells
(truncation analysis: 3 s tail moves gated points < +/-2%), climb on
both sweeps, anchors every 6, one finals ABBA block, and the ramp
stage switched to measure: ramp_rate. ~20 min instead of ~45-60.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AlexKlimaj AlexKlimaj marked this pull request as ready for review July 6, 2026 22:09
@AlexKlimaj AlexKlimaj merged commit 7d3b771 into ark-release Jul 6, 2026
@AlexKlimaj AlexKlimaj deleted the feat/hwci-autotuner branch July 6, 2026 22:09
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