Skip to content

sim: the ToF numbered its columns backwards - #30

Merged
apirrone merged 13 commits into
pollen-robotics:developfrom
peterschade:sim-body-server
Sep 2, 2026
Merged

apirrone merged 13 commits into
pollen-robotics:developfrom
peterschade:sim-body-server

Conversation

@peterschade

@peterschade peterschade commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What this is

The sim-body-server branch as @apirrone pushed it (eleven commits: a simulated body served to the real robotd over TCP, several ducks in one world, a depth sensor per duck, an apartment scene), plus one fix on top from running maploc on it.

The fix: the ToF numbered its columns backwards

sim/tof.py gave column 0 of the 8x8 frame the azimuth −22.5°, looking right. kinematics::tof in microduck, which reads the real VL53L5CX buffer, takes column 0 as the sensor's left. Every simulated frame therefore reached maploc mirrored left/right. 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 closure ever fired on the twin.

Proven before the change by replaying one recorded session through maploc's bench with the columns reversed, and confirmed by a fresh run after it. Map wall cells against the apartment's walls and furniture footprints:

mean p50 p90 wall cells on no real wall loops
before, live 0.174 m 0.114 0.386 18 % 0
before, replayed mirrored 0.058 m 0.036 0.131 3
after, live 0.031 m 0.026 0.066 0 % 0

The change is one sign: azimuth = -centres[col]. The comment above it now states the column convention and why.

Companion PR: pollen-robotics/microduck#202, which holds the scaffolding, the recordings and the bench numbers.

apirrone and others added 12 commits August 30, 2026 17:49
The other half of `microduck`'s `robotd --sim`: MuJoCo holds the body, the daemon holds everything
else, and a TCP socket replaces the servo bus. Above `duck_control::io::RobotIo` the code is what
runs on a real robot — the 50 Hz loop, the ONNX policies, safety, fall detection, odometry,
kinematics, every IPC call — and it cannot tell.

    uv run duck-body                 # then, elsewhere: robotd --sim 127.0.0.1:7801

Measured working end to end: the daemon reports `50.0 of 50.0 Hz · 0 missed`, detects a seated boot
from the sim's own joint angles, and `robotctl robot init` runs the sitstand policy until the duck is
upright and stays there.

Two mappings live here on purpose. The daemon indexes joints by `JOINT_NAMES`, which has fifteen
entries including `mouth`; the model actuates fourteen and has no mouth. And the policy observes
projected gravity in the trunk frame, while MuJoCo offers an orientation quaternion. Both conversions
belong on the side that knows the model, which is why the protocol carries the robot's own units.

Three things cost an hour each and are commented where they bit:

**`scene.xml`, not `scene_walk.xml`.** The walking scene includes the model the RL work trains
against, whose actuator default classes carry `contype="0" conaffinity="0"` — so the robot collides
with nothing and sinks through a floor the scene really does contain: trunk z went 0.120 to -0.105 in
one second. The daemon read that as a robot on its back, which it was. `scene.xml` includes
`robot_allcollisions.xml`.

**Start from a keyframe, not `qpos0`.** Every joint at zero is not a pose this robot is ever in; the
daemon measured 0.41 rad from its home frame, called it a seated boot and tried to rise. The scene
carries the real poses, and `STAND` matches `duck_control::DEFAULT_POSITION` — including the mirrored
right leg, which I got wrong by assuming symmetry before reading it.

**Torque on at startup, holding the pose.** `robotd` deliberately never enables torque when it
starts, because "a robotd restarted by an update must leave a standing robot standing" — on a real
robot the servos are already holding. Starting limp means the duck collapses before the first read.
`--limp` is there for when a duck on the floor is what you want, and `--keyframe SIT` is the boot
story the daemon is actually written for.

Real time, not as fast as possible: the daemon's health gate fails below 45 of 50 Hz, so a simulator
running at its own pace does not look wrong — it makes every duck unhealthy and starts rolling
releases back. It says so when it falls behind.
Two fixes for the same misreading: a duck that never stood up looked like one that had.

**`trunk_z` in the sensor frame.** Deliberately outside the protocol — no robot can measure how high
its own trunk is, and serde ignores it on the daemon side — because a tool asking "did it stand?"
has no other way to know. A duck sitting on its bottom with a vertical trunk has gravity [0, 0, -1]
exactly as a standing one does, so a check on orientation alone called 0.978 upright while the
window showed it sitting. STAND is 0.12 m and SIT is 0.07.

**A frame every N steps, counted.** The viewer synced on `data.time % 0.033 < dt`, which is float
arithmetic on an accumulating value and fires when it feels like it; a window that renders the first
frame and rarely again shows a pose the robot left seconds ago.
`sim_time` alongside `trunk_z`, outside the protocol for the same reason: the daemon's loop is
wall-clock, so a simulator whose seconds are not seconds silently ruins a policy, and the "behind
real time" warning cannot see slow drift because it resets its own reference each time it fires.
Measured with it: 6.00 s of sim in 6.00 s of wall clock, 1.00x. Pacing is not the problem.

The pacing loop no longer sleeps on every 2 ms step. `time.sleep` overshoots a 2 ms budget by more
than the budget, so 500 sleeps a second is a timer rather than a clock; it now sleeps only when
there is more than a step's worth of slack to wait for.
…torque

Three things the working reference does that this did not.

