Skip to content

Python tooling

Everything in policy/, which is a flat set of scripts you run from that directory.

The project is uv-managed: policy/pyproject.toml and policy/uv.lock pin the environment, and uv creates it on first use, so there is no setup step beyond installing uv.

Terminal window
cd policy
uv run python train_sac.py --help

Prefix commands with uv run, or activate the environment once (source .venv/bin/activate after a uv sync) and drop the prefix. Every command on this page assumes you are in policy/.

pendulum_env.py is the Gymnasium environment and the centre of gravity of the whole stack. It builds its MuJoCo model programmatically from measured parameters, so the simulation follows the rig rather than a hand-written model file.

Its inputs come from exactly two places — this separation is deliberate and breaking it is a bug:

QuantitySource
Pendulum mass, COM, inertiaOnshape CAD → model/model.urdf → pendulum_geometry.py
Viscous + Coulomb frictionMeasured per rig → sysid_params_<rig>.json, selected with --params-path (see below)
Arm geometryConstants in pendulum_env.py (not yet CAD-validated)
Motor and encoder limitsConstants mirroring the firmware

The environment also models the measurement chain, not just the physics: encoder quantisation, the firmware’s 8 ms finite-difference velocity window, and the fact that the motor channel reads the commanded step counter rather than the true joint. That last detail matters — leaking the simulated servo’s tracking error into the observation creates an oscillation that exists only in simulation and destroys policies that work fine on hardware.

Friction is the one quantity measured per rig, so two rigs with different bearings need different sysid files. --out-json is required, so every measurement names the rig it describes:

Terminal window
uv run python sysid_wizard.py --port <PORT> --out-json sysid_params_rig1.json
uv run python sysid_wizard.py --port <PORT> --out-json sysid_params_rig2.json

Then pass --params-path when training. The path is recorded in the run’s config.json and inherited by every downstream stage, so distillation, DAgger and the sim gate all build their model of the same rig the teacher was trained for — you set it once, not at every step:

StageHow it gets the rig
train_sac.py / curriculum_train.sh--params-path / PARAMS_PATH= — the one place you set it
distill.py, dagger_distill.py, analyze_sim.pyinherited from the teacher’s config.json; --params-path overrides
finetune_async.pyrecorded for provenance only — the rig is the plant, so no sim friction is used, but the following distillation reads it

Like motor_microsteps, it is recorded but not enforced by run_config.check_config: it is a filesystem path, so an absolute path from another machine would false-trip a comparison that is really about which rig’s physics the sim used. A missing file raises rather than silently falling back to the default.

Why this matters: the friction DR range is nominal × [0.5, 2.0], which covers grease and temperature drift on one rig — not a different bearing. Measured 2026-07-28 across this project’s two rigs: viscous friction differed by 1.76× and Coulomb by 1.87×, i.e. inside that window but at the 84th and 91st percentile of it. Training one rig against the other’s nominal therefore sampled its real friction in under a sixth of episodes. Inside the envelope is not the same as centred in it, so measure each rig.

ScriptPurpose
sysid_wizard.pyGuided free-swing measurement, writes sysid_params_<rig>.json
sysid_core.pyFriction derivation and cross-checks against measured period
freeswing_probe.pyRaw free-swing capture
sysid_accel.py, accel_lag_moving_probe.py, accel_step_probe.pyActuator lag and acceleration-response measurement
tick_budget_probe.pyHow much time inference actually has per tick
ScriptPurpose
curriculum_train.shThe canonical recipe — three domain-randomisation stages. Its defaults are the production configuration
train_sac.pySAC trainer
finetune_async.pyReal-rig fine-tuning with the threaded runtime
finetune_real.pyEarlier synchronous fine-tuner
async_control.pyThe threaded control runtime (design)
reward.py, run_config.pyReward terms and run configuration
ScriptPurpose
distill_student.shThe one command to use — runs BC, then DAgger at the deployment transport, then scores the student in sim. Defaults are the validated recipe; env vars override
distill.pyBehaviour cloning from teacher + real-rig buffer
dagger_distill.pyDAgger at the deployment transport — the step that makes it deploy
export_weights.pyEmits the PROGMEM policy_weights.h the Nano is flashed with
ScriptPurpose
run_policy.pyRun a policy on the rig, tethered
real_env.py, lowlevel_client.pyHardware bridge over the binary protocol
analyze_onboard.pyCapture and score a standalone deployment, including the sample→command latency the sketch measures
analyze_sim.pyThe off-rig counterpart — scores a .zip teacher or student.pt under the deployment transport, with --kick-amp for the disturbance pre-flight
balance_metrics.pySingle source of truth for the balance gate and the calmness metrics, shared by the on-rig and in-sim scorers
disturbance.pyThe calibrated-kick protocol — what each metric is worth, and which ones do not rank policies
analyze_run.py, analyze_deploy.pyScore tethered runs
sim_vs_real.pyReplay a real log through the simulation to find model error
eval_randomized.py, eval_dr_sensitivity.pyRobustness across randomised parameters (--mirror-pairs for the symmetry test)
fft_deploy.pyFrequency analysis of a deployment, for diagnosing vibration
analyze_symmetry.pyMirror asymmetry of any policy — .zip, student.pt or policy_weights.h (why)
symmetry.py, test_symmetry.pyThe mirror map, mirror-augmented replay, and the tests that keep the symmetry exact

Every scoring path reports a balanced fraction computed from the true pendulum angle, plus streak lengths and revolution counts. It deliberately does not use the upright indicator derived from the policy’s own observation, because that proxy is satisfied by spinning the arm — which is exactly how earlier evaluations were misled.

Deploy best_model.zip (best deterministic evaluation), never last.zip.

measure_serial_rtt.py times the host↔Nano echo against the TestSerial sketch, which bounds how fast any tethered loop can run. Used during first power-on; it needs no policy and no rig motion.