maploc on the MuJoCo twin: a mirrored sensor, a fall at enable, and a route steered on stale odometry - #202
Closed
peterschade wants to merge 57 commits into
Closed
peterschade wants to merge 57 commits into
peterschade wants to merge 57 commits into
Conversation
The satellite was already Rust; this is integration plus the bug hunt it never got, run offline against its own recorded sessions via the new replay bench (examples/replay.rs + kinematics dev-dep). Found and fixed, each with the measurement that exposed it: - Sensor-origin asymmetry: maps were inked from the sensor pose but MCL and relocalize scored beams from the body pose — a 10-15 cm systematic disagreement on a head-mounted sensor. Scan is now per-beam origin→endpoint pairs; every consumer derives world beams identically. The pair shape also makes scans composable: frames at one body pose with different head yaws merge exactly. - Off-map cherry-picking: relocalize and MCL skipped out-of-bounds beams, so a wrong pose that threw 95% of its scan off the map was scored on the agreeing remainder — measured beating the true pose with mean residuals near zero from a tenth of the beams. Every beam now counts (off-map = full clamp). - See-through degeneracy: endpoints-only scoring let poses dump whole scans into dense wall blobs at ~0 residual. One mid-ray occupancy sample per beam breaks it. - The map noise floor itself: stop-and-scan inked every raw frame, so walking-person transients and the sensor's far-range noise tail set a ~9 cm floor that no gate survived (loop closures gated at 10 cm never fired; the true pose scored above the 5 cm relocalize acceptance). New `accumulator`: a still window's frames vote per endpoint cell, beams confirmed by ≥3 distinct frames survive, ranges cap at 2 m, one wide composite comes out. Bench result on the recorded room: probe hit-rate 0/13 → 6/20 pre-closure, and loop closures fire at all. - Witness quality in the loop closer: verification picked scans spread across the buffer, so 12-beam scraps vetoed the consensus of 6000-beam composites — and after that fix, a lone weak witness could still swear in a wrong edge that warped every anchor. Witnesses are now the largest scans, minimum 150 beams, no fallback. Trimmed relative to the satellite: no TCP telemetry (the monitor will read robotd's socket), no rand/rayon (pinned xoshiro — a relocalize run replays bit-for-bit — and std::thread::scope), no fixed-mount model (kinematics::tof::flatten projects through the live head FK and the IMU-levelled frame, with the new per-frame sensor origin). Session format bumped to v2 for the Scan reshape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Struct-init over post-default assignment, the kernel index-loop lint allowed once at crate level with its reasoning (the loops mirror the matrix maths they implement), and the loop-closure tests pass a config that admits their single 64-beam raycasts past the witness gate that is tuned for the accumulator's composites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`pipeline::Slam` assembles submaps + graph + loop closure + optimizer behind the calls a host makes (observe_odom / tick / integrate / render / save), so robotd and the replay bench drive the same code instead of two copies that drift. The bench now runs on it. Fixed on the way, each measured in the bench: - The current submap's graph node was added with no edge attached — an unconnected node is invisible to the optimizer, and the tracked-pose correction after a loop closure rides that node. Closures moved every frozen anchor while live tracking sailed on uncorrected; the prototype wired it the same way. Nodes are now chained at open time. Pinned by a synthetic walked-loop test: drift 0.27 m, closure fires, tracking ends within a quarter of it. - Loop-closure coverage was measured against every valid beam; a 360° composite matched against a small submap parks most beams outside what that submap ever OBSERVED, and honest matches got vetoed as low-coverage. The denominator is now beams landing in observed cells. - Over-closing folded the two recorded rooms onto each other (43 "closures" in a 5-minute session): aliased matches in blobby rooms pass every local-quality gate. Two defenses, both from the data: a closure needs at least two strong witnesses in agreement (lone-witness closures were the wrong ones), and its correction must be plausible for the odometry drift accumulable over the gap it spans. Bench on the recorded room: 1 conservative closure, brute-force relocalize 6/20 probes within 30 cm/20° — every miss a narrow-wedge probe, every wide head-sweep composite a hit — and MCL no longer locks onto wrong clusters (streak gates hold it at "searching", which is the correct answer to ambiguous data). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[maploc] in robotd.toml (off by default — mapping is the most CPU-hungry thing the robot can do): enabled, mode = stop_and_scan | continuous, map_path, wipe_on_boot. When on, robotd spawns a niced (+10) worker thread driving maploc::pipeline::Slam; the control loop's entire cost is one try_send per tick (odometry pose, gravity, trunk height, head joints, the moving flag), and depth frames arrive from a tokio task subscribed to tofd's socket like any other client, reconnecting with backoff. Frames go through the Posture-aware reprojection; stop-and-scan mode routes them through the still-window accumulator so only vetted composites ink the map. Sessions autosave once a minute and on shutdown, and restore on boot. robot.map (API v13) is a subscription on robotd's socket like robot.state: the answer says whether this robot maps, then map.frame notifications carry the rendered grid — trinary cells as base64 (a small hand-rolled RFC 4648 module in the proto, so no client needs a dependency to read a map) — plus the map-frame pose, tracking state and submap/loop counts, at 1 Hz while anyone listens. robotctl monitor subscribes on its existing robotd connection: the path panel becomes " map " when frames flow (walls in braille, free space a sparse stipple, the robot its yellow marker — or a magenta ? while the pose is not to be trusted), and stays the odometry track otherwise. `m` puts either one over the whole terminal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field test one: enable maploc, stand the robot up, watch the monitor — nothing. Three causes, three fixes: - The still-window only integrated on the still→moving TRANSITION, so a robot standing still accumulated forever and never inked. Windows now flush every 3 s while the stand continues. - Empty submaps froze on age — nine husks in the graph after a couple of minutes of standing, each a dead node dragging the optimizer. A submap with no content re-anchors at the current pose instead of freezing (TickOutcome::Reanchored; the pipeline moves its node and updates the inbound edge measurement to match). - Nothing said what mapping was doing. map.frame now carries `windows` (integrated so far) and `still` (a window is accumulating right now); the panel caption reads "N windows · M submaps · K loops · scanning", so "no walls yet" and "no scans ever reached the map" stop looking identical. Window integration logs at info. And per request: the odometry path draws in red — in the path view and overlaid on the live map (same world frame until loop closures diverge them, and worth seeing together even then). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field test two: "0 windows · 71 submaps" — the 71 are husks restored from the previous build's session, but the zero needs data, not another theory. The worker now logs a status line every 5 s (odom samples, frames received/kept, windows, the still verdict with the moving flag and the 500 ms odometry deltas it was decided from, window size, submaps), says when it connects to tofd, and no longer swallows the connect error silently — that silence already cost an afternoon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field test three, with the new status line: odom flowing, stillness perfect, frames=0 — the depth feed never delivered a single frame. The task was spawned on the control loop's runtime, which is deliberately built time-only: no IO driver, so the UnixStream connect panicked the task on its first poll, silently, inside a JoinHandle nobody reads. The feed now runs on its own thread with its own current_thread runtime built with enable_io — which is also simply better: tofd's socket I/O and 15 Hz of JSON parsing never belonged on the control thread at all. Niced like the worker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four field-test-two fixes: - the age rule only fires once the robot has moved 15 cm from the anchor — standing (or sitting) still accrues no drift to bound, and the old rule minted an identical submap every 8 s (24 -> 53 over one seated coffee break, all junk for the loop closer); - windows under 60 beams are discarded, not inked: a seated robot's 3-second windows distilled to 2-27 beams of floor clutter; - a vetted window inks twice: one pass wrote log-odds 85 per wall cell and the wire frame calls a wall at 150, so a lap that stopped once per spot painted the whole walk invisibly — the 'scattered white points' were the rare twice-visited cells; - the map view's red path is now the tracked pose history, not raw odometry: after a session resume or a closure the two live in different frames, and the odometry path diverging from the robot marker reads as a bug when it is a frame. Also: loop closures now log their correction, and a fruitless window flush disarms the ripe timer instead of thrashing single-frame flushes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The moving flag fed maploc's stillness gate through `twist_magnitude() > 0.0` — and a gamepad twist idles at a not-quite-zero value below the walking threshold, so the flag stayed latched true through an entire stop-and-scan lap: the robot stood, odometry read zero, and mapping refused every stop (kept=124 frozen for two minutes in the field-test journal). The policy already decides standing versus walking; `moving` now follows that decision. Also makes safeToRestart stop saying no to a robot that is only holding a drifting stick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bench case Three things, one commit because they are one design: Mapper (maploc::mapper) — the mapping host loop moves out of robotd's worker into the crate: stillness, window vetting, and a new lost/relocalize state machine. Before a window inks it is scored against the map at the tracked pose (relocalize::score_pose); a window the map can judge but flatly contradicts flips tracking to lost — a kidnapped robot's scans land in territory the map knows and disagree everywhere, while a robot exploring a new room lands in cells the map cannot judge and keeps mapping. While lost nothing inks, and every window becomes a brute-force relocalize attempt (decimated to 256 beams — full composites would cost seconds a try); an accepted pose snaps tracking back and mapping resumes. The same watchdog heals a resumed session whose robot moved while the daemon was down. robotd's worker is now a thin host: channel in, log lines and map frames out. Recorder (maploc::record, [maploc] record_dir) — .mdlg format v2: everything the mapper consumes (odometry, gravity, trunk height, head, moving/sitting, raw ToF zones) appended to a timestamped log, ~6 KB/s. Ground-truth bench (examples/evaluate) — replays a v2 log through the SAME Mapper against a tape-measured room (truth.toml, centimetres and degrees): return-to-start error vs raw odometry, kidnap detection and relocalization latency + pose error, map-vs-room wall statistics, PGMs with the truth walls burned in. The protocol's kidnap marker is a SIT: odometry cannot see a carry, but it cannot miss a sit — and the mapper now refuses to map from sitting height anyway. room_lab.toml carries the lab digitized from the CAD sketch; the start/kidnap poses are scaled off the drawing and should be corrected with a tape measure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replayed the first ground-truth recording (lap + sit-carry kidnap in the tape-measured lab) through the bench until the kidnap recovered. Four measured failure modes, four fixes: - The see-through clamp punished beams for hitting the wall they measured: a robot 10 cm from a divider had 4200 of 10800 beams 'seeing through' it (midpoint of a short or grazing beam lands in the wall's own cells) — the false LOST at t=35 s. The watchdog is now endpoint-only (at a trusted pose there is no blob degeneracy to exploit); the search keeps the clamp but only for crossings well away from the beam's endpoint. - A young sparse map let a wrong relocalize basin score 0.022 and poisoned the whole run (return-to-start went 0.43 m / 95° while raw odometry was 0.14 m / 0.1°). A candidate now needs the NEXT window, carried by odometry, to confirm it — with a coverage floor, because a keyhole wall wedge aliases onto any wall at the same range (measured: 204/1680 kidnapped beams landed on old walls at residual 0.005, 0.3 m from the truth). - A kidnapped stand vouched for itself: its first window painted the kidnapper's room and every later window 'agreed with the map'. Windows are now judged against a snapshot of the map from when the stand began, contradictions quarantine before they ink, and lost takes two consecutive contradicting windows. - Geometry cannot detect a carry through a 45° keyhole at all when the scene aliases or lands in unknown (measured: both). But the robot KNOWS it sat: sitting arms the lost machinery with 'I was not moved' pre-seeded as the candidate — an unmoved robot confirms in one window, a kidnapped one is refused by the coverage floor and falls through to the search. On the recording: kidnap recovered 8.5 s after the stand, a loop closure fired after recovery, the final sit-confirm found the returned robot 0.2 m / 10° from its boot pose, and map-vs-room went from mean 0.57 m / p90 1.24 to mean 0.14 m / p90 0.28. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cion can expire The relocalization triggers are now every moment the robot stops being able to vouch for its pose: - a SIT (it cannot feel a carry), - a FALL (it can be dragged and spun — new 'fallen' bit through OdomSample and the .mdlg flags byte), - a SESSION RESUME (it may have been moved, or booted in another room, while the daemon was off — previously the saved pose was trusted outright), - and the scan watchdog, for displacements the scans can prove. All four arm the same machinery: tracking continues on odometry, nothing inks, and each still window either CONFIRMS the carried 'I was not moved' hypothesis (one window when true — a reboot in place costs ~3 s of paused mapping), REFUTES it (evidence of displacement: suspicion hardens and the brute-force search takes over), or cannot judge it. Soft suspicion that stays unjudgeable for 10 windows expires and tracking resumes unverified at the odometry pose — without that escape, a robot that sits facing an unmapped corner says 'searching' forever; with evidence of displacement the escape never applies. Bench regression on the recorded kidnap session: identical (recovery 8.5 s after the stand, map-vs-room mean 0.143 m). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s a room Two things the last field round asked for: Head sweep while searching ([maploc] search_sweep, default on): a single static 45° wedge aliases onto any wall at the same range — measured, it is why kidnap confirmation needs a coverage floor at all — and the bench scored 0-for-13 relocalizes on wedges vs 6-for-10 on wide composites. While the mapper cannot vouch for its pose and the robot stands, the control loop sweeps head yaw ±0.9 rad over 6 s (slow enough that every cell survives the accumulator's 3-frame vote); the window accumulator already merges the pan into one ~150° composite via per-beam origins. Only head yaw, only while searching; the command EMA glides the takeover and the handback. The worker publishes searching through an AtomicBool on the Host. Monitor map render: confirmed floor is now a solid dark background fill and walls fill their whole cell footprint in bright braille, instead of one dot per wall cell over a 1-in-5 floor stipple — a lap used to render as scattered stars; a room should read as a shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
robot.map_wipe (API v14): resets the mapping session in place — map, pose graph, tracked pose and any suspicion — and deletes the saved session file. Mapping starts fresh from wherever the robot stands; no ssh, no daemon restart. The field workflow was 'sudo rm /var/lib/robot/maploc.session && systemctl restart robotd' between every experiment, which is three moving parts too many for the thing done most often. The worker takes it as an event on its existing channel; the IPC side reaches it through a OnceLock<Host> the control loop sets at spawn. Refused (as an intent, not an error) when mapping is off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t map Field test five's second kidnap 'failed to relocalize' for three minutes: the stand press after the carry never reached robotd (no 'sit toggle direction=stand' in the journal, trunk kinematics frozen at sit height for 200 s — likely a pad/BT drop while the robot was carried out of range), so the controller stayed in Sitting and the mapper — correctly — refused to map or relocalize from the floor. The monitor said only 'searching', which reads as a relocalization failure when it is a robot waiting to be stood up. MapFrame gains an additive 'seated' flag (serde default, no API bump); the map caption now says 'seated — stand the robot to map', and the 5 s status log spells out sitting/fallen alongside moving. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field test five, second carry, decoded from its recording: the 'I was not moved' seed confirmed on a single static wedge (residual 0.010 at the OLD pose — the new spot's wall matched the old spot's wall), and half of that confirmation was free: the still window that flushes at the sit describes the world BEFORE the carry and trivially agrees with the pre-carry pose. Two changes, both verified by replaying that recording: - the soft seed now needs TWO consecutive agreeing windows before tracking resumes on it (the search path already had two windows by construction). The head sweep keeps running between them, so the second window covers a different arc — independent evidence instead of the same wedge twice; - suspicion arms AFTER the window flush, so the pre-sit window inks as ordinary tracked data instead of counting as agreement pollen-robotics#1. On the recording: the seed no longer false-confirms; the search finds the true post-carry pose, the next window confirms it, a loop closure lands, and window-vs-room agreement drops from 0.40 m to 0.028 m. Also folds the candidate check into a method — the four-parameter closure was the construct two rustfmt versions disagree about (CI red). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prototype panned the head 130-230 degrees at every mapping stop — that width is where its map quality came from, and the port only swept while searching: an ordinary stop kept whichever 45-degree wedge the head happened to face and threw the rest of the stop away. A messy office at ankle height needs every degree of that arc to read as more than scattered blobs. Same sweep, same param, same accumulator (per-beam origins were built for the pan); the head comes back to the commanded pose as soon as the robot moves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
robot.map / robot.map_wipe renumber to API v17 (main took v13–v16 for the theremin, the chorale, setMode and update.show). MaplocParams moves into the robotd-params crate with registry entries, so 'robotctl configure' can edit the [maploc] section like any other. The connection handler's three streams (state, map, chorale beacons) all ride the pending-forever select helper. mediad's WebRTC route permits robot.map — the transport btd's refusal always pointed at — and btd itself still refuses both map calls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nism A high-effort review over the mapping code confirmed ten correctness bugs; all fixed here, each pinned by an existing or new test: - continuous mode could arm 'lost' (sit/fall/resume) with no path that ever clears it: recovery now runs through the still-window machinery in both modes (test: continuous_mode_recovers_from_suspicion); - the two-witness independence gate cross-examined a scan against itself: double-inking stored duplicate RawScans. Weighted integration stores one scan per window, and the witness picker refuses byte-identical duplicates from old sessions; - a kidnap judged on 5-30% of beams was 'unjudgeable' and burned the give-up budget: refutation now needs only the watchdog's coverage floor, and a new Ambiguous verdict (judged, verdict between agreement and contradiction) keeps searching without spending the escape budget — the escape now fires only when the map truly had no opinion (test: a_contradicted_seed_never_resumes_unverified); - save-on-shutdown was dead code (the channel never closes: the tofd feed and the IPC handle hold senders forever): robotd's shutdown path now sends an explicit Shutdown event and waits for the ack; - a mid-stand loop closure left the pending window mixing pre- and post-correction frames: the accumulator drops them; - resume_at inked into a submap still anchored at the pre-carry pose (silently clipped): the manager ticks before the ink lands; - grid-untouched scans no longer occupy witness slots or make has_content() freeze inkless husks (integrate_ray reports whether it wrote anything); - the matcher's final score counted a stricter beam set than its own optimizer, deflating loop-closure acceptance: scoring now matches the GN set, and closure coverage judges by n_beams_observed; - MCL's see-through kernel gains relocalize's graze exemption and its own see_through_fp; - densest-bin tie-breaks were HashMap-order (per-process random): deterministic now, as the crate's replay contract requires. Plus the review's efficiency findings: loop-closure witnesses decimate to 512 beams before the coarse search (~20x fewer lookups per freeze), the 1 Hz map publish reuses a cached render unless the ink changed (the re-render grew with the map for a robot that was just walking), and wrap_pi collapses from seven copies to pose_graph's one. Ground-truth regression: kidnap recovery unchanged (8.5 s), pose error 0.46 -> 0.39 m, map-vs-room mean 0.143 -> 0.126 m, p90 0.28 -> 0.26 m. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`robotd-design.md` §9 deferred "the MuJoCo backend and the `RemoteIo` protocol". This is it, and it is small because the seam was already there: `duck_control::io::RobotIo` is six methods, `FakeIo` already implements them, and everything above — the loop, the policy, `Safety`, fall detection, odometry, kinematics, every IPC call and all of `robotctl` — runs unchanged and cannot tell. Three decisions worth the words they cost. **TCP, not a unix socket.** A unix path is capped at `SUN_LEN`, about 108 bytes, which a scratch directory blows straight through — that bit during validation this afternoon. And the simulator has to be reachable from outside whatever the daemons run in: a container on Linux, and on macOS a Linux VM with MuJoCo on the host beside it. A port crosses all of those. **Newline-delimited JSON.** A tick is fifteen joints each way, about a kilobyte, so 50 KB/s at 50 Hz — nothing against being able to read a frame with `nc` and write the other half in twenty lines of Python. The alternative is a packed struct shared between two repositories in two languages, which is the exact shape of thing that has already cost this project days when an offset was wrong and the failure was silent. **A dead simulator is one bad tick, not a dead duck.** MuJoCo compiles its model, so changing the number of ducks restarts it, and the ducks are expected to live through that. So a broken connection returns an error and reconnects on the next call — no backoff thread, because the control loop *is* the retry timer, and `robotd` already treats a failed read as a tick to skip. Two tests hold that: one hangs up mid-read, one answers with something that will not parse. Both must produce a fresh handshake rather than a wedged link, because a line protocol cannot resynchronise any other way. `TCP_NODELAY` is set, and that is not a micro-optimisation: Nagle delays a small write up to ~40 ms waiting for more to send, which is twice the tick, and it would look exactly like a slow simulator. The simulator reports in the robot's own units — radians, rad/s, mA, the IMU already in the trunk frame — because MuJoCo knows its own model's ordering and scaling, and a translation layer here would be a second place for that knowledge to drift.
`robotd --sim` referenced this file before it existed. It now says what the twin is, what it is not, and why — including the experiment that changed the plan halfway through. The attractive idea was to run the board's own aarch64 binaries on an x86 laptop under qemu-user: same bytes, perfect provenance. Measured twice, and the results point opposite ways. The daemon alone is fine — CI's real artifact, emulated, holds 50.0 of 50.0 Hz with zero missed ticks at 4.7% of one host core, because the policy is 0.029 ms against a 20 ms tick and the slow part of a real tick is a local socket here. Under systemd it is not: aarch64 systemd 257 boots in 8.7 s and then starts nothing, because qemu-user 8.2 cannot translate the new mount API its per-unit namespaces and credentials need. `robotd.service` dies with 226/NAMESPACE, journald and logind with 243/CREDENTIALS. Per-unit hardening is the entire reason to boot a container rather than run seven processes in a terminal, so the twin follows the *host's* architecture instead: an amd64 container and a native build on x86, and on Apple Silicon the robot's own signed artifact running natively. A colleague on a Mac gets the higher-fidelity twin for free. §1 is the part that decides whether any of this gets used: one command to get a duck, defaults that are the common case, and every duck a systemd unit so stopping one is `systemctl stop` rather than a key combination. That last rule was bought this afternoon by a container that had to be killed from a second terminal, because `Ctrl-]` is `AltGr + )` on a French keyboard. §6 lists what the twin is and is not, and checks itself against a week of real bugs: the videoflip that cost 22 fps, the 3A engine missing a stream-start event, an auto-exposure loop that converges once, an INT8 head collapsed to two values, a bus dropping reads. It would have caught none of them. That is the boundary, stated so nobody mistakes a green sim for a working robot.
One duck works end to end: `robotd --sim` against `microduck_rl`'s `duck-body`, 50.0 of 50.0 Hz with no missed ticks, a seated boot detected from the simulator's own joint angles, and the sitstand policy standing the duck up and holding it there. Getting there cost three hours, one per way of choosing a model that looks right and is not — and all three present identically, as a duck on its back. `scene_walk.xml` includes the RL training model, whose actuator default classes disable collision entirely, so the duck sinks through a floor the scene does contain. `qpos0` is every joint at zero, which is a shape this robot is never in. And a simulator that starts limp has its duck on the floor before the daemon's first read, because on a real robot the servos are already holding when the process comes up. Written down because the next person to point this at a scene will pick from six of them.
The last version of this was three terminals and a heredoc, which is a set of instructions rather than a tool. `scripts/duck-sim` and the duck is up: it builds what is missing, writes the params file, starts both halves, waits for each, and stands the duck up with the sitstand policy. Three things it exists so that nobody has to know, because none of them is guessable from the outside. Policies resolve under `/opt/robot/daemon/current` on a robot and live in this repo on a laptop, so every one has to be named explicitly. `ort` dlopens libonnxruntime and a laptop has no system one — the RL repo's venv does, so this finds it there. And a unix socket path is capped at about 108 bytes, which a scratch directory blows through, so the state directory is short by design. `drive` is the smallest client that sends a velocity intent, because walking has no `robotctl` subcommand — intents come from a pad or the console. It resends at 10 Hz, since `robotd` expires an intent 500 ms after they stop arriving, which is what makes a dropped connection stop the robot rather than run it into a wall. Stopping is by pidfile, never by name: `pkill -f robotd` also matches the shell about to start one, which killed this session's terminal three times in one afternoon. Verified end to end: up, `50.0 of 50.0 Hz · 0 missed`, upright at gravity z −0.974, still upright after walking, and down.
…stand Two bugs, both mine, both of the kind that makes a tool untrustworthy. **`down` left MuJoCo running.** The simulator was started as `( cd "$RL" && nohup python ... & echo $! > body.pid )`, and `A && B & C` parses as `(A && B) & C` — so the pidfile held the *subshell's* pid. Killing it reaped a wrapper and orphaned the window. Both halves now start with `setsid` and are stopped by process group, `down` waits for them to go and escalates to KILL, and it checks the port afterwards rather than announcing success: reporting "down" with a window still open is worse than reporting nothing. **A duck that never stood up looked like a success.** `robot init` ran as `>/dev/null 2>&1 || true`, which threw away the one command whose failure explains a duck sitting there doing nothing. It now fails loudly, and `up` checks afterwards whether the duck is actually upright — the daemon reporting healthy only means the loop is turning, and a duck that never rose is healthy and sitting. When it did not stand, the script says which of the two causes it was. If the simulator logged falling behind real time, that is the answer: the daemon's loop is wall-clock, so a MuJoCo running at half speed is a policy driving a robot that moves half as fast as it expects, and it cannot balance one — the viewer is the usual reason, and `DUCK_SIM_VIEWER=0` is the fix. If the simulator kept up, it is the policy, and the next thing to read is the log. `init` and `status` are there to retry and to ask, without restarting anything.
I claimed this twin stands a duck up. It does not, and the check that told me so was mine: `upright()` read gravity in the trunk frame and called -0.978 a success. A duck sitting on its bottom with a vertical trunk has gravity [0, 0, -1] as surely as a standing one does — so the script reported success twice, in front of the person who could see the window and could see it sitting. It now reads the trunk's height, which the simulator reports for exactly this purpose: 0.12 m standing, 0.07 seated, and 0.064 on a real run. Orientation is still checked, for the duck that stands and then falls over.
`robot init` enables torque and then position-ramps to the home pose over two seconds with nothing balancing. This duck cannot hold any pose without a policy driving it, so the ramp tipped it flat every single time: gravity in the trunk frame went -1.000 to +0.016 inside those two seconds, and it lay there. Every failure chased today — the seated duck, the frozen duck, the duck on its back — was that ramp. `robot.enable` hands it to the policy directly, and from a seated start it stands itself up: trunk 0.063 to 0.116 m, upright and steady. There is no robotctl subcommand for it, so the script makes the call. It starts seated now, because a seated duck is stable while it waits.
`up` runs the daemons as processes on your laptop. `boot` runs them the way a robot does — real unit files with their real User=, groups, RuntimeDirectory= and hardening, under a real init in a systemd-nspawn container — so `machinectl shell duck-a` puts you on the duck and `robotctl health` there is the same thing it is on hardware. MuJoCo stays on the host and the container shares the host's network namespace, so localhost is the same localhost on both sides of the wall. `rootfs` builds Debian 13 Trixie, the board's own userland, in about thirty seconds. Two things an Ubuntu host needs and does not have, both of which present as something other than what they are: Debian's archive keyring, fetched here — and staged in a world-traversable directory for the length of the build, because a keyring under a 0700 `~/.cache` cannot be read by the uid apt drops to *inside* the user namespace, and apt then calls the repository unsigned. That reads like a mirror problem and is a permissions one. And the rootfs is built as a tarball and unpacked with sudo, rather than written as a tree: writing the tree needs every parent directory traversable by the subuids mmdebstrap maps in, and `~/.cache` being 0700 is the user's business rather than this script's. A tar is one file in a directory we own, and the sudo that `boot` needs anyway is what gets the ownership right.
**`robotctl robot enable`.** The console has had this button since it existed and the CLI did not, which nobody noticed until a robot with no hands to hold it needed one. The difference from `init` is the whole point: `init` powers the joints and position-ramps to the home pose with nothing balancing, while `enable` hands the robot to its policy, which then holds it up. A biped cannot stand by being commanded to a pose — in simulation, where nobody is steadying it, `init` puts the duck on the floor and `enable` stands it up from sitting. `--toggle` is what Start does, and the daemon's own `reason` is printed because a client cannot know which way a toggle went. Three things the first boot got wrong, all visible the moment somebody logged in: `robotctl: command not found` — the release's bin is not on PATH. Symlinked into /usr/local/bin rather than added by a profile.d line, so `machinectl shell duck-a robotctl …` works too, which does not read a profile. The prompt said `antoine-Blade-14-RZ09-0508`. A duck should be called `duck-a`, so nspawn is told its hostname. And the container was given a params file that named a policy section with no policies in it. It now gets none at all: inside the container `RELEASE_DIR` is a real path, so every policy resolves exactly as it does on a robot. That is the reason to boot one. `boot` now also enables the policy from inside, over the container's own socket, and reports whether the duck actually stood.
`scripts/duck-sim boot 4` gives four ducks in one MuJoCo window and four containers to log into — `duck-a` through `duck-d`, each with its own body port, its own hostname and its own systemd. One rootfs, one overlay per duck: the 213 MB base is shared read-only and each duck gets its own upper, so the fourth costs megabytes and `robotctl configure` on one does not edit another's config. The port reaches each container through `--setenv`, which the robotd drop-in reads — the same unit file, told which body is its own. `down` stops and unmounts every duck this script could have started rather than the ones it remembers starting, so an interrupted boot does not leave a container running and an overlay mounted.
**Three ducks out of four were never enabled.** `machinectl shell` allocates a PTY and is unhappy with its output redirected, so the loop that stood them up failed silently for all but the first — and a duck that was never handed to its policy sits there looking like a duck whose policy failed. `systemd-run --machine --pipe --wait` runs the command in the container and returns its exit status, which is what the `||` was always assuming it had. And `boot` now measures the world's real-time factor and prints it. Measured here, four ducks with all four released run at 1.00x — so physics is not the reason anything felt slow, and guessing at that from the outside is exactly what this line is for. Below 1.0 the daemons' wall-clock loops are driving robots that move less than they expect, which no policy can balance; the message says what to try. `duck-sim simlog` follows the simulator's own log, where it reports falling behind.
The prompt inside every container read `antoine-Blade-14-RZ09-0508`. nspawn's `--hostname` sets the kernel hostname, and then systemd inside boots, reads `/etc/hostname` — which mmdebstrap filled in with the name of the machine that built the rootfs — and sets it straight back. The first boot log said so plainly: "Hostname set to <antoine-Blade-14-RZ09-0508>". Written per duck into its own overlay, beside the drop-in that names its body, for the same reason: what a duck is cannot depend on anything surviving the trip into the container.
The chorale has never been testable without several robots on a desk. It is now testable on a
laptop, and it works:
duck-a: soprano bar 16 beat 59.3 2 voices
duck-b: bass bar 16 beat 59.5 2 voices
`duck-ether` replaces `btd`'s radio and nothing above it. Presence is already an IPC contract on
`robotd`'s own socket — `chorale.subscribe` to be told what to advertise, `chorale.beacon` carrying
it, `chorale.heard` carrying what came back — and `btd` is a *client* of `robotd` rather than a
server, so this impersonates nothing and steals no socket path. It holds one connection per duck
exactly as `btd` does, and every duck's election, roster, beat and conductor deference runs
unmodified and cannot tell.
**Distance decides who hears whom**, because `ChoraleHeard` carries no signal strength: a real
scanner either sees an advertisement or does not. So the ether asks each simulator where its duck is
standing and delivers a beacon only within range. Cruder than a real radio and far more
controllable — "these two can hear each other and those two cannot" becomes a number, where on
hardware it means carrying robots into other rooms. `--rotate` changes every address on a timer,
because `from` is documented as an identity for de-duplication only and a real address moves
underneath you — the bug that cost this project a day, now available as a test.
**A voice each, and it is not decoration.** Two ducks heard nothing from each other for an hour of
debugging: `robotd` takes its chorale id from the seed recorded in the bank it plays from, and that
id is how a duck recognises its own beacon reflected back. Sharing a bank meant sharing an id, so
each dropped the other as its own reflection and sat there listening to nobody. One bank per duck,
seeded from its name, fixes the id and gives four ducks four voices — which is what a chorale is for.
`sounds::hardware_seed` learns `DUCK_IDENTITY` for the same reason: several ducks on one machine is
the one situation where deriving identity from hardware is wrong. Nothing on a robot sets it.
A container's `/run` is its own, so the socket at `/run/robotd.sock` inside one is invisible from outside — and the radio has to hold a connection to every duck, from outside all of them. So a host directory is bound in at `/run/duck`, robotd is told to listen there, and a symlink puts the socket back at the path every client in the container expects. One object, reachable from both sides. Three things a container needs that the process-mode duck got from a params file, and which belong in its own overlay instead: the drop-in naming its body and its socket, `[chorale] accept` (and only that key, so everything else still resolves from the release), and a voice bank of its own — the chorale id comes from the bank seed, and ducks sharing a bank drop each other as their own reflection. The ether runs under sudo, because robotd creates its socket 0660 root:robot and the person running this script is not in that group. Being in the *host* group would not help either: the container has its own.
`stop_one` killed the process *group* named by a pidfile, with a `sudo` fallback. Pidfiles go stale, pids are recycled, and a `boot` leaves behind the pidfiles an `up` wrote — so `down` ran `kill -TERM -<long-dead pid>` as root against whatever process group had inherited that number. It took out a login session, and the machine had to be rebooted to get it back. Two changes, and the first is the rule: **Nothing acts on a pid without confirming through /proc that the process is still the one the pidfile was written for.** The pidfile records what the process must be, `stop_one` checks `/proc/<pid>/cmdline` against it, and says so and does nothing when it does not match. The file is removed before anything is signalled, so a stale one cannot be acted on twice. Anything that is not a live pid above 1 is dropped. **No group kill anywhere.** Every process this script starts is a single process that exits on TERM; the group form bought nothing except the reach to do harm. The container-mode radio, which has to run as root, is a `systemd-run --unit=duck-ether` instead — `systemctl stop` needs no pid and cannot be wrong about which process it is. Verified: a pidfile pointing at an unrelated live process now prints "not stopping pid N: it is no longer robotd" and leaves it alone, and a pidfile containing 1 does not touch init.
Frames from the simulator instead of the sensor, over the same newline-delimited JSON link `duck_control::sim` uses for the servo bus. `tofd` publishes them exactly as it publishes real ones — same `tof.frame`, same 8x8, same per-zone statuses — so `robotd`, the theremin and the viewer cannot tell. Verified end to end: `sensor: "sim"`, 15 Hz, 24 of 64 zones valid, the floor at 0.74 m. **The fake stays at the loop level.** `sensor.rs` says in as many words that the off-board `Sensor` "is not a fake sensor and must never become one", because what it stands for is a vendor C library talking to a bus — so a frame arriving from somewhere else is a different question from a sensor that lies, and it gets its own loop beside `fake_loop`. A simulator that goes away is one missed frame and a reconnect, since MuJoCo restarts whenever the number of ducks changes. `TCP_NODELAY`, for the reason the body link needed it: Nagle would add tens of milliseconds to a 15 Hz request and response, which is most of a frame. `duck-sim` starts one per duck in both modes, and points each duck's theremin at its own — on a board that socket is `/run/tofd/tof.sock` and needs no saying, but here every duck has one and they must not share. In a container it is a unit with a drop-in, like robotd, because the units being the real thing is the reason to boot one.
`robotctl monitor` draws the depth frame from `tofd` and the pad from `padd`, and looks for them under /run — which is where they are on a board and is not where they are here. So a monitor with no ToF points was a client pointed at the wrong path, not a sensor that was never wired: `tofd` was publishing 48 valid zones on its socket the whole time. `ctl` now passes `--tof-socket` alongside `--robot-socket`, `scripts/duck-sim monitor` is the short way in, and `DUCK_SIM_DUCK=duck-b` picks another duck.
`tofd.service` in the container failed with `status=217/USER`: `User=tofd`, and no such user. The staging step copied only `robot.conf`, while the release's own postinstall installs every sysusers file it ships. Now so does this, plus the `i2c` group `tofd.service` asks for — which a board gets from provisioning rather than from a release. Worth the note for how it presented: a unit whose `User=` does not exist reports 217/USER and nothing about a missing user, and from the monitor it looks exactly like a sensor nobody wired. mediad, btd and padd would each have hit the same wall in turn, so all five files go in now.
`tofd.service` still died with 217/USER after every sysusers file was staged correctly. The files were there; nothing had read them. `systemd-sysusers.service` carries `ConditionNeedsUpdate=|/etc`, and staging writes /etc (the hostname, the drop-ins, the params) after /usr — so /etc looks newer, the condition is not met, and the unit is skipped for the life of that rootfs. The users never exist, and the first daemon with a `User=` of its own falls over. The release's postinstall runs `systemd-sysusers` directly rather than trusting the unit, and now so does this. Followed by a check that each expected user or group is actually in /etc/passwd or /etc/group, said plainly at staging time — three rounds of this were spent looking at a monitor, then at a socket path, then at a copy that had in fact worked.
`tofd.service` sets `RestrictAddressFamilies=AF_UNIX`, and rightly: on a robot the daemon talks to an I2C bus and its own socket, and TCP is not its business. In the twin the sensor is across a TCP connection, so the sandbox refuses it — with EAFNOSUPPORT, "Address family not supported by protocol", which reads like a broken network rather than a policy that is working exactly as written. The per-duck drop-in adds AF_INET, reset and re-stated rather than appended so the drop-in says what the daemon may do rather than what it may do *as well*. This is the shape of thing the twin exists to find, incidentally: the unit is right, the daemon is right, and the simulator is the one asking for something the hardening never had to allow.
DUCK_SIM_SCENE=apartment scripts/duck-sim boot 2 A bare name is one of the simulator's own scenes; anything with a slash is a path. The default stays a bare floor, where a depth frame is 24 valid zones and 40 of open sky. In the apartment it is 62 of 64 — a wall receding from 0.88 m to 2.2 m with a gap where a doorway is, another wall at 0.8 m, and the floor at 0.41 m. The first depth frame in this project a mapper could do anything with.
The chorale was visibly working and silent, and the reason was blunt: there is no `aplay` in the rootfs. `robotd` forks it to make a sound and — by design, so that a robot with no codec still runs — logs the failure and carries on. A duck singing into a container with no ALSA tools is exactly that case, and it looks like a sound bug rather than a missing package. So the rootfs gains `alsa-utils` and `libasound2-plugins`, `/etc/asound.conf` points the default PCM at PulseAudio, and each container is handed the host's audio socket at `/run/pulse-host` with `PULSE_SERVER` set for robotd. Through the host's audio server rather than a bound `/dev/snd`, because that is what lets four ducks be audible at once instead of the first one holding the device. A machine with no audio server is told, not failed: on a board a missing speaker is a warning. The package list is now recorded beside the rootfs and compared on every `rootfs`, so adding a package rebuilds instead of silently not taking effect — which is otherwise a round trip spent wondering why a pull changed nothing.
The package list gained `alsa-utils` and nothing rebuilt, so there was still no `aplay` and the ducks still sang silently. `boot` called `rootfs` only when the directory was missing — and deciding whether the existing rootfs is still the right one is the whole of what that function does. Guarding the call on the directory existing meant the check could never run. It is called unconditionally now; it returns immediately when the recorded package list matches. A function that exists to decide something must not be called only when the answer is already known.
`robotd` runs `aplay -q -D plughw:aic3104` — the board's codec, named explicitly from `[audio] device`. So every sound fails before ALSA's default, and therefore `/etc/asound.conf` and the host's audio server, is ever consulted. The failure is logged and skipped by design, so that a robot with no codec still runs, which is precisely why a silent duck looks like a sound bug rather than a device that is not there. `device = "default"` in both modes, which is what makes the asound.conf pointing at PulseAudio mean anything. Three rounds on this and each layer was real: no `aplay` in the rootfs, then a rootfs that never rebuilt because `boot` asked for it only when it was missing, and now the device name.
A perfect ether hides the bugs a real one causes. Four ducks in the twin converged on one piece every
time — simultaneous starts, staggered starts, it made no difference — because every duck was visible
to every other instantly and losslessly. On hardware, BLE discovery is slow and lossy, so two ducks
can be singing before the other two have noticed them, which is the split-brain the election has to
survive. The simulator was not modelling the one property that causes the bug.
`--discovery` makes a duck take a while to be *noticed*, per pair and timed from when it goes on the
air — per pair because it is the asymmetry that splits a flock, and one delay shared by everybody
cannot produce it. `--loss` drops a fraction of deliveries. Both come from a seeded splitmix, so a
split that happens once happens again: a flaky radio is only useful for debugging if its flakiness
repeats.
With `--discovery 90 --loss 0.3 --seed 3`, four ducks and a staggered start, on main's chorale:
duck-a: listening — 1 ducks in range (never sings)
duck-b: bass bar 4 beat 13.3 3 voices
duck-c: alto bar 4 beat 13.4 2 voices
duck-d: bass bar 2 beat 2.0 2 voices
Which is the field report — "sometimes nothing happens, sometimes 2 different songs" — with the
disagreeing rosters and a duplicated part thrown in. On a laptop, on demand.
The twin now produces the chorale's field symptom on demand: four ducks, a staggered start, and `duck-ether --discovery 90 --loss 0.3 --seed 3`. One duck never sings, two are on bar 4 with rosters of 3 and 2, and the fourth is on bar 2 with a duplicated part. "Sometimes nothing happens, sometimes two different songs", deterministically, on a laptop. Recorded with the caveat that matters: `chorale-election` merged does not fix *this* scenario (three timelines, bars 5, 12 and 8), but at `--discovery 20 --loss 0.4` both versions converge — so ninety seconds of discovery is harsher than that branch was written for, and this is not yet evidence about a robot. The sweep that would settle it is a loop over one number, which is the whole point of having built the thing.
feed_tof() connected to proto::socket::TOF, a hardcoded /run/tofd/tof.sock. Every other daemon takes a --socket override and the twin needs one: macOS has no /run, and N ducks in one MuJoCo scene each need their own tofd socket. robotd already carries a configurable path to exactly that socket for the theremin, so reuse it rather than invent a second one.
MapWipe was inserted between Look's doc comment and the Look variant, so clap gave the camera docs to map-wipe and left look with none. Moved the variant above the comment block, which also puts the allow_negative_numbers note back next to the attribute it explains.
try-maploc.sh brings up body_server + tofd + robotd on port 7871 and /tmp/dsm, so it cannot collide with the 7801+ ducks, and drives the stop-and-scan route that actually produces a map. sim-maploc/ holds the viewer overlay that draws robot.map into the viewer's user_scn. Investigation scaffolding: hardcodes paths to this machine's microduck_rl checkout and is not meant to merge as-is.
maploc ships a ground-truth bench (examples/evaluate) that wants wall segments, a boot pose and a kidnap pose in cm. room_lab.toml warns that every reported error inherits any error in the measured boot pose. The twin has no such problem: MuJoCo reports the boot pose exactly, and the scene XML is the wall truth. make_truth.py generates the file from apartment.xml. It emits furniture footprints too, not just walls. kinematics::tof rejects floor beams and projects everything else into 2-D with no upper height gate, so a fridge is 'wall' as far as the map is concerned; scoring a furnished flat against its walls alone charges maploc for seeing what is really there. evaluate: stop panicking on a truncated trailing record. A recording is cut off wherever the robot stopped, so a partial last record is the normal case; panicking there threw away a whole session's report over its last few bytes. scan_walk: return to the boot pose and stand, which is what the bench's return-to-start metric reads.
try-maploc.sh started the duck at the STAND keyframe. robot.enable then runs a two-second position ramp to the home pose with nothing balancing, and the duck tipped over every time -- scripts/duck-sim documents exactly this and boots from SIT for it. maploc saw the fall, went suspect, confirmed none of its relocalize candidates and resumed a minute later on raw odometry; every map the twin built started from that. Boot seated: robotd detects it and rises via the sitstand policy, trunk 0.070 -> 0.116 m in half a second. record_dir moves from /tmp/dsm to recordings/ in the worktree, because a reboot took every session so far with it. And the backticks around `evaluate` in the TOML heredoc were a command substitution.
pose() read one line from the robot.subscribe socket per call. The stream writes faster than the leg loop reads, and during a stop nobody reads at all, so each call returned an older sample than the last -- by the second stop the "odom" printed was the truth of the stop before, and the legs were steered on it. That is why "back at start" landed 2-3 m away twice. Drain the backlog and use the newest sample. With that the route returns to (-0.07, +0.20), and the odom column now shows genuine drift, which maploc's tracked pose corrects to within a few centimetres.
MAPLOC_MIRROR_COLS=1 reverses every row of every frame before projection. The MuJoCo twin's ToF numbered its columns the other way round from kinematics::tof -- column 0 on the right, not the left -- so every frame reached the mapper mirrored, oblique walls were inked at their mirror image across the head's axis, and no loop ever closed. Replaying the same session mirrored took the wall error from 0.174 m to 0.058 m and closed three loops, which is how the bug was proven before the simulator was fixed. Kept so the recordings made before the fix stay usable.
1788348816 boot from STAND, the duck falls at enable, maploc resumes
unverified; mirrored sensor. walls vs room 0.141 m
1788356722 boot seated, no fall; mirrored sensor (replay with
MAPLOC_MIRROR_COLS=1 to see it clean). 0.174 m, 0.058 mirrored
1788358851 boot seated, sensor fixed, route returns to start. 0.031 m
All against sim-maploc/apartment.toml via examples/evaluate.
robotctl monitor looked for /run/tofd/tof.sock, the board's path, and on macOS reported "no depth stream" as if no sensor were fitted. Pass --tof-socket with the socket try-maploc.sh started tofd on.
apirrone
self-requested a review
September 2, 2026 18:38
Contributor
|
Great, i'll test all this when I have time ! Thanks ! |
peterschade
marked this pull request as draft
September 9, 2026 14:19
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.
What this is
maploc run on the MuJoCo twin, with the map drawn into the viewer and scored against the scene's own walls. It stacks on #127 (
maploc) and on thesim-remote-iobranch, both by @apirrone, so the diff againstmaincarries both; the commits that belong to this PR are the ones aftermerge: maploc onto sim-remote-io (investigation).The scaffolding under
sim-maploc/andtry-maploc.shhardcodes paths to a localmicroduck_rlcheckout. It is investigation tooling, kept so the runs are reproducible, not something to ship on a robot.What the twin found
Three things stood between maploc and a usable map, none of them in maploc.
The simulated ToF numbered its columns backwards.
microduck_rl'ssim/tof.pyput column 0 on the sensor's right;kinematics::tofreads the real sensor's buffer with column 0 on the left. Every frame reached the mapper mirrored: walls seen at an angle were inked at their mirror image across the head's axis, phantom walls stood in open floor, and no loop ever closed on the twin. Fix in pollen-robotics/microduck_rl#30.examples/evaluategainedMAPLOC_MIRROR_COLS=1so recordings made before the fix still replay correctly.The duck fell at enable.
try-maploc.shbooted from the STAND keyframe;robot.enablethen ramps to the home pose with nothing balancing and the duck tips over, asscripts/duck-simalready documents. maploc saw the fall, went suspect, confirmed none of its relocalize candidates, and resumed a minute later on raw odometry. Boot seated instead: robotd rises via the sitstand policy.The route steered on stale odometry.
scan_walk.pyread one line per call from therobot.subscribestream, which writes faster than the loop reads; by the second stop the pose it steered on was the stop before. Drain the backlog.Numbers
Map wall cells against the scene's walls and furniture footprints (
sim-maploc/apartment.toml, generated fromapartment.xml; boot pose exact, no tape measure):MAPLOC_MIRROR_COLS=1On the clean run the tracked pose stays within about 6 cm of MuJoCo's ground truth at every stop, including after the return leg, while raw odometry drifts by up to 0.35 m. Before the fix the twin's best was worse than maploc's own hardware figure of 0.126 m; after it, the twin is the first place maploc's mapping has been shown to work.
The three recordings are committed under
recordings/(about 4.5 MB) so the bench can be rerun without a simulator:Open
scan_walk.pystands at the end, so both report "protocol not detected".1788358851.mdlg.scan_walk.pygates its walk bursts on wall-clock modulo, and a burst shorter than the policy's start latency moves nothing; the clean run stood still for three legs because of it.