`model.opt.timestep = 0.005` — the scenes ship 0.002 and `scripts/infer_policy.py` overrides it,
which with its decimation of 4 is exactly the 50 Hz the daemon runs at. The BAM actuator fit, the
contact solref and the joint armature are all tuned at that step.

`--keyframe HOME` is infer_policy's placement rather than a keyframe: trunk 0.125 m, upright, every
joint at the home pose.

And physics does not advance until the first `set_torque(true)`. This duck cannot hold any pose
without a policy driving it — measured at both timesteps, from every placement — and `robotd`
deliberately does not enable torque at startup, so the seconds before it would be spent falling
over. Holding it is a hand steadying the robot until someone enables it.
The window is for watching a duck. Everything the panels would drive — the pose, the actuators, the
policy — belongs to the daemon here, so they are two columns of controls that must not be touched.
`--ducks 4` builds one model holding four of them and serves each on its own port, from `--port`
upwards. One `mj_step` advances all of them, which is the difference between a room with four robots
in it and four robots on the same screen: they share a floor, and they can bump into each other.

The scene already contains one duck, so the rest are attached to it under a name prefix with
`MjSpec`. A fresh child spec for each — `attach` renames the spec it is given, so reusing one names
the third duck `d2_d1_left_hip_yaw` and fails to compile.

`Body` becomes a view onto a shared `World` rather than owning a model: its own actuators, joints
and trunk, resolved by prefixed name. A duck nobody has enabled yet is put back where it was after
every step, since physics is shared and one of them cannot simply not be advanced — a hand steadying
one robot while another walks about is an ordinary thing for a room to contain.

Poses are applied by joint name from a table read out of the scene's keyframes, because attaching a
robot does not bring the scene's keyframes with it. Including the keyframe's own trunk height: a
seated duck placed at standing height hovers above the floor and drops the moment it is enabled.
The lock is the bottleneck, not the solver: four daemons at 50 Hz make 400 requests a second, each
wanting the world lock, and Python hands the GIL between every one of them — so a physics loop that
takes and releases it 200 times a second loses. Four steps of 5 ms is 20 ms of world, exactly one
control tick and the same decimation infer_policy.py uses, so nothing sees a sensor older than the
tick it belongs to.

And the viewer syncs at 30 rather than 60. `viewer.sync()` copies the scene on the physics thread,
and with four ducks in it that is the difference between keeping real time and not.
Outside the protocol, like trunk_z and sim_time: no robot knows its own position in a room. It is
here so a simulated radio can decide who is close enough to hear whom — the one thing a real BLE
advertisement gets for free and a faked one has to be told.
An 8x8 VL53L5CX on the model's own `tof` site — 45 degrees per axis, 4 m, and noise that grows with
range, from ~/MISC/microduck_maploc's version of the same sensor. Answered on the body protocol as
`{"op":"tof"}`, at the sensor's own 15 Hz rather than the control loop's 50, because sixty-four ray
casts is the most expensive thing here and that is the rate the hardware runs at.

The status byte matters as much as the distance: a real sensor distinguishes nothing-out-there from
could-not-measure, and the consumers treat them differently — one is empty space to clear on a map,
the other is no information. A simulator reporting distances alone would let a bug through that
hardware finds.

Self-hits are reported rather than filtered. A real sensor sees the duck's own beak when the beak is
in front of it, and quietly skipping our own geometry would hide exactly the mounting problem this
is here to catch. Verified standing: 24 of 64 zones valid, the floor at 0.74 m from a site 0.251 m
up looking level, and open air above.
Six rooms in a 7x6 m floorplan with 50 cm walls, from ~/MISC/microduck_maploc — and its own comment
says why it is the right room rather than any room: the doorways are deliberately off centre, because
that asymmetry is what lets a pose be disambiguated from a 45-degree forward field of view. Which is
exactly the field of view the duck has.

`scene_apartment.xml` is `scene.xml` with the apartment included, so the duck, the floor, the visuals
and the four keyframes every pose is read from all come along. 164 geoms, and no name collisions
worth reporting.

The difference it makes to the sensor, measured through `tofd`: on a bare floor 24 of 64 zones are
valid and the rest is sky. In the apartment, 62 of 64 — a wall receding from 0.88 to 2.2 m with a gap
where a doorway is, another at 0.8 m, and the floor at 0.41 m. That is the first depth frame in this
project that a mapper could do something with.
The apartment lays its own floors — boxes whose top face is exactly z=0 — and `scene.xml`'s ground
plane sits at z=0 too. Two surfaces in the same place is a floor that flickers as the renderer picks
between them, which is what it looked like.

The plane goes. The apartment's floors carry `contype="1"`, so a duck still stands on them; walk out
of the flat and there is nothing there, which is a fair description of a flat.
Column 0 of the simulated sensor looked right; kinematics::tof, which reads
the real sensor's buffer, takes column 0 as the sensor's left. Every frame
reached maploc 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. Replaying one session with the columns
reversed took the wall error against the scene from 0.174 m to 0.058 m;
a fresh run with this fix reads 0.031 m.
@apirrone

apirrone commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Good catch ! I'll test and merge this when I have time. Thanks for your contribution !

@apirrone apirrone left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed it was reversed :)

@apirrone
apirrone merged commit 29e887e into pollen-robotics:develop Sep 2, 2026
microai-lab pushed a commit to microai-lab/microduck_rl that referenced this pull request Sep 11, 2026
sim: the ToF numbered its columns backwards
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