Skip to content

4. Flash and score standalone

The sketch carries its own copy of the operating point, and it must agree with the policy’s config.json — the header only carries weights, not the loop shape. Defaults already match the canonical recipe, so this is a one-time check per rig (RLControl.ino).

The “flashed” column below is read out of RLControl.ino and policy_weights.h when this page is built, so it is what your checkout actually contains rather than what the docs once claimed:

sketch constantmust equalflashed
CONTROL_FREQUENCY_HZcontrol_freq_hz50
ACTION_SMOOTH_WINDOWaction_smooth_window4
OBS_FRAMESobs_history_len4
MAX_VELOCITY_RAD_Smax_velocity_rad_s3.5
MICROSTEPSyour driver wiring (not training)32
Terminal window
# Export PROGMEM weights into the sketch directory (scratch header — the
# committed per-rig champions stay untouched)
python export_weights.py \
--student runs/<run>_async/distill_h16_dagger_dev/student.pt \
--header ../firmware/RLControl/policy_weights_dev.h \
--source-name <run>/distill_h16_dagger_dev
# Flash, selecting the header at compile time
cd ../../..
arduino-cli compile --upload -p /dev/cu.usbserial-1130 \
--fqbn arduino:avr:nano:cpu=atmega328 \
--build-property 'build.extra_flags=-DPOLICY_WEIGHTS_H="policy_weights_dev.h"' \
firmware/RLControl

With no --build-property, the sketch compiles its committed default — the reference rig’s champion (policy_weights_rig1_tmc2209_32.h). Champions for other rigs live alongside it, named policy_weights_<rig>_<driver>_<microsteps>.h; to promote a new champion, export to the named header for your rig and commit it. The rig comes first because it is what actually distinguishes two champions — each is fine-tuned against its own measured friction, so the same driver and microstep setting on a different rig is a different policy.

Keep the pendulum hanging straight down through the 1 s settle delay after boot (LED solid HIGH) — that pose becomes the encoder zero for the engagement. The sketch then swings up and balances autonomously.

The same gate as every other deployment. Opening the port resets the Nano, so hang the pendulum still before launching:

Terminal window
cd policy
python analyze_onboard.py --port /dev/cu.usbserial-1130 --duration-s 60 \
--log recordings/onboard_<run>.npz

Read the header line first: it must say ~50.0 Hz, matching CONTROL_FREQUENCY_HZ above. An off-rate capture (the script warns loudly) means inference exceeded the tick budget and the numbers evaluate a broken deployment, not the policy.

The script also reports the sample→command latency the sketch measured — mean, p95 and max — against the nominal the simulation assumes. That is the one number here describing the sim-to-real gap rather than the policy: if it sits outside the range the curriculum trained over, fix the model before blaming the controller. Detail in transport delay.

2026-07-22 reference: balanced fraction 0.892, 5 s streaks, verdict BALANCED. The current champion scores 1.000 over five minutes — a 299.6 s unbroken hold with zero drops, |action| 0.239 and pendulum σ 1.90°.

Boot also prints [boot] policy(hanging/upright) = … on serial (500 kbaud) — compare against the PyTorch student on the same reference frames if you suspect an export/PROGMEM bug.