Skip to content

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
pollen-robotics:mainfrom
peterschade:maploc-sim-investigation
Closed

peterschade wants to merge 57 commits into
pollen-robotics:mainfrom
peterschade:maploc-sim-investigation

Conversation

@peterschade

Copy link
Copy Markdown

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 the sim-remote-io branch, both by @apirrone, so the diff against main carries both; the commits that belong to this PR are the ones after merge: maploc onto sim-remote-io (investigation).

The scaffolding under sim-maploc/ and try-maploc.sh hardcodes paths to a local microduck_rl checkout. 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's sim/tof.py put column 0 on the sensor's right; kinematics::tof reads 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/evaluate gained MAPLOC_MIRROR_COLS=1 so recordings made before the fix still replay correctly.

The duck fell at enable. try-maploc.sh booted from the STAND keyframe; robot.enable then ramps to the home pose with nothing balancing and the duck tips over, as scripts/duck-sim already 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.py read one line per call from the robot.subscribe stream, 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 from apartment.xml; boot pose exact, no tape measure):

recording boot sensor mean p50 p90 wall cells on no real wall loops
1788348816 STAND, fell mirrored 0.141 m 0.094 0.315 0
1788356722 seated mirrored 0.174 m 0.114 0.386 18 % 0
1788356722 replayed MAPLOC_MIRROR_COLS=1 corrected 0.058 m 0.036 0.131 3
1788358851 seated fixed 0.031 m 0.026 0.066 0 % 0

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

cargo build --release -p maploc --example evaluate
./target/release/examples/evaluate recordings/1788358851.mdlg sim-maploc/apartment.toml out/

Open

  • The bench's return-to-start and kidnap metrics want a sit as the protocol mark; scan_walk.py stands at the end, so both report "protocol not detected".
  • No loop closed live on the clean run even though the return leg re-sees the boot area; the mirrored replay of the earlier session closed three. Worth chasing offline on 1788358851.mdlg.
  • scan_walk.py gates 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.

apirrone and others added 30 commits August 21, 2026 16:19
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.
apirrone and others added 24 commits August 30, 2026 18:46
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
apirrone self-requested a review September 2, 2026 18:38
@apirrone

apirrone commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Great, i'll test all this when I have time ! Thanks !

@peterschade
peterschade marked this pull request as draft September 9, 2026 14:19
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