Navigation for the Microduck: where the duck is, and how it gets somewhere else.
A daemon (quack-navd) and the library under it. It hosts the mapper
itself — maploc, in this workspace, derived from Pollen's (upstream PR
127) — against Pollen's released robotd, and adds everything above it: a cliff guard that judges the 8×8 depth sensor's
downward beams against the floor, a costmap planner, a registry of
places people taught it, an explorer that maps a house on its own, a
homecoming that recognises the house at boot — and a paper twin that
runs all of it against a kinematic model of the duck, thousands of
times an hour, so a rule is measured before it is believed.
A go_to on the MuJoCo twin, casa_grande, on the map the duck explored
itself: 5.2 m in a straight line from the far bedroom to the kitchen,
walked in 123 s and shown 6× faster. The dotted line is the path walked,
the line ahead of the duck the planned route, the red blobs the drops it
booked (2026-10-01).
Independent project, not affiliated with Pollen Robotics. Apache-2.0. Split out of quacksat on 2026-09-22 (ADR 0006), with the history of every measurement that made it. quacksat is now the voice front end only: it relays the user's spoken commands to quack-nav's tools and does no navigation.
An experiment — take it with a pinch of salt. Everything here has run on the MuJoCo and paper twins only, never on a physical duck. A walking robot near stairs can fall and break: if you try it on real hardware, keep it away from drops and stay beside it. The numbers in docs/results.md are what the twin measured, not a promise of what a real house will do. Where this is going, in the shape of Pollen's own roadmap: docs/roadmap.md.
Every move quack-nav sends —
robot.moveandrobot.map_stepincluded — stops at a drop the depth sensor sees ahead (forward moves only). Pollen's own teleop (the gamepad, the console) does not go through quack-nav, and robotd has no drop protection of its own: driven that way the duck walks off a stair as onto the floor (an ask upstream, docs/study/upstream-asks.md §8). Driving it around by hand does not lose its position: walking explains the motion; only a carry, a sit or a fall makes it look for itself.
Current release: v0.3.0-rc3, a release candidate validated on the twins — release notes (earlier: 0.3.0-rc2, 0.3.0-rc1, 0.2.0-rc2, 0.2.0-rc1), changelog.
Two things to know before installing v0.3.0
- Tell quack-nav which walk your duck runs. Pollen's ducks ship with the velstand walking policy, and that is quack-nav's default:
[gait] profile = "velstand"in/etc/robot/quack-nav.toml. If you switched robotd to alpha (alpha_walking+alpha_stand), setprofile = "alpha". The profile carries the walk's speed, turn rate and how long the body settles after a stop; the wrong one makes the duck misjudge its own steps. The walk.- It has a neural navigation model, optional. A small network (an MLP, exported to ONNX and run in pure Rust) can choose each step of a journey in place of the hand-written rules, one model per walk (and, for velstand, per daemon line); hard shields around it keep it off holes. Off by default: set
QK_RL_POLICY=/var/lib/quack-nav/pilotsto fly it. The pilot.
A duck that knows where it is can be told where to go. quack-navd
answers for that on a unix socket, in robotd's own dialect — NDJSON,
JSON-RPC 2.0 — so anything can drive it: a voice satellite, an agent,
a ROS bridge, a shell script.
# the catalog it announces
printf '{"jsonrpc":"2.0","id":1,"method":"nav.catalog","params":{}}\n' | nc -U /run/quack-nav/nav.sock
# where am I?
printf '{"jsonrpc":"2.0","id":2,"method":"nav.call","params":{"name":"robot.where_am_i","args":{}}}\n' | nc -U /run/quack-nav/nav.sock
# map the house, then go to the kitchen
… {"name":"robot.map_explore","args":{}}
… {"name":"robot.go_to","args":{"place":"kitchen"}}Twelve tools: robot.where_am_i, robot.remember_place,
robot.forget_place, robot.list_places, robot.map_status,
robot.map_step, robot.map_explore, robot.go_to, robot.map_save,
robot.map_list, robot.map_load, robot.map_match — each with a
JSON-Schema parameter list, ready to be projected onto OpenAI tools or
MCP by whoever hosts them.
What a user can ask of the duck, through a voice satellite or anything that speaks the socket:
- "Explore the house." Exploring is progressive: one session per charge
(a time budget, or the battery under 25 %), each going on from where the
last one stopped and saving the map at its end. After the next charge the
duck finds the map, finds itself on it and explores on (
[homecoming] resume_explore), until nothing large is left — then the house is done. - "How far along is the map?"
robot.map_status→house.percent_mapped, the sessions so far, done or not. - "Exploration complete." The user closes the map as it is
(
map_explore complete): saved, declared done, frozen. - A new map.
map_explore freshanswers what would be lost and waits forconfirmed; the saved map is replaced only when the new one's first session saves. - Going places. On a finished map the duck explores no more, even after a
restart: it comes home, freezes the map (as it loads it, with or without
resume_explore) and navigates — blind where the map knows the floor, with the guard on where it does not.robot.go_toa named place or a point;robot.remember_placenames where it stands. - When it moves on its own. After a power-on the duck may walk by
itself — looking for where it is on the saved map, exploring to recognise
the house, finding itself again before a job after it was moved. Allowed,
but said:
robot.map_status→explore.self_startedand the reason (explore.statesearchingat boot), a warning in the log, a hint. STOP —robot.go_to {"stop": true}, quack-control's button — stops it at once and holds: nothing starts it again by itself in that power-on until a job is asked for. Arobot.moveorrobot.map_stepwhile it moves on its own stops that motion and obeys (the reply'sstopped_own); after a STOP both work at once — on a lost or untrusted pose judged by the depth sensor alone,robot.move's cliff guard on. - Tables, beds, a tub. The map is a floor plan: what the duck meets at
its own height but the beams pass under or over — a coffee table's top
at 14–22 cm, a bed's edge, a tub's rim — reads as free floor there. So
the mapper also keeps a band: per 5 cm cell, ten 2.5 cm slices from
5 to 30 cm, filled by the returns and emptied, slice by slice, by the
beams that cross them, booked only standing with the pose tracked, kept
by submap like the map and saved beside it (
<map>.band). The navigation reads what the band holds as wall; a desk at 34 cm is not in it, so the duck still walks under. A return within 10 cm of a wall the submap's own 2D grid already has is left to the map: booked in the band it thickened the walls on their free side and narrowed passages.QK_BAND=0turns it off. On the apartment's first 30 minutes on the twin, replayed: 97 % of its cells within 10 cm of the furniture (the map's walls: 98 %); the coffee table, the bed and the tub blocked where the map had 0–4 % of them as wall. On the twin against main (docs/results.md): 48/48 journeys on both sides, no fall, and the exploration declared done sooner.
The design is ADR 0008.
quack-duck— robotd's lane (a JSON-RPC client over its unix socket), the gait's limits, the body's own commands. A client of the duck, nothing more; the satellite uses it too.quack-nav— the map client, the cliff guard, the planner, the places registry, the explorer, the homecoming, the tools, the paper twin, the pilot's runtime, andquack-navd.quack-rl— the pilot's training ground: generated houses, the simulator that runs quack-navd's own journey loop, the bench, the calibration from a duck's traces (scripts/rldrives the training).
docs/results.md has the release's numbers, the
criteria they are held to and the known limits. In short, on the MuJoCo twin
with three houses: no fall in 11 exploration sessions and 51 journeys; 90 %
of journeys arrived (main's build, same maps: 63 %); every confirmed pose
within 20 cm of the truth; 5 of 7 release criteria met, counting partials as
half.
Before that, three weeks on the twin, written down as it happened in
docs/todo-map.md (Italian copy beside it) and
docs/study/baseline-twin.md. The
shape of it:
- Blind journeys on a saved map: six goals round a flat, 6/6, about eight minutes, no fall — run after run since 2026-09-16.
- The boot: the duck wakes up where it was carried, recognises the house it saved and confirms its pose. On the wake bench (2026-09-30, 12 spawns across two houses, then the same turned 180°): 23 of 24 right, none wrong, no fall; medians 87–105 s, and 105–123 s turned (174–192 s and 126–135 s before the shadow map).
- A house never used to tune anything (casa_grande, 2026-09-30: seven rooms, a corridor turning 90°, two holes): 16/16 journeys, 8/8 wakes (median 84 s), no fall, walls 99 % on the truth.
- The stairwell: the passage beside a hole, 0.54 m wide, walked with the guards on when the pose is within 10 cm — and why it is the pose, not the rules, that decides (the scan matcher lags 8–10 cm along a corridor; measured, not guessed).
- Long idle stands (2026-10-01): after a minute with no job the
mapper rests — odometry carries the pose, one window a head sweep long
is judged against the map every two minutes, nothing is corrected — and
a job, a move or a push wakes it on its first tick (a
go_to: 0.12 s). Over thirty-minute stands on the twin, whose standing duck turns and slides by itself, the pose stayed 5.5-7.9 cm from the truth on average, about what staying awake gives, for less CPU. Moved while it rested — a watch the map contradicts, a push, a sit — the pose is untrusted, and the nextgo_towalks and looks until it finds where it is before it sets out (carried 3.3 m into another room: found in 70 s, arrived 2 cm off). - Things on the floor: a 7 cm cube beside a blind leg is seen and gone round; under ~9 cm the sensor's own floor threshold loses it while walking.
- People walking between the rooms (2026-10-26, casa_gente: four people on paths through the doors and the passage by the stairwell, and a cat, the same movers on the twin and on the bench): on the bench the pilot arrives 89 % of the time with 2.2 contacts with people a journey, the stick 84 % and 8.0; what is lost is the duck bumping someone who waits in a door instead of waiting or backing off — the next pilot's lesson (docs/results.md, "Round two on the 0.17 line").
quack-nav/examples/paper_twin.rs is the explorer run against a
kinematic model of the duck in a flat of boxes: the real planner, the
real guards, the real job — with the gait, the depth sensor and the
pose's drift modelled from what the MuJoCo twin measured. Thirty trials
take a minute:
cargo run --release --example paper_twin -- \
quack-nav/examples/apartment.world.json /tmp/out --runs 30 --goto -2.64,-2.12 --books--known freezes the world as the map (the journey bench), --bias dx,dy offsets the map's frame from the world (what a real pose error
does to a passage).
A neural navigation model, one per walk and daemon line, optional. A small network that picks the stick's moves from the route ahead, the
depth sensor's last second and the map around the body, trained on
hundreds of thousands of simulated journeys through generated houses with
what the map does not know on the way (things put down since, pets and
feet crossing, half-closed doors, passages beside a hole), shielded so
that no move it asks for can take the duck over a rim it knows or backward
blind; off unless QK_RL_POLICY names its file. With it, a calibration:
QK_RL_TRACE records the duck's legs, and scripts/rl/calibrate.sh fits
the simulator to them, retrains, and lets the new pilot fly only if it
beats the stick there.
- What it decides: one of 9 moves for each step (a step straight or curving, a turn in place, a back-off, a wait) from 351 numbers (the route ahead, 1.4 s of depth frames, 1.6 m of map, its last moves). Never the route: Dijkstra plans it, the pilot only walks it.
- The model: an MLP 351 → 256 → 256 → 9, trained by imitation (DAgger)
then PPO in a simulator that runs quack-navd's own journey loop, tuned
on the MuJoCo twin's traces, exported as
pilot.json(what quack-navd loads, pure Rust) andpilot.onnx(the same network, for any ONNX runtime). - One per walk, and per daemon line:
pilots/alpha/(v3-r7-mujoco),pilots/velstand/(v3-r7-velstand, the 0.16 line) andpilots/0.17/velstand/(v8l-s3-velstand, the 0.17 line) ship in the install package, under/var/lib/quack-nav/pilots/. WithQK_RL_POLICYnaming that directory, quack-navd takes the one of[gait] profileon the daemon line it runs; a pilot never flies a walk or a line it was not trained on (the stick drives then). - The shields: the hole guard, no step across a booked drop, no blind back-off, no pushing on; two refused moves or four turns in a row and the stick takes the leg. A pilot is released only if no brain — not even one choosing at random — falls into a hole on the test bench.
- Measured: test bench, 420 journeys, alpha's numbers: the pilot 97.1 %
arrived, 3 tip-overs, the stick 93.3 %, 20. On the velstand-tuned
simulator 95.2 % against 94.8 %, 4 tip-overs against 18. On the MuJoCo
twin, casa_ingombra, velstand: the pilot 12 of 12, no fall. On the 0.17
line (daemon-v0.17.6 + set v8) the pilot v8l-s3, trained on the twin's
traces and houses with the simulator's sensor made to see as the
twin's: 48/48 on the twin, no fall, 2.2 and 4.4 s a journey faster than
the stick (casa_ingombra, casa_arredata; 2026-10-25), and against
frozen
main's stick 48/48 again, 4.2 and 3.9 s faster (AB17, 2026-10-26) — the first on that line not slower than the stick, and the one shipped for it. Only the twins so far: on a duck, calibrate it first.
Everything — the design, the shields, the numbers, the limits: docs/rl-pilot.md.
Rust 1.89 or newer; the first build fetches Pollen's duck-ipc-proto and
kinematics crates from GitHub (tag daemon-v0.17.6). One build runs on
every daemon from v0.14.4: what changed since is optional on the wire.
git clone https://github.com/andreagenovese/quacknav.git && cd quacknav
cargo build --release
target/release/quack-navd /etc/robot/quack-nav.tomlquack-nav/quack-nav.example.toml:
socket = "/run/quack-nav/nav.sock"
robotd_socket = "/run/robotd.sock"
[map]
enabled = true
tof_socket = "/run/tofd/tof.sock"
places_path = "/var/lib/quack-nav/places.json"
[homecoming]
enabled = true # recognise the house at boot, and take its map back
resume_explore = true # explore on after each charge until the house is done
[maploc]
enabled = true # host the mapper here, against the released robotd
mode = "stop_and_scan" # or "localize" once the house is mapped
map_path = "/var/lib/quack-nav/maploc.session"
[gait]
profile = "velstand" # the walk robotd runs: "velstand" (Pollen's default since
# policy set v5) or "alpha" (alpha_walking + alpha_stand)The example leaves daemon out: quack-navd asks robotd which daemon it is
(The daemon).
With [maploc] on, quack-navd runs Pollen's maploc itself (the
maploc crate in this workspace): it reads robot.state and tofd's
depth stream, pans the head at stops, and serves the map on
/run/quack-nav/map.sock in robotd's robot.map* dialect. Nothing in
robotd changes — the release (daemon-v0.14.4 and on) publishes everything
the mapper needs.
With it off, the map comes from a robotd that hosts maploc itself.
quack-nav/systemd/quack-navd.service and quack-nav/systemd/sysusers.d/
install it as an unprivileged service beside robotd
(Installing on the duck); the unit runs
/usr/local/bin/quack-navd /etc/robot/quack-nav.toml and creates
/run/quack-nav/ for the sockets. Run by hand outside that unit,
/run/quack-nav/ must exist and be writable — [maploc] socket defaults to
/run/quack-nav/map.sock even when socket is set elsewhere — or the daemon
stops at once, naming the path and the way out:
Error: cannot bind the map socket /run/quack-nav/map.sock: No such file or directory (os error 2) — its directory /run/quack-nav does not exist (systemd's RuntimeDirectory= creates it; by hand: mkdir -p /run/quack-nav or set `[maploc] socket`)
A config file that does not parse, or cannot be read, is named the same way. It starts without robotd and tofd and waits for them (tools answer "no map yet" meanwhile); without the duck, the MuJoCo twin stands in for both (scripts/twin/README.md).
The tests and the paper twin gate, as CI runs them
(.github/workflows/ci.yml):
cargo test --workspace --release --features maploc/kinematics
python3 scripts/knobs.py --check
cargo build --release -p quack-nav --example paper_twin
mkdir -p /tmp/paper-twin
python3 scripts/ci/paper_twin_gate.py target/release/examples/paper_twin \
quack-nav/examples/apartment.world.json /tmp/paper-twinNo checkout and no build: every release from v0.2.0-rc2 on carries an
install package, quack-nav-<version>-aarch64-linux.tar.gz, with its
.sha256 (v0.2.0-rc2's was added to it afterwards, around its own
binary; v0.2.0-rc1: the bare binary only). It
holds the board's binary, the unit, the service account, the example
config, install-on-duck.sh and a step-by-step README-install.md
(Italian: README-install.it.md). The duck must be provisioned by
microduck first (robotd, tofd, the robot group); your computer needs
ssh, scp, tar and shasum.
V=0.3.0-rc3 # the release's tag without its v
gh release download "v$V" --repo andreagenovese/quacknav \
--pattern "quack-nav-$V-aarch64-linux.tar.gz*"
# or: curl -LO https://github.com/andreagenovese/quacknav/releases/download/v$V/quack-nav-$V-aarch64-linux.tar.gz
# (and the same URL with .sha256)
shasum -a 256 -c "quack-nav-$V-aarch64-linux.tar.gz.sha256" # prints OK
tar xzf "quack-nav-$V-aarch64-linux.tar.gz" && cd "quack-nav-$V"
./install-on-duck.sh --dry-run microduck@192.168.1.42 # optional: prints every command, connects to nothing
./install-on-duck.sh microduck@192.168.1.42The script finds its files next to itself (bin/quack-navd,
systemd/, quack-nav.example.toml) and does what
Installing on the duck describes: binary, unit
and account replaced, /etc/robot/quack-nav.toml installed only when
there is none, the service enabled and restarted.
The config, on the duck (sudo nano /etc/robot/quack-nav.toml, then
sudo systemctl restart quack-navd). The example is right for a standard
duck; what to look at on a real one:
| key | in the example | when to change it |
|---|---|---|
robotd_socket |
/run/robotd.sock |
robotd listens elsewhere |
[map] tof_socket |
/run/tofd/tof.sock |
tofd listens elsewhere (the cliff guard and the mapper read it) |
[maploc] mode |
"stop_and_scan" |
the map grows at every stop; set "localize" once the house is mapped (the map stays as saved, the pose is corrected against it) |
[maploc] map_path |
/var/lib/quack-nav/maploc.session |
the working session; the named maps are maps/ beside it |
[map] places_path |
/var/lib/quack-nav/places.json |
the named places |
[homecoming] enabled, resume_explore |
true, true |
off: the duck does nothing on its own at boot, nor explores on after a charge |
socket |
/run/quack-nav/nav.sock |
where quacksat and quack-control find quack-navd |
daemon |
left out ("auto") |
"0.16" or "0.17" to name Pollen's daemon instead of asking robotd (see below) |
[gait] profile |
"velstand" |
"alpha" when robotd walks with alpha_walking + alpha_stand (see below) |
Paths must stay under /var/lib/quack-nav/ or /run/quack-nav/: the unit
lets the daemon write nowhere else. A key the daemon does not know stops
it with a message naming the key (journalctl -u quack-navd). Every key
and its default: quack-nav/src/config.rs.
Pollen's daemons do not all walk alike: from daemon-v0.16.2 the duck runs policy set v8, whose velstand is slower forward (0.095 m/s against 0.115) and turns about twice as fast (a turn in place at 1.0 is 65°/s, where set v7's barely moved). One key chooses which daemon quack-navd drives for:
daemon = "auto" # the default: robotd says which it is at start
daemon = "0.16" # up to daemon-v0.16.1 (policy set v7)
daemon = "0.17" # daemon-v0.16.2 on (policy set v8)A release works as well ("0.16.1", "0.17.6"). With "auto" — or no
daemon at all, as in every config written before it — quack-navd asks
robotd at start (hello) and, if robotd says nothing within 30 s, takes
the newest line, since Pollen's ducks update themselves; the log names the
line taken (journalctl -u quack-navd | grep daemon:). A named line wins
over robotd, which is only said in the log when it disagrees. The daemon
sets velstand's numbers and which pilot may fly
(docs/rl-pilot.md); alpha is the same on every line.
Each line's numbers live in a file of their own,
quack-duck/src/daemon/v0_16.rs and v0_17.rs.
Those numbers are priors. The gait ledger measures the walk on the
duck's own legs — a free forward step gives the speed and the trim that
keeps it straight, a long curving leg the yaw per unit of command, the
stick's curving pulses each side's yaw per unit (the two together, the
left and right gains), a turn in place its rate and, read off the map
at the next leg, its coast, the body's state stream the settle after a
stop — and lays the median of the last 32 samples over the prior once
5 are in (the trim 10, a side 16), bounded about it (a bad session
cannot take the duck far). It is kept beside the map,
/var/lib/quack-nav/gait-ledger.json, per daemon line and profile, so
every run starts where the last one got and a new line of Pollen's
starts from its own priors; and per state of the body, read from
robotd — a mouth commanded shut and held open by what is in it is a
duck carrying, with a walk of its own that starts from the free one's
numbers and learns on half the samples. robot.gait reports it and
forgets it on request; robot.map_status carries it as gait;
QK_GAIT_LEDGER=0 leaves the priors alone
(docs/control-contract.md, the module
quack-nav/src/ledger.rs).
quack-nav needs to know which walking policy robotd runs,
and [gait] profile says it. Pollen's ducks ship with velstand (one
network that walks and stands; policy set v5 and on). alpha
(alpha_walking + alpha_stand) is still in the set, and every number
before 2026-10-08 was measured on it; to run it, on the duck:
robotctl policy load walk alpha_walking.onnx
robotctl policy load stand alpha_stand.onnxand profile = "alpha" here (velstand is the default when [gait] is
left out). Both
are validated on the MuJoCo twin at daemon-v0.16.1: 48 of 48 journeys
with velstand, 47 of 48 with alpha, the pose within 6–7 cm (median) —
docs/results.md, "daemon 0.16.1 and velstand". The
profile carries the walk's numbers (speed, turn rate, how long the body
takes to settle after a stop); a profile never changes the other's.
Checking it: systemctl status quack-navd, journalctl -u quack-navd -f, and the nc -U call under Installing on the duck.
Upgrading: the newer release's package, verified and unpacked, its
./install-on-duck.sh the same way; the config, the maps and the places
stay. Uninstalling: the commands under
Installing on the duck.
No build needed: CI cross-builds quack-navd for the board on every push
(the aarch64 job's artifact quack-navd-aarch64-linux: the bare binary
and the install package, each with its sha256, packed by
scripts/package.sh <version> <binary> <outdir>), and every v* tag
attaches them to the
GitHub release
(Installing from a release; the bare
quack-navd-aarch64-linux stays, to copy to /usr/local/bin/quack-navd).
To build it yourself:
The duck's board is a Radxa Zero 3 (RK3566, aarch64) running Armbian
26.2.x with the Debian 13 (Trixie) userland, glibc 2.41. An Apple-silicon
Mac shares the CPU but not the OS, so quack-navd is cross-built for
aarch64-unknown-linux-gnu with
cargo-zigbuild: zig cc
is the cross linker and brings the glibc stubs, no Docker needed. The glibc
floor is pinned at 2.31 — what microduck's own cargo board pins — so the
binary loads on the board whatever glibc the build host has.
# once, on a Mac (Homebrew's rustup is keg-only and leaves its `rust` alone)
brew install rustup zig cargo-zigbuild
/opt/homebrew/opt/rustup/bin/rustup toolchain install stable --profile minimal \
--target aarch64-unknown-linux-gnu
# every build
scripts/cross-build.shThe script finds rustup's toolchain, runs
cargo zigbuild --release -p quack-nav --bin quack-navd --target aarch64-unknown-linux-gnu.2.31,
and checks the result:
target/aarch64-unknown-linux-gnu/release/quack-navd: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 2.0.0, stripped
glibc required: GLIBC_2.30
Zig's linker prints one harmless warning (ignoring deprecated linker optimization setting '1'). The binary was run in a debian:trixie
arm64 container (glibc 2.41): it starts, binds both sockets and waits for
robotd and tofd. On Linux the same script works (rustup target add aarch64-unknown-linux-gnu, zig from the distribution or pip install ziglang, cargo install cargo-zigbuild); alternatives are
cross with Docker or Podman
(cross build --release -p quack-nav --bin quack-navd --target aarch64-unknown-linux-gnu), or a plain cargo build --release -p quack-nav --bin quack-navd on any aarch64 Linux machine, the board included (slow
there: four Cortex-A55 cores).
The board must be provisioned by microduck first: robotd and tofd running,
and the robot group their sockets belong to. Everything quack-nav adds:
| on the duck | from this repo |
|---|---|
/usr/local/bin/quack-navd |
the cross-built binary |
/etc/systemd/system/quack-navd.service |
quack-nav/systemd/quack-navd.service |
/etc/sysusers.d/quack-nav.conf (user quacknav) |
quack-nav/systemd/sysusers.d/quack-nav.conf |
/etc/robot/quack-nav.toml |
quack-nav/quack-nav.example.toml (the config above) |
/run/quack-nav/{nav,map}.sock |
created by the unit (RuntimeDirectory=), mode 0660, group robot |
/var/lib/quack-nav/ (places, sessions, maps/) |
created by the unit (StateDirectory=), owned by quacknav |
/var/lib/quack-nav/knobs.env (optional) |
the knobs, written by nav.knobs (docs/control-contract.md), read by the unit at every start |
/var/lib/quack-nav/gait-ledger.json |
the gait ledger: what the duck learned of its own walk, kept between runs (robot.gait, docs/control-contract.md) |
The unit runs the daemon as quacknav with robot as a supplementary
group (it reaches robotd's and tofd's 0660 sockets, and hands its own two
to robot), at nice 5, under 320 MB, with the filesystem read-only but
for its state directory.
One command from the dev machine, after scripts/cross-build.sh:
scripts/install-on-duck.sh microduck@192.168.1.42microduck is the board image's account (Pollen's docs and duckctl
since 2026-10-01; older images had radxa).
It copies the four files over scp, then with sudo on the duck
installs the binary, the unit and the account, installs the config only if
/etc/robot/quack-nav.toml is absent (an edited one is kept), copies an
old /var/lib/quacksat/places.json when /var/lib/quack-nav/ has none,
and enables and restarts the service, printing every command it runs. Run
again, it is the upgrade. SSH_OPTS="-p 2222" passes options to ssh and
scp; --dry-run prints every command, the script it would run on the duck
included, and connects to nothing; a second argument installs another
binary (a path from where you run it). From a checkout it takes the files
from the repository, from an unpacked release package the ones next to it
(it looks for bin/quack-navd beside itself). By hand, the same:
# on the dev machine
scp target/aarch64-unknown-linux-gnu/release/quack-navd \
quack-nav/systemd/quack-navd.service quack-nav/systemd/sysusers.d/quack-nav.conf \
quack-nav/quack-nav.example.toml microduck@192.168.1.42:/tmp/
# on the duck
sudo install -m 755 /tmp/quack-navd /usr/local/bin/quack-navd
sudo install -m 644 /tmp/quack-navd.service /etc/systemd/system/quack-navd.service
sudo install -m 644 /tmp/quack-nav.conf /etc/sysusers.d/quack-nav.conf
sudo systemd-sysusers /etc/sysusers.d/quack-nav.conf
sudo install -D -m 644 /tmp/quack-nav.example.toml /etc/robot/quack-nav.toml # first time only
sudo systemctl daemon-reload && sudo systemctl enable --now quack-navdChecking it:
systemctl status quack-navd
journalctl -u quack-navd -f
# from a user in the `robot` group (or with sudo)
printf '{"jsonrpc":"2.0","id":1,"method":"nav.call","params":{"name":"robot.where_am_i","args":{}}}\n' \
| nc -U -q1 /run/quack-nav/nav.sockUpgrading: scripts/install-on-duck.sh again, or by hand
sudo systemctl stop quack-navd, sudo install -m 755 /tmp/quack-navd /usr/local/bin/quack-navd, sudo systemctl start quack-navd (stopping
saves the mapping session first). An old places registry: 0.1.0 kept
it in /var/lib/quacksat/places.json, which is no longer read —
sudo install -D -o quacknav -g quacknav -m 644 /var/lib/quacksat/places.json /var/lib/quack-nav/places.json, then restart. Uninstalling:
sudo systemctl disable --now quack-navd
sudo rm /usr/local/bin/quack-navd /etc/systemd/system/quack-navd.service /etc/sysusers.d/quack-nav.conf
sudo systemctl daemon-reload
# kept on purpose: the config, /etc/robot/quack-nav.toml, and the maps and
# places, /var/lib/quack-nav/ — remove them (and `sudo userdel quacknav`)
# only to forget the houseThe script, the unit and the uninstall were run in a Debian 13 arm64
container booted with systemd, with sshd, a radxa sudoer and a robot
group (systemd-analyze verify passes; the sockets come up 0660
quacknav:robot; nc -U gets an answer from a user in robot), from a
checkout and, since 2026-10-03, from an unpacked package with no checkout
(install, upgrade, the old places copied, uninstall). Not yet on a real
board.
A page on the home network that shows the live map and drives the duck —
tap to go, stop, places, explore, the knobs — is
quack-control, a repository of its own (decided 2026-10-01; see
docs/study/map-app.md, "Decision 2026-10-01"). It
runs on the duck beside quack-navd and talks to its two sockets; this
repository only keeps the contract it consumes, open to any other client:
docs/control-contract.md — the sockets,
nav.catalog and nav.call, the map stream, nav.knobs (the knobs'
env file, /var/lib/quack-nav/knobs.env, which the unit reads at every
start) and nav.restart (save the session and exit, for systemd to start
the daemon again with them).
Said plainly, so nobody has to find it out: this is a rigorously measured prototype, not a navigation stack to the standards of the field.
- The explorer is an accumulation of rules. Each one — the passage law beside a drop, off the rim first, the no-go spots, blind and guarded legs, lanes, trusted floor — came from a fall or a stall measured on the twin, and the reasons are in the code and the ADRs. Together they are hard to reason about, and their thresholds were tuned on three simulated houses (two of them generated): they may be fitted to the twin.
- The code shows it.
explore/mod.rsis some 2,500 lines; 47QK_*environment knobs (and 19MAPLOC_*, all listed indocs/knobs.md, generated from the code); legs areserde_json::Values; recovery decides on error messages (the homecoming'swhy.contains("° right")), which a reworded sentence breaks. - Localization is thresholds, not confidence. The standard (AMCL, SLAM Toolbox, Cartographer) carries a covariance; here a pose is trusted or not. maploc's valley test is an empirical stand-in for a scan matcher's degeneracy analysis.
- Planning is not layered. Nav2 has a global planner, a local controller, a layered costmap (obstacles, inflation, keep-out) and recoveries in a behaviour tree. Here: Dijkstra, a string pulled taut, stop-and-go legs, and recoveries spread through the explorer. The drop book is a costmap layer in all but name.
- Tests. 223 tests passing and 1 ignored (2026-10-26). The paper twin runs in CI as a
gate on fixed seeds (explore 40 × 1200 s,
go_to30;.github/workflows/ci.yml,scripts/ci/paper_twin_gate.py); beyond it, behaviour is verified by hours-long, non-deterministic runs on the MuJoCo twin. - Simulation only. The real sensor, floor and gait will move many of the numbers.
Part of it is the duck's: an 8×8 time-of-flight sensor with a 45° view, a gait that does not turn below a speed, mapping only while standing still, no ROS on board — Nav2 as it is would not run here. The direction is to keep the behaviour and put it in the field's shapes:
- typed errors instead of matched strings;
- the explorer as a state machine (or a behaviour tree) of small, tested parts;
- drops, lanes and no-go spots as costmap layers;
- the pose's confidence as a measure (the scan match's information matrix), not a yes or no;
- the paper twin in CI, with the release criteria of
docs/results.mdas its gate (the gate runs; its bars are fixed-seed numbers, not those criteria); - the physical duck.
Measured on the MuJoCo twin (microduck_rl + robotd); the physical duck
arrives in December 2026. Two ways to run it:
- Released robotd (daemon-v0.17.6, or v0.16.1) with
[maploc] enabled: the mapper inquack-navd. This is the released configuration; the numbers indocs/results.mdwere measured on daemon-v0.14.4 up to v0.17.6, each section saying which. - A robotd that hosts maploc — upstream PR 127, still open, plus the
map library of
docs/study/upstream-asks.md§5, which lives on a fork ofpollen-robotics/microduck— with[maploc]off.
main runs against both of Pollen's daemon lines — daemon-v0.17.6 (API
44, policy set v8) and daemon-v0.16.1 (API 41); the daemon key names
one or leaves it to robotd — validated on the twin on each with both
walks (docs/results.md: four rounds per house, no
regression in reach, falls or wake-ups; the 0.17 line slower by set v8's
walk). Its additions are optional on the wire, so the same quack-navd
runs against a board still on daemon-v0.14.4.
Done since 2026-10-20 (branch pilot-why, in main since
2026-10-26): the pilot on the 0.17 line taken apart difference by
difference on the twin; the simulator's sensor and houses made the
twin's; pilot v8l-s3 shipped for velstand on that line (48/48 on the
twin against frozen main's stick, 4 s a journey faster, no fall);
people walking between the rooms on the twin and on the bench alike
(casa_gente); a survey of the field's techniques against ours; the look
at the floor before a step onto what the map does not know (the sensor's
blind zone under 0.4–0.6 m was behind every reckless fall; 0 in 25 200
journeys with it, merged 2026-10-27); the turn in place's overshoot,
which was robotd holding the last command to its deadman, closed with a
stop at every turn's end (the turns 12–16° past what was asked instead
of 43–44°, merged 2026-10-27). Remaining, in order: the walking
leg's own hold to the deadman, measured and modelled; a gait ledger (the
walk's numbers learned on the duck's own legs, kept beside the map); a
bounded exploration ("explore 2×3"); a pilot trained with people in the
houses; things under 12 cm met walking, measured on the twin; the
survey's first recommendations; then the first session on the physical
duck. The dated
detail is the status at the top of docs/todo-map.md;
the measurements are in docs/results.md ("Round two
on the 0.17 line") and the comparison with the field's established
techniques in docs/study/nav-survey.md.
The first session on the physical duck — safety, install, measurements, staged tests and what to bring back for the bench — is a checklist: docs/first-duck-session.md.
