Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,25 @@ Milestone Ms of `docs/design/fleet.md`: how the two **non-robot** machines are b

Everything that is only meaningful relative to one mapped place — the Nav2 map pair, the slam_toolbox posegraph, and named zones — lives together as a **site bundle** under `~/.mote/sites/<site>/floors/<floor>/`, managed by `mote_bringup/sites.py` (CLI: `pixi run site`, docs in the module docstring). A floor is one SLAM session (one map frame); a site groups floors sharing a location. `~/.mote/active.yaml` selects the active site/floor per robot; launch files resolve the map (`nav2_launch.py`, `robot_launch.py`) and zones (`tasks_launch.py`) from it at launch time (zones fall back to the committed default). `MOTE_HOME` overrides `~/.mote` for tests/experiments. What a revision must *contain* — and how it validates, packs and travels — is `mote_bringup/bundle.py` (ROS-free, shared with the fleet server; see the map registry section above). Map artifacts are immutable **revisions** under `floors/<floor>/maps/<rev>/`, published by atomically flipping the `floors/<floor>/map` symlink once the revision is complete — a half-written save or interrupted transfer is never visible, and `site use-map <rev>` rolls back. `save-map` stores the posegraph alongside the map so mapping can be *continued* in the same frame later (extend, don't remap — remapping breaks zone coordinates). Mapping runs also record the `mapping` rosbag stream by default (`mapping_launch.py record:=true`; the sim passes false), and `save-map` stamps the session's bag into the revision's `meta.yaml` for provenance (`site info` shows it). Zones get their geometry three ways, and only the first needs a robot: `pixi run save-zone <name> [--note TEXT]` captures the pose the robot is standing at (the one way that also measures an approach heading), `segment-map` reads room outlines off a saved map, and the fleet dashboard's zone editor places and drags them on a candidate revision. A zone is a named pose (a fetch waypoint or a `goto <zone>` target) that may optionally carry an area **footprint** — a taught `--radius` circle, or a `polygon` outline that follows the actual room walls — so it reads as a room and answers "am I in it"; `site info` shows the zone/footprint counts and how many names the binding carries no geometry for. A floor's zones are **two files, not one** (zone/v0): `vocabulary.yaml` holds what the places are called and `binding.yaml` holds where geometry says they are — stored apart because only the names are portable off the map frame they were measured in; the binding travels inside the revision that names that frame. A legacy combined `zones.yaml` is still read and is migrated the first time anything writes. See "Fleet: the zone vocabulary/binding split" and "Fleet: zones are place-names". Maps are saved as PNG (map_server reads it natively; browsers can render it directly). `save-map` automatically runs an FFT structure-extraction **cleaning pass** (`mote_bringup/map_cleanup`, `sites._promote_cleaned`): it keeps the untouched map_saver output as `map_raw.png` and promotes the decluttered image to the served `map.png` (plus a `diagnostics.png`), so navigation always consumes the cleaned map while the raw is retained for provenance/audit. The `map.yaml` frame is identical for both, so zones/localization are unaffected; a cleaning failure falls back to serving the raw. The posegraph belongs to the raw map — mapping continuation extends from raw, never the cleaned image. **Zones no longer have to be taught one at a time**: `pixi run segment-map` (`map_cleanup/room_segmentation.py`, the ROSE² second stage the declutter pass left open) carves a saved map's free space into rooms and proposes one polygon zone per room, `--write` merging them into the floor's zones for the operator to rename — additive over zones already bound (a candidate covering an already-footprinted zone is dropped as named, so re-running is a byte-identical no-op) and written at floor level, never into the immutable map revision. A proposed room is anchored `derived`, not `taught`: it was read off a map by an algorithm, which is what tells an operator later that a re-map invalidates it. The method is one physical assumption — a doorway is narrow — applied to a grid the wall lines cut into faces: faces merge wherever their shared boundary has a clear span wider than a door, so it is indifferent to room size where a distance-transform threshold is not. Two consequences: a **corridor network is not proposed at all** (a footprint is a single outline, so a region encircling a block of rooms would claim them; those are dropped, taking with them any room wrongly absorbed into the corridor), and the geometry is **Manhattan after rotation** — an arbitrarily rotated map frame is fine, a building with wings at 30° to each other is not. Scored against ground-truth room rectangles on the sim ladder by `pixi run segment-eval` (30/33 mapped hospital rooms, 10/10 office, 1/1 mote, **zero merges**, unchanged with the map turned 17° or -31°); results in `docs/tuning/2026-07-27-room-segmentation.md`.

## Mapping: the pipeline (design)

`docs/design/mapping-pipeline.md` — **mapping as a build process**, written
from the 2026-08-02 flat-mapping session, where the best map this project has
had required leaving the paved road at every stage. The bag is the source, the
map revision is a build artifact, the parameters are the toolchain, and
promotion is a release; the robot's live map is scaffolding for navigation
during capture and is never the deliverable. Four stages on three machines:
capture (robot) → build (off-board: solve, align, declutter, segment, carry
zones forward, validate and score, package) → review (operator, dashboard) →
distribute (M4, unchanged). Two rules do most of the work. **The live config is
never deliberately hobbled** — optimisation beyond best-known-good belongs in
the build, where the output is scored and reviewed before it can reach a robot.
And **a build gates on what it can measure and reports the rest**: an assertion
the measurement cannot see is worse than no assertion, because it is believed.
It builds on the site bundle (Sites, above), the M4 registry, and the
declutter/segmentation passes, and it moves none of them. The work breakdown at
the end of the doc names its Voro task per item and is the state of the work.

## Drive path (who gets the wheels)

`DiffDriveController` has exactly one publisher: **`twist_mux`**, started with the base by `twist_mux_launch.py` (included by `mote_launch.py` and, so the sim base matches, by `sim_launch.py`). Nav2's `controller_server`/`behavior_server` publish `/cmd_vel_nav` (priority 10), everything a human drives with publishes `/cmd_vel_teleop_stamped` (priority 100) — `twist_relay` for the Foxglove panel, `pixi run teleop`, the RViz teleop panel in `mote.rviz`, and `pixi run explore` (it stands in for a human driver) — and the mux forwards one of them to `/diff_drive_controller/cmd_vel`, whose name is deliberately unchanged so bags, the benchmark and the sim smoke test still watch the command the wheels got. Table in `config/twist_mux.yaml`; rationale and measurements in `mote_bringup/README.md` "Drive path". It is **adopted, not built** (`ros-jazzy-twist-mux` 4.5.0, which supports `TwistStamped` via a `use_stamped` that defaults true — the parameter is not declared by the node, so setting it in the launch would be a no-op that read like a setting): a first-party mux would have been a Python node in a 20 Hz path, the thing #73 just removed from a 50 Hz one. Three decisions are load-bearing. **Teleop overrides Nav2, it does not cancel it** — cancelling from the drive path would wire velocity arbitration into the action layer, and a nudge to straighten the robot in a doorway would destroy a fetch mission. **The teleop input's timeout (1.0 s) is deliberately longer than the controller's `cmd_vel_timeout` (0.5 s)**, so after the operator's last command the wheels halt before Nav2 regains the topic: a takeover always ends with a stopped robot, never a handback mid-motion (measured 1.00–1.05 s of silence; pre-emption itself ~50 ms). Invert those two numbers and the property vanishes silently, so `test_twist_mux.py` holds the files together and `test_twist_mux_arbitration.py` measures a real mux. **The deadman is unchanged**: twist_mux publishes only from an input callback, with no timer and no stored last command, so every source stopping still means the drive topic stopping — asserted, not assumed, because a mux that re-published would turn "the link dropped" into "the robot keeps going". To hold autonomy off entirely there is a twist_mux **lock** on `/pause_navigation` (`std_msgs/Bool`, priority 50 — masks navigation, not teleop; a Publish panel in the shipped layout sends it), with one consequence worth knowing: a goal held off the wheels while the robot stands still fails Nav2's own `SimpleProgressChecker` after ~10 s, so a long pause ends the task. Cost is one process and **one DDS participant**, putting the robot stack at ~26 of 33. There is still **no `cancel` command in the task layer** — `task_server` accepts only `fetch`/`goto` — so "cancel the task first" was never actually possible; the pause lock is what an operator has.
Expand Down
Loading