diff --git a/.gitignore b/.gitignore index ac6c9d4..9179724 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ benchmark_results/ bag_replay_results/ # Parameter-sweep outputs (timestamped rankings/reports/merged params) sweep_results/ +# Map-build outputs (a candidate revision, its bundle and its build report) +map_build_results/ # Rendered cloud-init (`pixi run provision`): carries a Tailscale auth key and a # wifi passphrase, so it must never be committed even by accident. diff --git a/CLAUDE.md b/CLAUDE.md index 4048191..beb9c83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ pixi run save-map # Save map + slam posegraph into the active site floor pixi run publish-map # Offer that map to the fleet registry as a candidate (M4) pixi run save-zone # Teach a zone: capture current robot pose (+ optional --radius) into the site pixi run segment-map # Propose a zone per room of a saved map (--write merges into zones.yaml) +pixi run map-build # Mapping bag -> reviewable map candidate (pipeline stage 2) pixi run site # Site CLI: create / add-floor / use / use-map / list / info pixi run teleop # Keyboard teleoperation pixi run explore # Autonomous mapping coverage (run beside `pixi run mapping`, on the Pi) @@ -540,9 +541,67 @@ describes the seven-field vocabulary; a successor revision there is outstanding. Milestone Ms of `docs/design/fleet.md`: how the two **non-robot** machines are built and updated, runbook in `docs/fleet/server-pipelines.md`, measurements in `docs/fleet/ms-verification.md`. Both are container deploys **driven by their operator, not by the fleet server** — a robot is fleet-managed, a server is infrastructure — and the pipelines differ in exactly one thing, state. **The fleet server** (`mote_fleet/deploy/`: `Dockerfile` + `docker-compose.yml` + `fleet-deploy.sh`, image `ghcr.io/clachdev/mote-fleet` built by `.github/workflows/fleet-image.yml`) is two containers — mosquitto mounting **`server/mosquitto.conf`, the same file `fleet-broker` uses**, so deployed and workstation brokers cannot drift, with the **image tag pinned once** in the compose file and read back by `broker.sh` so they cannot drift on the binary either (a *minor* series, `2.1-alpine`, not the floating `:2`: 2.0 links libwebsockets and 2.1 implements websockets natively, so `:2` moving again could take the dashboard's read path with it, and the broker healthcheck probes 9001 as well as 1883 because mosquitto stays up and healthy-looking when only the websockets listener fails to open), and a python image carrying the API, the registry and the M3 dashboard — plus two named volumes holding the only state that matters: `/var/lib/mote-fleet` (registry.db + the `sites/` bundles the dashboard's basemaps come from) and the broker's retained messages. `.env` is the declared state; `BROKER_HOST` is the one value that must be right (handed to robots verbatim at enrollment, so the compose file refuses to start without it) and `BROKER_WS_PORT` is published *and* passed as `--broker-ws-port`, because it is the port the browser is told to reach the broker on. It holds state, so its update is a **gated recreate**, not blue/green: `fleet-deploy.sh update` tags the running image `:previous` before pulling, recreates, health-gates on `/healthz` over the *published* port, and puts the old image back automatically if that fails; `backup`/`restore` snapshot both volumes (registry via sqlite3's online backup API, not `cp`). **The inference server** (`mote_perception/deploy/inference-deploy.sh`, one file curled onto the host) is stateless, so it *is* blue/green: the candidate runs on shadow ports 5611/5612 while the current one keeps serving, and must pass `mote_perception/tools/probe.py` — health **and a real synthetic frame**, because a health sentinel is answered before the model has ever loaded and cannot see a broken weight download or a CUDA mismatch. The **flip is a stop-then-start**, deliberately: pushing a new port out to robots (as the design sketch had it) means editing `perception.yaml` on every robot, a worse outage than the seconds this costs, which the robot's warn-and-skip fallback makes a non-event. Every check runs *inside the image being deployed*, so the GPU box still installs nothing. `mote_perception/deploy/test/drill.sh` (`pixi run deploy-test`) exercises that whole pipeline with stub images on any machine with docker — no GPU — and `mote_fleet/test/test_fleet_outage.py` is the other half of the milestone's acceptance: kill the broker under a live agent and the robot still finishes its task, then the agent reconnects by itself. +## The mapping pipeline: a map is a build artifact + +`docs/design/mapping-pipeline.md`. **Capture produces a bag. A build produces a +map. A human review promotes it.** The robot's live map is scaffolding for +navigation during capture and is never the deliverable — the 2026-08-02 flat +session produced this project's best map and needed roughly a dozen manual +judgment calls to do it, none of them on the paved road. **`pixi run map-build` +is stage 2 of that pipeline as one command** (`mote_simulation/tools/map_build/`, +runbook in its README, acceptance in `docs/tuning/2026-09-01-map-build.md`): +solve → assemble → declutter → segment → validate + score → package, from a +recorded mapping bag to a validated candidate revision, a packed bundle and a +build report, with no robot and no live SLAM session. It lives beside +`bag_replay` in `mote_simulation` for that harness's reason — workstation-only, +excluded from the robot sync — and calls into it rather than re-implementing +any of the DDS isolation, stack launch, acceptance-chain feed or teardown. +On the 2026-08-02 bag it reproduces the hand-built map's loop drift (0.0992 m +against 0.098) in **21 s of solve for a 21-minute bag**, which is the economy +the whole design rests on. + +Four things are load-bearing. **The declutter step is `sites.promote_cleaned` +itself, not a copy**, so a built map and a saved one are comparable; that is +also why those two helpers in `sites.py` stopped being private. **A solve that +serialized no posegraph fails the build** rather than warning: a revision +without one navigates and cannot be extended, and the frame — with every zone +taught in it — is gone. **Hard gates stop the build, soft ones ride on the +candidate**: `bundle.validate` errors mean nothing is emitted, while a metric +regression against `--baseline` is printed for the reviewer. And **both sides of +that diff are read back from a revision's own served `map.png`** at the +thresholds its own `map.yaml` declares — the replay leg's metrics describe the +*raw* solve, so scoring the candidate from the leg and the baseline from disk +compares two different artifacts and reported the candidate's speckle as five +times the baseline's when the served maps agree to a thousandth. For the same +reason `unknown_frac` is not a diffed row (it is a fraction of the grid, so a +4-px-wider canvas reads worse while covering more floor) and neither is +`angular_support_deg` (confounded by coverage). Two smaller ones: the map pair +is written with `free_thresh` 0.100 rather than `map_saver`'s 0.196, which is +the unknown shade's own value to five decimals and decides "unknown" against +"free space the planner may drive through" on a rounding; and the replay +harness now records the grid's **origin yaw**, because a build assembling a +`map.yaml` must not assume it — a dropped origin yaw moves every zone on the +floor and leaves the map looking perfectly good. + +**Three of the design's steps are deliberately not in it, and each is reported +on the candidate rather than left silent.** *Alignment* (measure the wall +rotation, re-solve with it injected, keep the better map) needs an estimator +that can say which map is better, and the one in the tree called four of the +seven banked 2026-08-02 solves square when they were 3.5–5.6° off; a re-solve +is not a rigid rotation either, so the step is **undecidable** rather than +merely ungated (task 615, `docs/tuning/2026-09-01-alignment-residual.md`). +Birth-alignment is therefore an operator's `--frame X Y YAW`, recorded in the +revision's meta. *Vocabulary carry-forward* is task 345: the build emits the +segmenter's `room_NN` placeholders and reports what the baseline floor's places +were called. *Upload* needs a build identity (task 344), since the registry +accepts candidate uploads only from enrolled robots — so the bundle is emitted +locally and the report says what will send it. Every revision's `meta.yaml` +names the exact inputs (bag sha256 over bytes *and* file names, params sha256, +frame, feed, harness commit), so any candidate is reproducible. + ## Sites (maps & zones) -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//floors//`, 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//maps//`, published by atomically flipping the `floors//map` symlink once the revision is complete — a half-written save or interrupted transfer is never visible, and `site use-map ` 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 are taught by driving there and running `pixi run save-zone [--note TEXT]`, not by editing YAML; a zone is a named pose (a fetch waypoint or a `goto ` 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 this robot has *not* been taught. A floor's zones are **two files, not one** (zone/v0): `vocabulary.yaml` holds what the places are called and `binding.yaml` holds where this robot believes they are — taught together, stored apart, because only the names are portable off this robot. 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 hand-taught zones (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`. +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//floors//`, 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//maps//`, published by atomically flipping the `floors//map` symlink once the revision is complete — a half-written save or interrupted transfer is never visible, and `site use-map ` 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 are taught by driving there and running `pixi run save-zone [--note TEXT]`, not by editing YAML; a zone is a named pose (a fetch waypoint or a `goto ` 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 this robot has *not* been taught. A floor's zones are **two files, not one** (zone/v0): `vocabulary.yaml` holds what the places are called and `binding.yaml` holds where this robot believes they are — taught together, stored apart, because only the names are portable off this robot. 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 hand-taught zones (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`. ## Drive path (who gets the wheels) diff --git a/docs/design/mapping-pipeline.md b/docs/design/mapping-pipeline.md index ab5e9c7..4838f8a 100644 --- a/docs/design/mapping-pipeline.md +++ b/docs/design/mapping-pipeline.md @@ -274,10 +274,17 @@ Sized so each is one dispatchable task; existing tasks noted. `slam_toolbox_build_params.yaml` beside the live file, one divergent key, held to it by `test_slam_build_params.py`. It also records what was measured and rejected, so the same sweep is not run twice. -4. **`map-build` orchestrator** — the stage-2 chain as one command on the - fleet box, emitting a validated candidate + build report. Depends on the - lockstep harness landing (task 295 / PR 91), prominence picking (337), and - item 10 for the alignment step's gate. +4. **`map-build` orchestrator** — landed, less its alignment step: + `mote_simulation/tools/map_build/`, `pixi run map-build`. Solve, assemble, + declutter, segment, validate, score against a baseline, package — one + command, and on the 2026-08-02 bag it reproduces the hand-built map's loop + drift to a millimetre in 21 s of solve + (`docs/tuning/2026-09-01-map-build.md`). **Step 2 is not in it**: with no + estimator that can say which of two solves is squarer, the step is + undecidable rather than merely ungated, so birth-alignment stays an + operator's `--frame X Y YAW` — recorded in the revision's meta — until item + 10 lands. Steps 5 and 7's upload wait on items 6 and 5 respectively; both + gaps are reported on the candidate rather than left silent. 5. **Build identity** — the registry accepts candidate uploads from a credentialed builder, not only enrolled robots; audit rows name it. 6. **Vocabulary carry-forward** — same-frame rebinding by containment + diff --git a/docs/tuning/2026-09-01-map-build.md b/docs/tuning/2026-09-01-map-build.md new file mode 100644 index 0000000..5790318 --- /dev/null +++ b/docs/tuning/2026-09-01-map-build.md @@ -0,0 +1,124 @@ +# `map-build` against the map it was written to reproduce + +The 2026-08-02 flat map was built by hand: a lockstep re-solve of the session's +bag under corrected parameters, a measured-then-injected frame rotation, a +declutter pass with a hand-tuned threshold, room segmentation, and a +hand-assembled revision side-loaded onto the robot. `pixi run map-build` is +that chain as one command. This is what it produces on the same bag. + +Everything below is in `2026-09-01-map-build/`: the build's own report, the map +it emitted, the map it is compared against, and `build.json`. + +## 0. The two artifacts + +| | promoted (by hand, 2026-08-02) | built (`map-build`, 2026-09-01) | +| --- | --- | --- | +| revision | `home/ground/20260802T203339` | `20260901T180343` | +| bag | `20260802_142539` | the same, sha256 `099f9d06…` | +| params | live file + chain 10 + `coarse_angle_resolution` 0.0175 | committed `slam_toolbox_build_params.yaml` (chain 10, `coarse_angle_resolution` 0.0349) | +| frame | `--frame 0 0 -3.0` | the same, passed on the command line | +| declutter | `peak_rel_threshold` 0.55, hand-tuned | the committed default, prominence-picked (task 337) | +| size | 236x181 @ 0.05 m/px | 240x182 @ 0.05 m/px | + +The two parameter differences are not slips. `coarse_angle_resolution` 0.0175 +was adopted in the hand build on the reasoning that the live 2.0° value snapped +solutions to an orientation lattice; there is no such lattice, and the finer +sweep beat the live value on nothing +(`2026-08-25-slam-build-params.md` §2), so the committed build file keeps the +live value. The hand-tuned declutter threshold is what task 337 replaced. + +## 1. What the build did + +``` +solve ok — 340 pose-graph nodes from 542 scans in 21 s +assemble ok — 240x182 @ 0.050 m/px, origin (-5.316, -5.211, 0.000) +declutter ok — -1244 cells, +277 +segment ok — 9 room zone(s) proposed +validate ok — valid +score ok — 1 metric worse than the baseline: map.speckle_frac +package ok — 7101101 bytes +``` + +**21 seconds of solve for a 21-minute bag**, unattended, from `--bag` to a +validated candidate and a build report. That is the economy the whole design +rests on, and it is the lockstep harness's (task 295), not this tool's. + +## 2. It reproduces the map + +| metric | built | promoted | verdict | +| --- | --- | --- | --- | +| loop drift (start↔end, m) | **0.0992** | 0.098 (recorded in the hand build's meta) | reproduced | +| drift ratio | 0.00071 | — | | +| mean wall thickness (m) | 0.0602 | 0.0607 | same | +| speckle fraction | 0.00364 | 0.00277 | worse | +| explored area (m²) | 63.3 | 62.8 | same | +| wall directions | 4 | 4 | same | +| second wall frame | 18.5° off dominant, 0.219 of energy | — | the flat's angled wing | + +Loop drift lands within a millimetre of the hand build's, which is the number +that matters: it is the only one of these that measures the *solve*. The +second orthogonal frame at 18.5° carrying about a fifth of the energy is the +flat's angled wing, exactly as the 2026-08-25 report found it. + +The speckle difference — 0.0009 of occupied cells — is the declutter +thresholds, not the solve: the hand build stripped more short structure at +`peak_rel_threshold` 0.55 than the committed prominence-picked default does. +It is reported as review evidence and gates nothing, which is what the design +asks for. + +Side by side, `map.png` and `promoted-map.png`: the same flat, the same +layout, the built one a degree or two rotated and holding slightly more +short-wall detail. + +## 3. Two ways the report was wrong before it was right + +Both were found by running the acceptance and reading the numbers, and both +would have told a reviewer to reject a good map. + +**The candidate was scored from the raw solve and the baseline from disk.** +The replay leg carries map metrics, but they describe the image *before* the +declutter pass, while a stored revision only ever keeps the cleaned one. The +first acceptance run reported the candidate's speckle as 0.0137 against the +baseline's 0.0028 — five times worse — when the two *served* maps are +0.0036 and 0.0028. Both sides now go through one function that reads a +revision's own `map.png` at the thresholds its own `map.yaml` declares. + +**`unknown_frac` moves with the bounding box.** It is a fraction of the grid, +so the candidate — 4 px wider than the baseline — read 2.2% worse on it while +covering 0.4 m² *more* floor. It is out of the diff table; +`explored_area_m2` carries the same signal in metres and does not depend on +the canvas. It is still in `build.json`, like everything `map_quality` +measures. + +The general rule both cases are instances of: a diff row is a claim that the +metric ranks one candidate against another. `angular_support_deg` was already +excluded on the same grounds (it is confounded by coverage). + +## 4. What the acceptance asked for and could not be checked + +The task's acceptance included "walls ≤ 0.5° off axis". That assertion cannot +be made: `2026-09-01-alignment-residual.md` established that the map has no +single wall grid to half a degree — thirds of this building disagree by 8°, and +there is a second family 18° off — and that the estimator in the tree reports +maps 3.5–5.6° out as square. The design was corrected accordingly (#111) and +the build measures wall structure and prints it rather than asserting a +residual. An orientation estimator the alignment step can be gated on is task +615. + +## 5. What the run also confirmed, and is not this tool's + +The nine room proposals are visibly tilted about 2° off the walls they enclose +(`rooms.png`) — task 349, `segment-map`'s wall-alignment estimator on a +multi-family building, reproduced here on the building it was reported against. + +## Reproducing this + +```bash +pixi run map-build -- \ + --bag ~/.mote/bags/mapping/20260802_142539 \ + --site home --floor ground --frame 0 0 -3.0 \ + --baseline +``` + +The bag is on the robot under `~/.mote/bags/mapping/`; the baseline revision is +that floor's current map. `build.json` records both digests. diff --git a/docs/tuning/2026-09-01-map-build/build-report.md b/docs/tuning/2026-09-01-map-build/build-report.md new file mode 100644 index 0000000..c26ef39 --- /dev/null +++ b/docs/tuning/2026-09-01-map-build/build-report.md @@ -0,0 +1,96 @@ +# Map build 20260901T180343 + +**candidate emitted** — `/home/michael/.claude/jobs/c1592bca/tmp/acceptance3/20260901T170343Z/revision/20260901T180343` — validated, packed as `20260901T180343.tar.gz` + +## Inputs + +| input | value | +|---|---| +| bag | /home/michael/.mote/bags/mapping/20260802_142539 | +| bag sha256 | 099f9d0608ec818c75a3c5792c13b7e5a4d58a9c5fcd8f005a43a40088750f40 | +| bag bytes | 191910513 | +| slam params | /home/michael/Projects/mote/.claude/worktrees/map-build-orchestrator/mote_bringup/config/slam_toolbox_build_params.yaml | +| params sha256 | 072929cf88c0147758b2a471546c7fac9a236b1ffef8d14c25981d54a3b4bb37 | +| frame injection (x, y, yaw°) | [0.0, 0.0, -3.0] | +| feed | lockstep | +| harness commit | 1c75eac | +| built (UTC) | 20260901T170343Z | + +## Stages + +| stage | outcome | detail | +|---|---|---| +| solve | ok | 340 pose-graph nodes from 542 scans in 21 s | +| assemble | ok | 240x182 @ 0.050 m/px, origin (-5.316, -5.211, 0.000) | +| declutter | ok | -1244 cells, +277, wall directions [11.2, 88.2, 110.2, 178.2] | +| segment | ok | 9 room zone(s) proposed | +| carry forward | stub | task 345 — names are reported, not rebound | +| validate | ok | valid | +| score | ok | 1 metric(s) worse than the baseline: map.speckle_frac | +| package | ok | 20260901T180343.tar.gz, 7101101 bytes, sha256:a1d6a4393b634f43… | + +## Validation + +`bundle.validate` — **valid** + +- no errors, no warnings + +## Metrics + +Baseline: `/home/michael/.claude/jobs/c1592bca/tmp/baseline/20260802T203339` + +These are **truth-free proxies**: the bag carries no ground truth, so a confidently wrong map can score well. Read them beside the map. + +The `map.*` rows are the map this revision **serves** — after the declutter pass — on both sides, because that is what a promotion publishes. The raw solve's are in `build.json` under `map_raw`. + +| metric | candidate | baseline | delta | vs baseline | reading | +|---|---|---|---|---|---| +| loop.start_end_dist_m | 0.09919 | — | — | — | lower is better — start↔end distance, if the run closed | +| loop.drift_ratio | 0.0007105 | — | — | — | lower is better — that distance over path length | +| map.mean_wall_thickness_m | 0.06015 | 0.0607 | -0.0005498 | same | lower is better — wall crispness; blur reads thicker | +| map.speckle_frac | 0.003637 | 0.002768 | 0.0008691 | worse | lower is better — isolated occupied cells | +| map.explored_area_m2 | 63.26 | 62.83 | 0.435 | same | higher is better — decided cells × cell area | + +A change under 2% reads as `same`: the solver is not bit-identical run to run. **Nothing here blocks** — a regression is evidence for the reviewer, not a gate. + +## Wall structure + +| frame | angle (deg) | directions | energy share | off dominant | +|---|---|---|---|---| +| 0 | 2.25 | 2 | 0.5785 | 0 | +| 1 | 73.75 | 2 | 0.219 | 18.5 | + +`angular_support_deg` 50.7, 4 wall direction(s), dominant frame share 0.5785. Support is **not** a quality ranking — a map that explored less has fewer long walls and reads as tighter. + +A rectilinear building puts every wall in one frame. A second frame carrying real energy with **two** directions in it means a section of the map is drawn on its own axes — a tear. A second frame with one direction is an angled hallway, which is architecture. + +The build does **not** align the map frame. Measuring a map's wall rotation well enough to gate a re-solve on it is task 615 (`docs/tuning/2026-09-01-alignment-residual.md`): the estimator in the tree called four maps square that were 3.5–5.6° out. Until it lands, birth-alignment is an operator's judgment, passed as `--frame X Y YAW`, and recorded above. + +## Zones + +Segmentation proposed 9 room(s): `room_01`, `room_02`, `room_03`, `room_04`, `room_05`, `room_06`, `room_07`, `room_08`, `room_09` + +**Not carried forward**: the baseline floor names 7 place(s) — `room_01`, `room_02`, `room_03`, `room_04`, `room_05`, `room_06`, `room_07`. Re-binding them onto this map's rooms is task 345; until it lands the reviewer renames the placeholders above in the dashboard's zone editor, which is where a name is edited on a candidate anyway. + +## Renders + +### Built map (served) + +![Built map (served)](map.png) + +### Raw solve + +![Raw solve](map_raw.png) + +### Declutter diagnostics + +![Declutter diagnostics](diagnostics.png) + +### Proposed rooms + +![Proposed rooms](rooms.png) + + +## Next + +Review the map above, then upload `20260901T180343.tar.gz` to the registry as a candidate for `home/ground`. The upload route accepts enrolled robots only, so a builder needs a credential of its own — that is task 344; until it lands, a robot at the site can side-load the revision directory into its floor and `pixi run publish-map --revision 20260901T180343`. Promotion is unchanged: an operator's audited call, in the dashboard or `fleetctl promote`. diff --git a/docs/tuning/2026-09-01-map-build/build.json b/docs/tuning/2026-09-01-map-build/build.json new file mode 100644 index 0000000..607d75f --- /dev/null +++ b/docs/tuning/2026-09-01-map-build/build.json @@ -0,0 +1,428 @@ +{ + "revision": "20260901T180343", + "built": "20260901T170343Z", + "out_dir": "/home/michael/.claude/jobs/c1592bca/tmp/acceptance3/20260901T170343Z", + "revision_dir": "/home/michael/.claude/jobs/c1592bca/tmp/acceptance3/20260901T170343Z/revision/20260901T180343", + "site": "home", + "floor": "ground", + "stages": [ + { + "name": "solve", + "outcome": "ok", + "detail": "340 pose-graph nodes from 542 scans in 21 s" + }, + { + "name": "assemble", + "outcome": "ok", + "detail": "240x182 @ 0.050 m/px, origin (-5.316, -5.211, 0.000)" + }, + { + "name": "declutter", + "outcome": "ok", + "detail": "-1244 cells, +277, wall directions [11.2, 88.2, 110.2, 178.2]" + }, + { + "name": "segment", + "outcome": "ok", + "detail": "9 room zone(s) proposed" + }, + { + "name": "carry forward", + "outcome": "stub", + "detail": "task 345 \u2014 names are reported, not rebound" + }, + { + "name": "validate", + "outcome": "ok", + "detail": "valid" + }, + { + "name": "score", + "outcome": "ok", + "detail": "1 metric(s) worse than the baseline: map.speckle_frac" + }, + { + "name": "package", + "outcome": "ok", + "detail": "20260901T180343.tar.gz, 7101101 bytes, sha256:a1d6a4393b634f43\u2026" + } + ], + "diff": [ + { + "metric": "loop.start_end_dist_m", + "gloss": "start\u2194end distance, if the run closed", + "better": "lower", + "candidate": 0.09918580889101973 + }, + { + "metric": "loop.drift_ratio", + "gloss": "that distance over path length", + "better": "lower", + "candidate": 0.0007104602496457169 + }, + { + "metric": "map.mean_wall_thickness_m", + "gloss": "wall crispness; blur reads thicker", + "better": "lower", + "candidate": 0.06015459230069719, + "baseline": 0.06070439864657029, + "delta": -0.0005498063458731009, + "verdict": "same", + "relative": -0.009057108844354957 + }, + { + "metric": "map.speckle_frac", + "gloss": "isolated occupied cells", + "better": "lower", + "candidate": 0.003637465898757199, + "baseline": 0.0027683789603199014, + "delta": 0.0008690869384372977, + "verdict": "worse", + "relative": 0.3139335152066283 + }, + { + "metric": "map.explored_area_m2", + "gloss": "decided cells \u00d7 cell area", + "better": "higher", + "candidate": 63.260000000000005, + "baseline": 62.825, + "delta": 0.4350000000000023, + "verdict": "same", + "relative": 0.006923995224830916 + } + ], + "images": [ + [ + "Built map (served)", + "map.png" + ], + [ + "Raw solve", + "map_raw.png" + ], + [ + "Declutter diagnostics", + "diagnostics.png" + ], + [ + "Proposed rooms", + "rooms.png" + ] + ], + "inputs": { + "bag": { + "name": "20260802_142539", + "sha256": "099f9d0608ec818c75a3c5792c13b7e5a4d58a9c5fcd8f005a43a40088750f40", + "files": [ + { + "name": "20260802_142539_0.mcap", + "bytes": 62008230, + "sha256": "67931d55e67654ebde39a3d9d22335dce0736fe85a44cae70be4309748d4e3a2" + }, + { + "name": "20260802_142539_1.mcap", + "bytes": 62415137, + "sha256": "7cdbb43a048a6206a2fa50e12ab838bfaa40582a965b0b7f6e051725cd281269" + }, + { + "name": "20260802_142539_2.mcap", + "bytes": 55809872, + "sha256": "609ff0d4d2f864149f747601407ece02c4272dab26ec75dd3c202159f17d35bf" + }, + { + "name": "20260802_142539_3.mcap", + "bytes": 11669945, + "sha256": "58ea3168d0eb4322c84319f0b3b6721c01ed8ff973936fcc5c076a433777435f" + }, + { + "name": "metadata.yaml", + "bytes": 7329, + "sha256": "8e675b8d0d09b2be28cf87e0bd11de37ea80f892f1f490b9db115e29d22fa92c" + } + ], + "path": "/home/michael/.mote/bags/mapping/20260802_142539" + }, + "params": { + "path": "/home/michael/Projects/mote/.claude/worktrees/map-build-orchestrator/mote_bringup/config/slam_toolbox_build_params.yaml", + "sha256": "072929cf88c0147758b2a471546c7fac9a236b1ffef8d14c25981d54a3b4bb37" + }, + "frame": [ + 0.0, + 0.0, + -3.0 + ], + "feed": "lockstep", + "harness_commit": "1c75eac" + }, + "zones": { + "added": [ + "room_01", + "room_02", + "room_03", + "room_04", + "room_05", + "room_06", + "room_07", + "room_08", + "room_09" + ], + "skipped": [], + "n_rooms": 9, + "overlay": "rooms.png", + "carry_forward": "**Not carried forward**: the baseline floor names 7 place(s) \u2014 `room_01`, `room_02`, `room_03`, `room_04`, `room_05`, `room_06`, `room_07`. Re-binding them onto this map's rooms is task 345; until it lands the reviewer renames the placeholders above in the dashboard's zone editor, which is where a name is edited on a candidate anyway." + }, + "validation": { + "summary": "valid", + "errors": [], + "warnings": [], + "occupancy": { + "total": 43680, + "free": 0.503777, + "occupied": 0.075527, + "unknown": 0.420696 + } + }, + "baseline": { + "path": "/home/michael/.claude/jobs/c1592bca/tmp/baseline/20260802T203339", + "metrics": { + "map": { + "n_cells": 42716, + "unknown_frac": 0.4116958516715048, + "free_frac": 0.5121968349096357, + "occ_frac": 0.07610731341885944, + "explored_area_m2": 62.825, + "mean_wall_thickness_m": 0.06070439864657029, + "speckle_frac": 0.0027683789603199014, + "angular_support_deg": 47.524686611496804, + "angular_entropy_norm": 0.7737539891235249, + "unassigned_energy_frac": 0.20204506969103694, + "n_peaks": 4, + "directions": [ + { + "angle_deg": 92.25, + "energy_frac": 0.26684065870019485, + "width_deg": 2.6600670365190853 + }, + { + "angle_deg": 160.25, + "energy_frac": 0.11935603677853039, + "width_deg": 2.371171686594371 + }, + { + "angle_deg": 179.25, + "energy_frac": 0.32560393495826956, + "width_deg": 2.5139618844536775 + }, + { + "angle_deg": 71.25, + "energy_frac": 0.08615429987196835, + "width_deg": 2.7076397806040102 + } + ], + "frames": [ + { + "angle_deg": 89.25, + "energy_frac": 0.5924445936584644, + "n_directions": 2, + "offset_from_dominant_deg": 0.0 + }, + { + "angle_deg": 70.25, + "energy_frac": 0.20551033665049873, + "n_directions": 2, + "offset_from_dominant_deg": 19.0 + } + ], + "n_frames": 2, + "n_strong_frames": 2, + "dominant_frame_share": 0.5924445936584644, + "manhattan_frame_deg": 85.15941303221811, + "manhattan_concentration": 0.8577708949791387, + "manhattan_share": 0.4530011508219582 + } + } + }, + "metrics": { + "n_scans": 542, + "n_inserted": 340, + "traj_samples": 340, + "loop": { + "n": 340, + "start_end_dist_m": 0.09918580889101973, + "path_length_m": 139.60782315475134, + "drift_ratio": 0.0007104602496457169, + "duration_s": 1855.0016474723816 + }, + "feed": "lockstep", + "traj_source": "pose", + "wall_s": 20.7, + "map": { + "n_cells": 43680, + "unknown_frac": 0.4206959706959707, + "free_frac": 0.5037774725274725, + "occ_frac": 0.07552655677655677, + "explored_area_m2": 63.260000000000005, + "mean_wall_thickness_m": 0.06015459230069719, + "speckle_frac": 0.003637465898757199, + "angular_support_deg": 50.70095965324051, + "angular_entropy_norm": 0.7847452227091799, + "unassigned_energy_frac": 0.2024568210587793, + "n_peaks": 4, + "directions": [ + { + "angle_deg": 92.75, + "energy_frac": 0.2823199735516087, + "width_deg": 2.7764656751564187 + }, + { + "angle_deg": 163.75, + "energy_frac": 0.12396016905685829, + "width_deg": 2.3252893831906696 + }, + { + "angle_deg": 2.25, + "energy_frac": 0.2961823802302831, + "width_deg": 2.5567636214272724 + }, + { + "angle_deg": 73.75, + "energy_frac": 0.09508065610247042, + "width_deg": 2.7053240626571378 + } + ], + "frames": [ + { + "angle_deg": 2.25, + "energy_frac": 0.5785023537818919, + "n_directions": 2, + "offset_from_dominant_deg": 0.0 + }, + { + "angle_deg": 73.75, + "energy_frac": 0.2190408251593287, + "n_directions": 2, + "offset_from_dominant_deg": 18.5 + } + ], + "n_frames": 2, + "n_strong_frames": 2, + "dominant_frame_share": 0.5785023537818919, + "manhattan_frame_deg": 87.23950484652163, + "manhattan_concentration": 0.8599850867715234, + "manhattan_share": 0.40904568114691275 + }, + "map_raw": { + "n_cells": 43680, + "unknown_frac": 0.42703754578754577, + "free_frac": 0.46895604395604396, + "occ_frac": 0.10400641025641026, + "explored_area_m2": 62.56750186465682, + "mean_wall_thickness_m": 0.06432973901713941, + "speckle_frac": 0.013647369579572969, + "angular_support_deg": 50.414003858733345, + "angular_entropy_norm": 0.7837809435125195, + "unassigned_energy_frac": 0.2397107336918075, + "n_peaks": 4, + "directions": [ + { + "angle_deg": 92.25, + "energy_frac": 0.278270610624543, + "width_deg": 2.6532849517899315 + }, + { + "angle_deg": 163.75, + "energy_frac": 0.1116209930937212, + "width_deg": 2.1794653675659705 + }, + { + "angle_deg": 2.25, + "energy_frac": 0.32153111819038543, + "width_deg": 2.6311468336443804 + }, + { + "angle_deg": 73.75, + "energy_frac": 0.04886654439954271, + "width_deg": 2.2758001071334455 + } + ], + "frames": [ + { + "angle_deg": 2.25, + "energy_frac": 0.5998017288149284, + "n_directions": 2, + "offset_from_dominant_deg": 0.0 + }, + { + "angle_deg": 73.75, + "energy_frac": 0.16048753749326392, + "n_directions": 2, + "offset_from_dominant_deg": 18.5 + } + ], + "n_frames": 2, + "n_strong_frames": 2, + "dominant_frame_share": 0.5998017288149284, + "manhattan_frame_deg": 88.71921407987656, + "manhattan_concentration": 0.867644982249046, + "manhattan_share": 0.49203160040381133 + } + }, + "angular": { + "n_cells": 43680, + "unknown_frac": 0.4206959706959707, + "free_frac": 0.5037774725274725, + "occ_frac": 0.07552655677655677, + "explored_area_m2": 63.260000000000005, + "mean_wall_thickness_m": 0.06015459230069719, + "speckle_frac": 0.003637465898757199, + "angular_support_deg": 50.70095965324051, + "angular_entropy_norm": 0.7847452227091799, + "unassigned_energy_frac": 0.2024568210587793, + "n_peaks": 4, + "directions": [ + { + "angle_deg": 92.75, + "energy_frac": 0.2823199735516087, + "width_deg": 2.7764656751564187 + }, + { + "angle_deg": 163.75, + "energy_frac": 0.12396016905685829, + "width_deg": 2.3252893831906696 + }, + { + "angle_deg": 2.25, + "energy_frac": 0.2961823802302831, + "width_deg": 2.5567636214272724 + }, + { + "angle_deg": 73.75, + "energy_frac": 0.09508065610247042, + "width_deg": 2.7053240626571378 + } + ], + "frames": [ + { + "angle_deg": 2.25, + "energy_frac": 0.5785023537818919, + "n_directions": 2, + "offset_from_dominant_deg": 0.0 + }, + { + "angle_deg": 73.75, + "energy_frac": 0.2190408251593287, + "n_directions": 2, + "offset_from_dominant_deg": 18.5 + } + ], + "n_frames": 2, + "n_strong_frames": 2, + "dominant_frame_share": 0.5785023537818919, + "manhattan_frame_deg": 87.23950484652163, + "manhattan_concentration": 0.8599850867715234, + "manhattan_share": 0.40904568114691275 + }, + "verdict": "candidate emitted", + "verdict_detail": "`/home/michael/.claude/jobs/c1592bca/tmp/acceptance3/20260901T170343Z/revision/20260901T180343` \u2014 validated, packed as `20260901T180343.tar.gz`", + "next": "Review the map above, then upload `20260901T180343.tar.gz` to the registry as a candidate for `home/ground`. The upload route accepts enrolled robots only, so a builder needs a credential of its own \u2014 that is task 344; until it lands, a robot at the site can side-load the revision directory into its floor and `pixi run publish-map --revision 20260901T180343`. Promotion is unchanged: an operator's audited call, in the dashboard or `fleetctl promote`." +} diff --git a/docs/tuning/2026-09-01-map-build/diagnostics.png b/docs/tuning/2026-09-01-map-build/diagnostics.png new file mode 100644 index 0000000..4cbf9cc Binary files /dev/null and b/docs/tuning/2026-09-01-map-build/diagnostics.png differ diff --git a/docs/tuning/2026-09-01-map-build/map.png b/docs/tuning/2026-09-01-map-build/map.png new file mode 100644 index 0000000..f981268 Binary files /dev/null and b/docs/tuning/2026-09-01-map-build/map.png differ diff --git a/docs/tuning/2026-09-01-map-build/map_raw.png b/docs/tuning/2026-09-01-map-build/map_raw.png new file mode 100644 index 0000000..adcac09 Binary files /dev/null and b/docs/tuning/2026-09-01-map-build/map_raw.png differ diff --git a/docs/tuning/2026-09-01-map-build/promoted-map.png b/docs/tuning/2026-09-01-map-build/promoted-map.png new file mode 100644 index 0000000..3f414c8 Binary files /dev/null and b/docs/tuning/2026-09-01-map-build/promoted-map.png differ diff --git a/docs/tuning/2026-09-01-map-build/rooms.png b/docs/tuning/2026-09-01-map-build/rooms.png new file mode 100644 index 0000000..020d82a Binary files /dev/null and b/docs/tuning/2026-09-01-map-build/rooms.png differ diff --git a/mote_bringup/config/slam_toolbox_build_params.yaml b/mote_bringup/config/slam_toolbox_build_params.yaml index c5fffc9..4b7c2de 100644 --- a/mote_bringup/config/slam_toolbox_build_params.yaml +++ b/mote_bringup/config/slam_toolbox_build_params.yaml @@ -20,8 +20,8 @@ # file is expected to stay close to the live one. Today it is one key away. # What was measured and *not* adopted is recorded at the foot of the file. # -# Nothing chains those steps yet: until the `map-build` orchestrator lands, -# this file is what a hand-run `bag-replay --params` should point at. +# `pixi run map-build` chains those steps and defaults to this file. A hand-run +# `bag-replay --params` still points at it for a one-off solve. slam_toolbox: ros__parameters: diff --git a/mote_bringup/mote_bringup/bundle.py b/mote_bringup/mote_bringup/bundle.py index e94038c..5c3971d 100644 --- a/mote_bringup/mote_bringup/bundle.py +++ b/mote_bringup/mote_bringup/bundle.py @@ -147,7 +147,7 @@ #: Trinary occupancy values as ``map_saver`` writes them: 0 occupied, 205 #: unknown, 254 free. Read with slack either side, because the cleaning pass -#: (sites._promote_cleaned) goes through cv2 and need not land exactly on them. +#: (sites.promote_cleaned) goes through cv2 and need not land exactly on them. OCCUPIED_MAX = 25 FREE_MIN = 230 @@ -777,7 +777,7 @@ def _validate_image(revision_dir: Path, report: Report): return # The raw and the cleaned map are the same frame with different pixels - # (sites._promote_cleaned), so a size that differs means one of them is + # (sites.promote_cleaned), so a size that differs means one of them is # not what it claims and every zone taught on this floor is suspect. raw = revision_dir / "map_raw.png" if raw.is_file(): diff --git a/mote_bringup/mote_bringup/map_cleanup/README.md b/mote_bringup/mote_bringup/map_cleanup/README.md index 0d65dfe..15368e9 100644 --- a/mote_bringup/mote_bringup/map_cleanup/README.md +++ b/mote_bringup/mote_bringup/map_cleanup/README.md @@ -117,7 +117,7 @@ results and overlays in `docs/tuning/2026-07-27-room-segmentation/`. - **Done:** FFT declutter core + CLI + diagnostics, validated on a real noisy mote map (see `scratchpad_results/map_cleanup/`). - **Done:** wired into `save-map` as an automatic post-processing pass - (`sites._promote_cleaned`): every saved revision keeps the untouched + (`sites.promote_cleaned`): every saved revision keeps the untouched map_saver output as `map_raw.png` and promotes the decluttered image to the served `map.png`. The `map.yaml` frame is byte-identical, so zone coordinates and localization are unaffected; a cleaning failure falls back to serving the diff --git a/mote_bringup/mote_bringup/map_cleanup/rooms_cli.py b/mote_bringup/mote_bringup/map_cleanup/rooms_cli.py index 2d79430..9cb4d65 100644 --- a/mote_bringup/mote_bringup/map_cleanup/rooms_cli.py +++ b/mote_bringup/mote_bringup/map_cleanup/rooms_cli.py @@ -80,7 +80,14 @@ def zone_entry(room: Room) -> dict: } -def merge_into_zones(path: Path, rooms: list[Room]) -> tuple[list[str], list[str]]: +def merge_into_zones( + path: Path, + rooms: list[Room], + *, + site: str = "", + floor: str = "", + platform_id: str | None = None, +) -> tuple[list[str], list[str]]: """Add the rooms that are not already named to a zones file. Returns ``(added, skipped)`` names. A candidate is skipped when its outline @@ -88,15 +95,20 @@ def merge_into_zones(path: Path, rooms: list[Room]) -> tuple[list[str], list[str that room has a name, and it is not this tool's to replace. Bare waypoints (a ``pickup`` standing in the middle of a hall) do not suppress anything: they name a spot, not the room around it. + + ``site``/``floor``/``platform_id`` stamp the documents that come out. The + CLI leaves them to whatever the floor already says, because it is writing + into a floor that knows; the offline map build passes them, because it is + writing a revision for a floor it was told about on the command line. """ if path.suffix == ".yaml": # A caller that named the old combined file means the floor it is in. path = path.parent try: - floor = bundle.read_floor(path) + document = bundle.read_floor(path) except bundle.BundleError: - floor = {"frame_id": "map", "revision": 0, "zones": {}} - zones = floor["zones"] + document = {"frame_id": "map", "revision": 0, "zones": {}} + zones = document["zones"] named = [ (float(spec["x"]), float(spec["y"])) @@ -125,8 +137,8 @@ def merge_into_zones(path: Path, rooms: list[Room]) -> tuple[list[str], list[str ) added.append(name) if added: - floor["revision"] = int(floor.get("revision") or 0) + 1 - bundle.write_floor(path, floor) + document["revision"] = int(document.get("revision") or 0) + 1 + bundle.write_floor(path, document, site=site, floor=floor, platform_id=platform_id) return added, skipped diff --git a/mote_bringup/mote_bringup/sites.py b/mote_bringup/mote_bringup/sites.py index 1a015d8..52b990c 100644 --- a/mote_bringup/mote_bringup/sites.py +++ b/mote_bringup/mote_bringup/sites.py @@ -342,7 +342,7 @@ def revision_meta(fdir: Path, rev: str) -> dict: return yaml.safe_load(meta_file.read_text()) or {} -def _clean_map_png(raw_png: Path, out_png: Path, diag_png: Path) -> dict: +def clean_map_png(raw_png: Path, out_png: Path, diag_png: Path) -> dict: """Declutter a saved occupancy PNG: read raw_png, write the cleaned map to out_png and a diagnostics panel to diag_png. Returns cleaning stats for meta.yaml. Kept file-only (no ROS) so it is testable off the robot. @@ -372,7 +372,7 @@ def _clean_map_png(raw_png: Path, out_png: Path, diag_png: Path) -> dict: } -def _promote_cleaned(rev_dir: Path) -> dict: +def promote_cleaned(rev_dir: Path) -> dict: """Turn a freshly-saved raw revision into a served, cleaned one. The untouched map_saver output (map.png) is kept as map_raw.png and the @@ -380,6 +380,11 @@ def _promote_cleaned(rev_dir: Path) -> dict: identical for both (only pixels change), so zones and localization are unaffected. A cleaning failure never discards the map — the raw is served instead. Returns the clean stats block for meta.yaml. + + Public because ``save-map`` is no longer its only caller: the offline map + build (``mote_simulation/tools/map_build``) runs this very function on the + revision it assembles, rather than a copy of it, so a built map and a saved + one are the same pass over the same pixels and their metrics compare. """ raw_png = rev_dir / "map_raw.png" (rev_dir / "map.png").rename(raw_png) @@ -387,7 +392,7 @@ def _promote_cleaned(rev_dir: Path) -> dict: (rev_dir / "map.yaml").read_text().replace("map.png", "map_raw.png") ) try: - return _clean_map_png(raw_png, rev_dir / "map.png", rev_dir / "diagnostics.png") + return clean_map_png(raw_png, rev_dir / "map.png", rev_dir / "diagnostics.png") except Exception as exc: # noqa: BLE001 — a bad clean must not lose the map shutil.copyfile(raw_png, rev_dir / "map.png") print(f"WARNING: map cleaning failed ({exc}); serving raw map", file=sys.stderr) @@ -447,7 +452,7 @@ def save_map(clean: bool = True): f"incomplete map revision (missing map{'/map'.join(missing)}) — " "discarded; are mapping + slam_toolbox running?" ) - clean_stats = _promote_cleaned(rev_dir) if clean else {"skipped": True} + clean_stats = promote_cleaned(rev_dir) if clean else {"skipped": True} meta = {"schema": SCHEMA, "saved": time.strftime("%Y-%m-%dT%H:%M:%S")} bag = latest_mapping_bag() diff --git a/mote_bringup/test/test_sites.py b/mote_bringup/test/test_sites.py index 6a57d4f..662b58a 100644 --- a/mote_bringup/test/test_sites.py +++ b/mote_bringup/test/test_sites.py @@ -175,7 +175,7 @@ def test_promote_cleaned_serves_clean_and_keeps_raw(mote_home): fdir = mote_home / "f" rev_dir = fdir / "maps" / "r1" _stage_raw_map(rev_dir) - clean = sites._promote_cleaned(rev_dir) + clean = sites.promote_cleaned(rev_dir) assert clean["ok"] and clean["removed"] > 0 # raw retained, cleaned promoted to the served map.png, diagnostics written @@ -195,7 +195,7 @@ def test_promote_cleaned_failure_falls_back_to_raw(mote_home): rev_dir.mkdir(parents=True) (rev_dir / "map.png").write_text("not a png") # cv2 cannot read -> failure (rev_dir / "map.yaml").write_text("image: map.png\nresolution: 0.05\n") - clean = sites._promote_cleaned(rev_dir) + clean = sites.promote_cleaned(rev_dir) assert clean["ok"] is False and "error" in clean assert (rev_dir / "map.png").read_bytes() == (rev_dir / "map_raw.png").read_bytes() diff --git a/mote_simulation/tools/bag_replay/README.md b/mote_simulation/tools/bag_replay/README.md index 54f961e..82788b9 100644 --- a/mote_simulation/tools/bag_replay/README.md +++ b/mote_simulation/tools/bag_replay/README.md @@ -51,7 +51,13 @@ pixi run bag-replay -- \ Output lands in `bag_replay_results//`: `report.md` (open it — the map images are relative links), `run.json` (full metrics + provenance), and per set a `stack.log`, `replay.log`, `series.json` (re-scorable trajectory), `map.npz` -(occupancy grid) and `map.png`. +(occupancy grid, its resolution and its origin including the **yaw** — always +zero in practice, recorded because a consumer assembling a `map.yaml` from this +must not have to assume it) and `map.png`. + +**To build a map rather than score a tuning**, use `pixi run map-build` +(`../map_build/`), which drives this harness and then assembles, declutters, +segments, validates and packages the result as a candidate map revision. ## How it works diff --git a/mote_simulation/tools/bag_replay/replayer.py b/mote_simulation/tools/bag_replay/replayer.py index 68bd7fa..affafea 100755 --- a/mote_simulation/tools/bag_replay/replayer.py +++ b/mote_simulation/tools/bag_replay/replayer.py @@ -811,6 +811,10 @@ def main(): if node.latest_map is not None: m = node.latest_map grid = np.array(m.data, dtype=np.int16).reshape(m.info.height, m.info.width) + q = m.info.origin.orientation + origin_yaw = math.atan2( + 2.0 * (q.w * q.z + q.x * q.y), 1.0 - 2.0 * (q.y * q.y + q.z * q.z) + ) np.savez_compressed( out / "map.npz", grid=grid, @@ -818,6 +822,12 @@ def main(): origin=np.array( [m.info.origin.position.x, m.info.origin.position.y], dtype=np.float64 ), + # The third number ``map_saver`` writes into ``origin:``. It is zero + # for every grid slam_toolbox has ever published, and a consumer + # assembling a map revision from this file must not have to assume + # that: a dropped origin yaw puts every zone on the floor somewhere + # else, with the map still looking perfectly good. + origin_yaw=np.float64(origin_yaw), ) result["map"] = { "width": int(m.info.width), @@ -827,6 +837,7 @@ def main(): float(m.info.origin.position.x), float(m.info.origin.position.y), ], + "origin_yaw": float(origin_yaw), } (out / "series.json").write_text(json.dumps(result)) print( diff --git a/mote_simulation/tools/map_build/README.md b/mote_simulation/tools/map_build/README.md new file mode 100644 index 0000000..fc299ce --- /dev/null +++ b/mote_simulation/tools/map_build/README.md @@ -0,0 +1,141 @@ +# map-build + +One command that turns a recorded mapping bag into a **map revision a human can +review and promote**. Stage 2 of `docs/design/mapping-pipeline.md`: capture +produces a bag, a build produces a map, a human promotes it. + +```bash +pixi run map-build -- \ + --bag ~/.mote/bags/mapping/20260802_142539 \ + --site home --floor ground \ + --baseline ~/.mote/sites/home/floors/ground/map +``` + +It needs no robot, no live SLAM session and no map to already exist. It runs on +any machine with the project's default pixi environment and a copy of the bag. + +Output lands in `map_build_results//`: + +| | | +|---|---| +| `report.md` | **open this** — inputs, stages, validation, metric diff, renders | +| `revision//` | the map revision itself, in the layout `save-map` writes | +| `.tar.gz` | the same revision packed as the registry accepts it | +| `build.json` | everything in the report, as data | +| `build/` | the replay leg: `stack.log`, `replay.log`, `series.json`, `map.npz` | + +## The chain + +1. **solve** — lockstep replay of the whole bag through slam_toolbox under + `mote_bringup/config/slam_toolbox_build_params.yaml`, using the harness in + `../bag_replay`. A 21-minute bag solves in tens of seconds; that economy is + what makes rebuilding cheap enough to be the normal path. +2. **assemble** — the finished grid and the serialized posegraph written as + `map.yaml` + `map.png` + `map.posegraph` + `map.data`. A solve that + serialized no posegraph **fails the build**: a revision without one cannot + be extended later, and the frame — with every zone taught in it — is gone. +3. **declutter** — `sites.promote_cleaned`, the robot's own FFT structure pass, + called rather than copied. The untouched solve is kept as `map_raw.png`. +4. **segment** — one polygon zone per room of the cleaned map, named + `room_01`… for the reviewer to rename. +5. **validate + score** — `bundle.validate` (**hard**: a revision with an error + is not emitted), then truth-free metrics diffed against `--baseline` + (**soft**: a regression is printed as review evidence). +6. **package** — the gzipped bundle, plus the report. + +## What it does not do + +**It does not align the map frame.** The design's alignment step measures a +solved map's wall rotation, re-solves with that yaw injected, and keeps the +better of the two. Deciding "better" needs an estimator that can see the +difference, and the one in the tree cannot: it called four of the seven banked +2026-08-02 solves square when they were 3.5–5.6° off. A re-solve is not a rigid +rotation either — the same −3.0° injection moved three solves of one bag by ++0.1°, −4.3° and −5.8° — so the step is *undecidable* here, not merely ungated. +That estimator is task 615; the evidence is +`docs/tuning/2026-09-01-alignment-residual.md`. + +Until it lands, birth-alignment is an operator's judgment: `--frame X Y YAW` +passes an SE2 through to the solve, and it is recorded in the revision's +`meta.yaml`, so a map built that way is still reproducible. The build measures +and prints the map's wall structure either way, as evidence rather than as a +gate. + +**It does not carry a floor's zone names forward.** Re-binding the previous +revision's names onto new geometry is task 345. The build emits the segmenter's +placeholders and *reports* what the baseline floor's places were called, so the +gap is visible rather than silent. + +**It does not upload.** The registry's candidate-upload route accepts enrolled +robots only; a builder needs a credential of its own (task 344). The bundle is +emitted locally and the report says what will send it. + +## Options + +| | | +|---|---| +| `--bag DIR` | a recorded mapping bag directory (required) | +| `--params FILE` | slam parameters; default the committed build params | +| `--site` / `--floor` | stamped into the revision's zone documents | +| `--baseline PATH` | a revision directory (or its `map.yaml`) to diff against | +| `--frame X Y YAW` | birth-align the map frame by this SE2 | +| `--no-clean` | serve the raw solve; for ground-truth-clean maps (sim) | +| `--no-segment` | propose no room zones | +| `--paced` | feed against the wall clock instead of in lockstep | +| `--max-scans N` | stop after N scans — a quick smoke build | +| `--out DIR` | where the UTC-stamped result directory lands | + +`--paced` exists for a parameter set whose gates `bag_replay/acceptance.py` has +not been validated against; see that harness's `--validate` mode. Everything +else about the feed is its business, not this tool's. + +## Reproducing a build + +`meta.yaml` in every emitted revision names the exact inputs: + +```yaml +built_by: map-build +bag: 20260802_142539 +bag_sha256: 099f9d06… +slam_params: slam_toolbox_build_params.yaml +slam_params_sha256: 072929cf… +frame: [0.0, 0.0, -3.0] +feed: lockstep +harness_commit: 1c75eac +``` + +The bag digest covers every file's bytes *and* its name, so "the same bag" means +the same bytes rather than the same directory name. Re-running the same +`map-build` on the same inputs at the same harness commit reproduces the map — +to within the solver, which is not bit-identical run to run (small deltas on the +proxies are noise; the map images decide anything marginal). + +## Reading the metrics + +They are **proxies, not error measures** — the bag carries no ground truth, so +a confidently wrong map (a mis-closed loop drawn with sharp walls) can score +well on all of them. `bag_replay/README.md` "Limitations" is the full list of +what they can and cannot prove. Two that matter here: + +- **Loop drift needs a loop.** It cannot tell a legitimate open A→B traverse + from a drifting one. Know the bag's shape before reading it. +- **`angular_support_deg` is not in the diff table** and must not be used to + rank candidates: it is confounded by coverage, so a map that explored less + reads as tighter. + +The wall-structure table is the one thing crispness cannot see: a second +orthogonal frame carrying real energy, with **two** directions in it, means a +section of the map is drawn on its own axes — a tear. A second frame with one +direction is an angled hallway, which is architecture. + +## Tests + +```bash +python mote_simulation/tools/map_build/test_map_build.py +``` + +Covers the ROS-free half: the pixel convention a revision is written in and the +thresholds `map.yaml` declares for reading it back (get those wrong and unknown +space reads as free — as somewhere the planner may drive straight through), the +origin, the bag digest, and the metric diff's direction. The solve needs +slam_toolbox and a real bag and is exercised by running the tool. diff --git a/mote_simulation/tools/map_build/build_report.py b/mote_simulation/tools/map_build/build_report.py new file mode 100644 index 0000000..81fa544 --- /dev/null +++ b/mote_simulation/tools/map_build/build_report.py @@ -0,0 +1,249 @@ +"""The build report: what was built, from what, and how it compares. + +A candidate map is promoted by a human, and this is what they read before they +do it. Two rules shape it. Everything that decides the artifact — the bag's +digest, the parameter file's digest, the injected frame, the harness commit — +is printed, because "reproducible" means somebody can re-run this exact build. +And every number that is a *proxy* is labelled as one: the metrics here are +truth-free (the bag carries no ground truth), so they can say a map got +speckle-ier and they cannot say it got wrong. + +Metric direction is stated, never inferred. A reader should not have to know +whether more ``explored_area_m2`` is good. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +#: The metrics carried into the baseline diff, with the direction that counts as +#: better and a one-line gloss. Adding a row is a claim that the metric ranks +#: one candidate against another, and two that ``map_quality`` reports are +#: deliberately **not** in it: +#: +#: * ``angular_support_deg`` is confounded by coverage — a map that explored +#: less has fewer long walls and reads as tighter (bag_replay/README.md +#: "Limitations"). +#: * ``unknown_frac`` is a fraction of the *grid*, so it moves with the +#: bounding box. Measured on the 2026-08-02 bag: a candidate 4 px wider than +#: the baseline read 2.2% worse on it while covering 0.4 m² more floor, which +#: is the opposite of what it appeared to say. ``explored_area_m2`` carries +#: the same signal in metres and does not depend on the canvas. +#: +#: Everything ``map_quality`` measures is in ``build.json`` either way. +DIFFED = ( + ("loop.start_end_dist_m", "lower", "start↔end distance, if the run closed"), + ("loop.drift_ratio", "lower", "that distance over path length"), + ("map.mean_wall_thickness_m", "lower", "wall crispness; blur reads thicker"), + ("map.speckle_frac", "lower", "isolated occupied cells"), + ("map.explored_area_m2", "higher", "decided cells × cell area"), +) + +#: Relative change below which a diff is reported as unchanged. The solver is +#: not bit-identical run to run, so a fraction of a percent on either side of a +#: proxy is noise; this is a legibility threshold and nothing gates on it. +DEADBAND = 0.02 + + +def dig(source: dict, dotted: str): + for key in dotted.split("."): + if not isinstance(source, dict): + return None + source = source.get(key) + return source + + +def compare(candidate: dict, baseline: dict | None) -> list[dict]: + """One row per diffed metric: the two values, the change, and its direction.""" + rows = [] + for key, better, gloss in DIFFED: + new = dig(candidate, key) + if not isinstance(new, (int, float)): + continue + old = dig(baseline or {}, key) + row = {"metric": key, "gloss": gloss, "better": better, "candidate": float(new)} + if isinstance(old, (int, float)): + row["baseline"] = float(old) + row["delta"] = float(new) - float(old) + scale = abs(float(old)) or 1.0 + relative = row["delta"] / scale + if abs(relative) < DEADBAND: + row["verdict"] = "same" + elif (relative < 0) == (better == "lower"): + row["verdict"] = "better" + else: + row["verdict"] = "worse" + row["relative"] = relative + rows.append(row) + return rows + + +def _fmt(value) -> str: + if value is None: + return "—" + if isinstance(value, float): + return f"{value:.4g}" + return str(value) + + +def _table(header: list[str], rows: list[list]) -> list[str]: + out = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)] + out += ["| " + " | ".join(_fmt(cell) for cell in row) + " |" for row in rows] + return out + + +def build_markdown(build: dict) -> str: + inputs = build["inputs"] + lines = [ + f"# Map build {build['revision']}", + "", + f"**{build['verdict']}** — {build['verdict_detail']}", + "", + "## Inputs", + "", + ] + lines += _table( + ["input", "value"], + [ + ["bag", inputs["bag"]["path"]], + ["bag sha256", inputs["bag"]["sha256"]], + ["bag bytes", sum(f["bytes"] for f in inputs["bag"]["files"])], + ["slam params", inputs["params"]["path"]], + ["params sha256", inputs["params"]["sha256"]], + ["frame injection (x, y, yaw°)", inputs["frame"] or "none"], + ["feed", inputs["feed"]], + ["harness commit", inputs["harness_commit"]], + ["built (UTC)", build["built"]], + ], + ) + + lines += ["", "## Stages", ""] + lines += _table( + ["stage", "outcome", "detail"], + [[s["name"], s["outcome"], s["detail"]] for s in build["stages"]], + ) + + report = build["validation"] + lines += [ + "", + "## Validation", + "", + f"`bundle.validate` — **{report['summary']}**", + "", + ] + for error in report["errors"]: + lines.append(f"- ERROR {error}") + for warning in report["warnings"]: + lines.append(f"- warning: {warning}") + if not report["errors"] and not report["warnings"]: + lines.append("- no errors, no warnings") + + lines += ["", "## Metrics", ""] + baseline = build.get("baseline") + if baseline: + lines.append(f"Baseline: `{baseline['path']}`") + else: + lines.append( + "No baseline given, so nothing is diffed — pass `--baseline` with the " + "floor's current revision to compare against what is published." + ) + lines += [ + "", + "These are **truth-free proxies**: the bag carries no ground truth, so a " + "confidently wrong map can score well. Read them beside the map.", + "", + "The `map.*` rows are the map this revision **serves** — after the " + "declutter pass — on both sides, because that is what a promotion " + "publishes. The raw solve's are in `build.json` under `map_raw`.", + "", + ] + rows = [ + [ + row["metric"], + row["candidate"], + row.get("baseline"), + row.get("delta"), + row.get("verdict", "—"), + f"{row['better']} is better — {row['gloss']}", + ] + for row in build["diff"] + ] + lines += _table( + ["metric", "candidate", "baseline", "delta", "vs baseline", "reading"], rows + ) + lines += [ + "", + f"A change under {DEADBAND:.0%} reads as `same`: the solver is not " + "bit-identical run to run. **Nothing here blocks** — a regression is " + "evidence for the reviewer, not a gate.", + ] + + angular = build.get("angular") or {} + lines += ["", "## Wall structure", ""] + if angular.get("frames"): + lines += _table( + ["frame", "angle (deg)", "directions", "energy share", "off dominant"], + [ + [ + index, + frame.get("angle_deg"), + frame.get("n_directions"), + frame.get("energy_frac"), + frame.get("offset_from_dominant_deg"), + ] + for index, frame in enumerate(angular["frames"]) + ], + ) + lines.append("") + lines.append( + f"`angular_support_deg` {_fmt(angular.get('angular_support_deg'))}, " + f"{angular.get('n_peaks')} wall direction(s), dominant frame share " + f"{_fmt(angular.get('dominant_frame_share'))}. Support is **not** a " + "quality ranking — a map that explored less has fewer long walls " + "and reads as tighter." + ) + lines += [ + "", + "A rectilinear building puts every wall in one frame. A second " + "frame carrying real energy with **two** directions in it means a " + "section of the map is drawn on its own axes — a tear. A second " + "frame with one direction is an angled hallway, which is " + "architecture.", + ] + else: + lines.append("No angular structure was measured.") + lines += [ + "", + "The build does **not** align the map frame. Measuring a map's wall " + "rotation well enough to gate a re-solve on it is task 615 " + "(`docs/tuning/2026-09-01-alignment-residual.md`): the estimator in " + "the tree called four maps square that were 3.5–5.6° out. Until it " + "lands, birth-alignment is an operator's judgment, passed as " + "`--frame X Y YAW`, and recorded above.", + ] + + zones = build.get("zones") or {} + lines += ["", "## Zones", ""] + lines.append( + f"Segmentation proposed {len(zones.get('added', []))} room(s): " + + (", ".join(f"`{name}`" for name in zones.get("added", [])) or "none") + ) + if zones.get("carry_forward"): + lines += ["", zones["carry_forward"]] + + if build.get("images"): + lines += ["", "## Renders", ""] + for caption, path in build["images"]: + lines.append(f"### {caption}\n\n![{caption}]({path})\n") + + lines += ["", "## Next", "", build["next"], ""] + return "\n".join(lines) + "\n" + + +def write(out_dir, build: dict) -> Path: + out_dir = Path(out_dir) + (out_dir / "build.json").write_text(json.dumps(build, indent=2)) + path = out_dir / "report.md" + path.write_text(build_markdown(build)) + return path diff --git a/mote_simulation/tools/map_build/map_build.py b/mote_simulation/tools/map_build/map_build.py new file mode 100755 index 0000000..2091cd7 --- /dev/null +++ b/mote_simulation/tools/map_build/map_build.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +"""``map-build`` — a mapping bag in, a reviewable map candidate out. + +Stage 2 of the mapping pipeline (``docs/design/mapping-pipeline.md``). Capture +produces a bag; this produces a map revision; a human promotes it. The bag is +the source, the build parameters are the toolchain, and the revision is a build +artifact — so it is cheap to rebuild, and every rebuild names its exact inputs. + + pixi run map-build -- --bag ~/.mote/bags/mapping/20260802_142539 \\ + --site home --floor ground \\ + --baseline ~/.mote/sites/home/floors/ground/map + +The chain, which is what the 2026-08-02 flat session ran by hand: + +1. **solve** — lockstep replay of the whole bag under the committed *build* + parameters (``slam_toolbox_build_params.yaml``), through the harness that + already exists (``tools/bag_replay``). Minutes, not the bag's own duration. +2. **assemble** — the finished grid and the serialized posegraph, written out + in the layout ``save-map`` writes, because there is exactly one shape of + revision and the registry knows it. +3. **declutter** — ``sites.promote_cleaned``: the robot's own FFT structure + pass, not a copy of it. The raw map_saver-shaped image is kept. +4. **segment** — one polygon zone per room of the cleaned map. +5. **validate + score** — ``bundle.validate`` (hard: a revision that fails is + not emitted), then truth-free metrics diffed against a baseline revision + (soft: a regression is printed for the reviewer). +6. **package** — the revision directory plus the gzipped bundle the registry + accepts, and a build report. + +**Two steps of the design are not here, deliberately.** + +*Alignment* — measure the wall rotation, re-solve with it injected, keep the +better map — needs an estimator that can tell which map is better. The one in +the tree cannot: it called four of the 2026-08-02 solves square when they were +3.5–5.6° off (``docs/tuning/2026-09-01-alignment-residual.md``, task 615). A +re-solve is not a rigid rotation either, so the step is *undecidable* rather +than merely ungated, and building it on a measurement that cannot see would be +worse than not building it — the design says so in as many words. Until 615 +lands, birth-alignment is an operator's judgment: ``--frame X Y YAW``, recorded +in the revision's meta so the map stays reproducible. + +*Vocabulary carry-forward* is task 345. The build emits the segmenter's +placeholder room names and **reports** what the baseline floor was called, so +the gap is visible rather than silent. + +*Upload* needs a build identity (task 344): today's registry accepts candidate +uploads only from enrolled robots. The build therefore emits the packed bundle +locally and prints what will send it. +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from datetime import datetime, timezone +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[2] +BAG_REPLAY = REPO / "mote_simulation" / "tools" / "bag_replay" +BUILD_PARAMS = REPO / "mote_bringup" / "config" / "slam_toolbox_build_params.yaml" + +sys.path.insert(0, str(REPO / "mote_bringup")) +sys.path.insert(0, str(REPO / "mote_simulation" / "tools" / "benchmark")) +sys.path.insert(0, str(BAG_REPLAY)) +sys.path.insert(0, str(HERE)) + +import build_report # noqa: E402 +import revision as rev # noqa: E402 + +LEG = "build" + + +def log(msg): + print(f"[map-build] {msg}", flush=True) + + +class BuildFailed(Exception): + """A gate the build must not walk past. Nothing is emitted.""" + + +def solve(bag: Path, params: Path, run_dir: Path, args) -> dict: + """Replay the whole bag through slam_toolbox, in lockstep. + + The harness is called in-process rather than re-implemented: it owns the + DDS isolation, the stack launch, the acceptance-chain feed and the + teardown, and a second copy of any of that would be a second thing to keep + in step with slam_toolbox. + """ + import replay + + replay_args = argparse.Namespace( + rate=1.0, + settle=args.settle, + max_scans=args.max_scans, + skip_secs=args.skip_secs, + stop_secs=args.stop_secs, + frame=args.frame, + lockstep=not args.paced, + boot_timeout=args.boot_timeout, + replay_timeout=args.replay_timeout, + ) + leg = replay.run_one(bag, params, LEG, "slam", run_dir, replay_args) + if leg is None: + raise BuildFailed( + f"the solve did not complete — see {run_dir / LEG}/stack.log and replay.log" + ) + if not leg.get("map_npz"): + raise BuildFailed("the solve produced no occupancy grid") + return leg + + +def assemble(leg: dict, rev_dir: Path) -> dict: + """The map pair and the posegraph, in the layout a revision has.""" + frame = rev.write_map_pair(leg["map_npz"], rev_dir) + copied = rev.copy_posegraph(Path(leg["map_npz"]).parent, rev_dir) + if len(copied) != 2: + raise BuildFailed( + "the solve serialized no posegraph — a revision without one cannot " + "be extended later, and the frame is unrecoverable (extend, don't remap)" + ) + return frame + + +def declutter(rev_dir: Path, enabled: bool) -> dict: + """The robot's own cleaning pass, so a built map and a saved one compare.""" + if not enabled: + return {"skipped": True} + from mote_bringup import sites + + return sites.promote_cleaned(rev_dir) + + +def segment(rev_dir: Path, site: str, floor: str, out_dir: Path) -> dict: + """One polygon zone per room of the cleaned map, plus an overlay to look at. + + Geometry only: the names are ``room_NN`` placeholders for the reviewer to + replace in the dashboard's zone editor. What a room is *called* is a fact + about the building that no map holds. + """ + import cv2 + + from mote_bringup.map_cleanup.room_segmentation import RoomParams, segment_rooms + from mote_bringup.map_cleanup.rooms_cli import ( + load_map, + make_overlay, + merge_into_zones, + ) + + occ, geometry = load_map(rev_dir / "map.yaml") + result = segment_rooms(occ, geometry, RoomParams()) + added, skipped = merge_into_zones( + rev_dir, + result.rooms, + site=site, + floor=floor, + # The coordinates are in the *build's* map frame, which is no robot's. + # zone/v0 wants the platform that holds the frame named; naming the + # builder is the true answer and keeps a robot from being blamed for + # poses it never drove to. + platform_id="map-build", + ) + overlay = out_dir / "rooms.png" + cv2.imwrite(str(overlay), make_overlay(occ, geometry, result)) + return { + "added": added, + "skipped": skipped, + "n_rooms": len(result.rooms), + "overlay": overlay.name, + } + + +def carry_forward(baseline_dir: Path | None) -> str: + """What the previous revision's places were called — reported, not rebound. + + Re-binding a floor's names onto new geometry is task 345. Doing it badly is + worse than not doing it: a name bound to the wrong room sends the robot to + the wrong room, and nothing downstream can tell. So the build says what was + lost and leaves the reviewer to rename in the editor. + """ + if baseline_dir is None: + return ( + "No baseline floor, so there were no names to carry forward. " + "(Carrying a floor's vocabulary across a rebuild is task 345.)" + ) + from mote_bringup import bundle + + try: + previous = bundle.read_floor(baseline_dir) + except bundle.BundleError: + return ( + f"`{baseline_dir}` has no zone documents, so there were no names to " + "carry forward. (Task 345.)" + ) + names = sorted(previous.get("zones", {})) + if not names: + return f"`{baseline_dir}` names no places. (Task 345.)" + return ( + f"**Not carried forward**: the baseline floor names {len(names)} place(s) — " + + ", ".join(f"`{name}`" for name in names) + + ". Re-binding them onto this map's rooms is task 345; until it lands " + "the reviewer renames the placeholders above in the dashboard's zone " + "editor, which is where a name is edited on a candidate anyway." + ) + + +def revision_metrics(revision_dir: Path) -> dict: + """Truth-free map metrics for the map a revision *serves*. + + Both sides of the build's diff go through here, and that is the point. The + replay leg carries map metrics too, but they describe the raw solve — the + image before the declutter pass — while a stored revision only ever keeps + the cleaned one. Scoring the candidate from the leg and the baseline from + disk compares two different artifacts: measured on the 2026-08-02 bag, that + reported the candidate's speckle as five times the baseline's when the two + *served* maps agree to a thousandth, and every number it printed invited a + reviewer to reject a good map. + + So the candidate is read back from its own pixels exactly as the baseline + is, each at the thresholds its own ``map.yaml`` declares. + """ + import cv2 + import numpy as np + + import metrics + from mote_bringup import bundle + + map_yaml = revision_dir / "map.yaml" + if not map_yaml.is_file(): + raise BuildFailed(f"{revision_dir} has no map.yaml") + meta = bundle.read_map(map_yaml) + image = cv2.imread(str(revision_dir / meta["image"]), cv2.IMREAD_GRAYSCALE) + if image is None: + raise BuildFailed(f"could not read {revision_dir / meta['image']}") + grid = rev.png_to_grid( + np.asarray(image), + int(meta.get("negate", 0)), + float(meta.get("free_thresh", rev.YAML_FREE_THRESH)), + float(meta.get("occupied_thresh", rev.YAML_OCC_THRESH)), + ) + return metrics.map_quality(grid, float(meta["resolution"])) + + +def resolve_baseline(argument: str | None) -> Path | None: + if not argument: + return None + path = Path(argument).expanduser().resolve() + if path.name == "map.yaml": + path = path.parent + if not path.is_dir(): + raise BuildFailed(f"baseline not found: {argument}") + return path + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + prog="map-build", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--bag", required=True, help="a recorded mapping bag directory") + parser.add_argument( + "--params", + default=str(BUILD_PARAMS), + help="slam_toolbox parameters (default: the committed build params)", + ) + parser.add_argument("--site", default="local", help="site the revision is for") + parser.add_argument("--floor", default="default", help="floor the revision is for") + parser.add_argument( + "--baseline", + default="", + help="a revision directory (or its map.yaml) to diff metrics and zone " + "names against — normally the floor's current map", + ) + parser.add_argument( + "--frame", + nargs=3, + type=float, + metavar=("X", "Y", "YAW_DEG"), + help="birth-align the map frame by this SE2. An operator's judgment " + "until task 615 lands an estimator the build can gate on; it is " + "recorded in the revision's meta either way.", + ) + parser.add_argument( + "--out", + default=str(REPO / "map_build_results"), + help="where the build lands (a UTC-stamped directory under this)", + ) + parser.add_argument( + "--no-clean", + action="store_true", + help="serve the raw solve rather than the declutter pass. For maps " + "built from ground-truth geometry, where the pass would strip thin " + "true walls — the same reason sim maps save with clean=False.", + ) + parser.add_argument( + "--no-segment", action="store_true", help="do not propose room zones" + ) + parser.add_argument( + "--paced", + action="store_true", + help="feed against the wall clock instead of in lockstep: a whole bag " + "costs what it cost to record. For a parameter set whose gates the " + "acceptance chain has not been validated against.", + ) + parser.add_argument("--settle", type=float, default=8.0) + parser.add_argument("--max-scans", type=int, default=0, help="0 = whole bag") + parser.add_argument("--skip-secs", type=float, default=0.0) + parser.add_argument("--stop-secs", type=float, default=0.0) + parser.add_argument("--boot-timeout", type=float, default=120.0) + parser.add_argument("--replay-timeout", type=float, default=7200.0) + args = parser.parse_args(argv) + + bag = Path(args.bag).expanduser().resolve() + params = Path(args.params).expanduser().resolve() + if not bag.is_dir(): + sys.exit(f"bag not found: {bag}") + if not params.is_file(): + sys.exit(f"param file not found: {params}") + + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + out_dir = Path(args.out).expanduser() / stamp + out_dir.mkdir(parents=True, exist_ok=True) + revision_id = rev.new_revision_id() + rev_dir = out_dir / "revision" / revision_id + rev_dir.mkdir(parents=True) + log(f"results -> {out_dir}") + + import replay + + build = { + "revision": revision_id, + "built": stamp, + "out_dir": str(out_dir), + "revision_dir": str(rev_dir), + "site": args.site, + "floor": args.floor, + "stages": [], + "diff": [], + "images": [], + # Shaped before the first step that can fail, so a build that dies + # early still writes a report saying what it was asked to build. + "inputs": { + "bag": {"path": str(bag), "name": bag.name, "sha256": "", "files": []}, + "params": {"path": str(params), "sha256": ""}, + "frame": list(args.frame) if args.frame else None, + "feed": "paced" if args.paced else "lockstep", + "harness_commit": replay.git_commit(), + }, + } + stages = build["stages"] + + def stage(name, outcome, detail=""): + stages.append({"name": name, "outcome": outcome, "detail": str(detail)}) + log(f"{name}: {outcome}{f' — {detail}' if detail else ''}") + + try: + baseline_dir = resolve_baseline(args.baseline) + + log(f"digesting {bag.name}") + build["inputs"]["bag"] = dict(rev.digest_bag(bag), path=str(bag)) + build["inputs"]["params"]["sha256"] = rev.digest_file(params) + + leg = solve(bag, params, out_dir, args) + stage( + "solve", + "ok", + f"{leg['n_inserted']} pose-graph nodes from {leg['metrics']['n_scans']} " + f"scans in {leg.get('wall_s') or 0:.0f} s", + ) + + frame = assemble(leg, rev_dir) + origin = ", ".join(f"{value:.3f}" for value in frame["origin"]) + stage( + "assemble", + "ok", + f"{frame['width']}x{frame['height']} @ {frame['resolution']:.3f} m/px, " + f"origin ({origin})" + + ("" if frame["origin_yaw_recorded"] else " (origin yaw assumed 0)"), + ) + + clean = declutter(rev_dir, not args.no_clean) + if clean.get("skipped"): + stage("declutter", "skipped", "--no-clean: serving the raw solve") + elif clean.get("ok"): + stage( + "declutter", + "ok", + f"-{clean['removed']} cells, +{clean['added']}, wall directions " + f"{clean['directions_deg']}", + ) + else: + stage("declutter", "failed", f"{clean.get('error')}; serving raw") + + if args.no_segment: + zones = {"added": [], "skipped": [], "n_rooms": 0} + stage("segment", "skipped", "--no-segment") + else: + zones = segment(rev_dir, args.site, args.floor, out_dir) + stage("segment", "ok", f"{len(zones['added'])} room zone(s) proposed") + zones["carry_forward"] = carry_forward(baseline_dir) + stage("carry forward", "stub", "task 345 — names are reported, not rebound") + build["zones"] = zones + + meta = { + "schema": 1, + "saved": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S"), + "built_by": "map-build", + "site": args.site, + "floor": args.floor, + "bag": build["inputs"]["bag"]["name"], + "bag_sha256": build["inputs"]["bag"]["sha256"], + "slam_params": params.name, + "slam_params_sha256": build["inputs"]["params"]["sha256"], + "frame": build["inputs"]["frame"], + "feed": build["inputs"]["feed"], + "harness_commit": build["inputs"]["harness_commit"], + "clean": clean, + } + rev.write_meta(rev_dir, meta) + + from mote_bringup import bundle + + report = bundle.validate(rev_dir) + build["validation"] = { + "summary": report.summary(), + "errors": list(report.errors), + "warnings": list(report.warnings), + "occupancy": report.occupancy, + } + if not report.ok: + stage("validate", "FAILED", report.summary()) + raise BuildFailed(f"the revision is not usable: {report.summary()}") + stage("validate", "ok", report.summary()) + + # The leg's own map metrics describe the raw solve; the served map is + # what a promotion publishes, so that is what is scored and diffed. + # ``loop`` comes from the leg either way — it is a property of the + # trajectory, and no image holds it. + candidate_metrics = dict(leg["metrics"], map=revision_metrics(rev_dir)) + candidate_metrics["map_raw"] = leg["metrics"].get("map", {}) + base_metrics = None + if baseline_dir is not None: + base_metrics = {"map": revision_metrics(baseline_dir)} + build["baseline"] = {"path": str(baseline_dir), "metrics": base_metrics} + build["metrics"] = candidate_metrics + build["angular"] = candidate_metrics["map"] + build["diff"] = build_report.compare(candidate_metrics, base_metrics) + worse = [ + row["metric"] for row in build["diff"] if row.get("verdict") == "worse" + ] + stage( + "score", + "ok", + f"{len(worse)} metric(s) worse than the baseline: {', '.join(worse)}" + if worse + else "no metric regressed against the baseline" + if base_metrics + else "no baseline to diff against", + ) + + blob = bundle.pack(rev_dir) + bundle_path = out_dir / f"{revision_id}.tar.gz" + bundle_path.write_bytes(blob) + stage( + "package", + "ok", + f"{bundle_path.name}, {len(blob)} bytes, {bundle.digest(blob)[:23]}…", + ) + + for name, caption in ( + ("map.png", "Built map (served)"), + ("map_raw.png", "Raw solve"), + ("diagnostics.png", "Declutter diagnostics"), + ): + source = rev_dir / name + if source.is_file(): + shutil.copyfile(source, out_dir / name) + build["images"].append((caption, name)) + if zones.get("overlay"): + build["images"].append(("Proposed rooms", zones["overlay"])) + + build["verdict"] = "candidate emitted" + build["verdict_detail"] = ( + f"`{rev_dir}` — validated, packed as `{bundle_path.name}`" + ) + build["next"] = ( + f"Review the map above, then upload `{bundle_path.name}` to the " + f"registry as a candidate for `{args.site}/{args.floor}`. The upload " + "route accepts enrolled robots only, so a builder needs a credential " + "of its own — that is task 344; until it lands, a robot at the site " + "can side-load the revision directory into its floor and " + "`pixi run publish-map --revision " + f"{revision_id}`. Promotion is unchanged: an operator's audited " + "call, in the dashboard or `fleetctl promote`." + ) + status = 0 + except BuildFailed as failure: + build.setdefault( + "validation", {"summary": "not reached", "errors": [], "warnings": []} + ) + build["verdict"] = "build failed" + build["verdict_detail"] = str(failure) + build["next"] = "Nothing was emitted. Fix the above and re-run." + log(f"FAILED: {failure}") + status = 1 + + path = build_report.write(out_dir, build) + log(f"wrote {path}") + if status == 0: + log(f"candidate {revision_id} -> {rev_dir}") + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mote_simulation/tools/map_build/revision.py b/mote_simulation/tools/map_build/revision.py new file mode 100644 index 0000000..a8c3378 --- /dev/null +++ b/mote_simulation/tools/map_build/revision.py @@ -0,0 +1,185 @@ +"""A solved replay leg, turned into a site-bundle map revision. + +``save-map`` writes a revision from a *running* mapping session: it asks +``map_saver_cli`` for the map pair and slam_toolbox for the posegraph, promotes +the decluttered image over the raw one, and stamps a ``meta.yaml``. An offline +build has the same two artifacts — the finished grid and the serialized graph — +and has to write the same layout, because the registry, ``bundle.validate`` and +every consumer downstream know exactly one shape of revision. + +So this module writes the map pair and the meta, and the *cleaning* is +``sites.promote_cleaned`` itself rather than a copy of it: a build whose +declutter pass differed from the robot's would produce maps that cannot be +compared with the ones already published. + +Nothing here imports ROS. The grid arrives as the ``map.npz`` the replay +harness wrote, so a revision can be assembled — and this module tested — on a +machine with no ROS on it at all. +""" + +from __future__ import annotations + +import hashlib +import time +from pathlib import Path + +import numpy as np +import yaml + +# Two scales, and confusing them writes a map whose unknown space reads back as +# free — which is to say, as somewhere Nav2 may plan straight through. +# +# GRID_* classify the incoming ``OccupancyGrid``: 0..100 probability, -1 +# unknown. These are the values ``map_saver``, ``render.py`` and the benchmark's +# ``map_quality`` all split occupancy at, so a built map's pixels agree with +# every other reading of the same grid. +GRID_FREE_MAX = 25 +GRID_OCC_MIN = 65 + +# YAML_* are what ``map.yaml`` declares, on ``map_server``'s ``p = (255 - +# shade) / 255`` scale: free below the first, occupied above the second, +# unknown in between. The three shades written below land at p = 0.004, 0.196 +# and 1.0, so these sit with room on both sides. +# +# ``map_saver`` writes 0.196 for the free threshold, which is the unknown +# shade's own p value to five decimals — the read-back is then correct by +# 8e-5, and a grey pixel one shade lighter is free space. A build writes the +# same three shades with a threshold that is not deciding the map on a +# rounding, and picks the pair ``bundle.occupancy`` already counts at. +YAML_FREE_THRESH = 0.100 +YAML_OCC_THRESH = 0.650 + +FREE_PX = 254 +UNKNOWN_PX = 205 +OCCUPIED_PX = 0 + + +def grid_to_png_array(grid: np.ndarray) -> np.ndarray: + """ROS occupancy grid -> the greyscale image ``map_saver`` would write. + + Occupied black, free white, unknown grey, north up: the grid's row 0 is the + bottom of the world, an image's row 0 is the top. + """ + image = np.full(grid.shape, UNKNOWN_PX, dtype=np.uint8) + decided = grid >= 0 + image[decided & (grid <= GRID_FREE_MAX)] = FREE_PX + image[grid >= GRID_OCC_MIN] = OCCUPIED_PX + return np.flipud(image) + + +def png_to_grid( + image: np.ndarray, + negate: int = 0, + free_thresh: float = YAML_FREE_THRESH, + occupied_thresh: float = YAML_OCC_THRESH, +) -> np.ndarray: + """The inverse, as ``map_server`` reads a saved map back. + + Wanted for the baseline side of the build's metric diff: the revision a + candidate is compared against is on disk as pixels, and the metrics take + grids. The thresholds come from that revision's own ``map.yaml``, because a + map saved by a robot declares ``map_saver``'s and not these. + """ + values = image.astype(np.float64) if negate else 255.0 - image.astype(np.float64) + p = values / 255.0 + grid = np.full(image.shape, -1, dtype=np.int16) + grid[p < free_thresh] = 0 + grid[p > occupied_thresh] = 100 + return np.flipud(grid) + + +def write_map_pair(npz_path, rev_dir) -> dict: + """Write ``map.png`` + ``map.yaml`` from a replay leg's captured grid. + + Returns the frame — resolution, origin, size — for the build report. + """ + import cv2 + + rev_dir = Path(rev_dir) + rev_dir.mkdir(parents=True, exist_ok=True) + data = np.load(npz_path) + grid = data["grid"] + resolution = float(data["resolution"]) + ox, oy = (float(v) for v in data["origin"]) + # Harness output from before the origin yaw was recorded has none. It has + # always been zero in practice, but a build must not assume it: a dropped + # origin yaw moves every zone on the floor and leaves the map looking + # perfectly good, so the absence is written into the report as well. + yaw = float(data["origin_yaw"]) if "origin_yaw" in data.files else 0.0 + + cv2.imwrite(str(rev_dir / "map.png"), grid_to_png_array(grid)) + (rev_dir / "map.yaml").write_text( + "image: map.png\n" + "mode: trinary\n" + f"resolution: {resolution:.3f}\n" + f"origin: [{ox:.3f}, {oy:.3f}, {yaw:.6f}]\n" + "negate: 0\n" + f"occupied_thresh: {YAML_OCC_THRESH}\n" + f"free_thresh: {YAML_FREE_THRESH}\n" + ) + return { + "width": int(grid.shape[1]), + "height": int(grid.shape[0]), + "resolution": resolution, + "origin": [ox, oy, yaw], + "origin_yaw_recorded": "origin_yaw" in data.files, + } + + +def copy_posegraph(set_dir, rev_dir) -> list[str]: + """Put the leg's serialized graph in beside its map. + + A revision without it navigates and cannot be *extended* — the frame is + lost, and with it every zone taught in it — so the build treats a missing + graph as a failure rather than a warning, and this reports what it found. + """ + set_dir, rev_dir = Path(set_dir), Path(rev_dir) + copied = [] + for name in ("map.posegraph", "map.data"): + source = set_dir / name + if source.is_file(): + (rev_dir / name).write_bytes(source.read_bytes()) + copied.append(name) + return copied + + +def new_revision_id() -> str: + """The shape ``sites._new_revision_dir`` mints: revisions sort by name, and + a build's has to sort beside a robot's.""" + return time.strftime("%Y%m%dT%H%M%S") + + +def digest_bag(bag_dir) -> dict: + """A mapping bag's identity: every file, its size, and one digest over all. + + The bag is the build's source, so "which bag" has to mean the bytes and not + a directory name anybody can re-use. Cheap enough to always do: a 184 MB + bag hashes in under a second. + """ + bag_dir = Path(bag_dir) + files = sorted(p for p in bag_dir.iterdir() if p.is_file()) + whole = hashlib.sha256() + members = [] + for path in files: + each = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + each.update(chunk) + whole.update(chunk) + whole.update(path.name.encode()) + members.append( + { + "name": path.name, + "bytes": path.stat().st_size, + "sha256": each.hexdigest(), + } + ) + return {"name": bag_dir.name, "sha256": whole.hexdigest(), "files": members} + + +def digest_file(path) -> str: + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def write_meta(rev_dir, meta: dict) -> None: + Path(rev_dir, "meta.yaml").write_text(yaml.safe_dump(meta, sort_keys=False)) diff --git a/mote_simulation/tools/map_build/test_map_build.py b/mote_simulation/tools/map_build/test_map_build.py new file mode 100755 index 0000000..3512dc6 --- /dev/null +++ b/mote_simulation/tools/map_build/test_map_build.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Unit tests for the map-build orchestrator's ROS-free half. + + python mote_simulation/tools/map_build/test_map_build.py + +What is covered is what a wrong answer would be *silent* about: the pixel +convention a revision's map is written in (get it inverted and the map still +renders, mirrored, with every wall where free space was), the origin that lands +in ``map.yaml``, the baseline reader that has to invert the same convention, +and the metric diff's direction — where "lower is better" is data and reading +it the wrong way round would print `better` over a regression. + +The solve itself needs slam_toolbox and a real bag, and is exercised by +``pixi run map-build``, not from here. +""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[2] +sys.path.insert(0, str(REPO / "mote_bringup")) +sys.path.insert(0, str(HERE)) + +import build_report # noqa: E402 +import revision as rev # noqa: E402 + + +def sample_grid() -> np.ndarray: + """A 4x6 grid with one of each cell class, and an asymmetric top and bottom + so a vertical flip cannot pass unnoticed.""" + grid = np.full((4, 6), -1, dtype=np.int16) + grid[0, :] = 0 # free along the bottom row of the world + grid[3, :] = 100 # occupied along the top + grid[1, 2] = 100 + return grid + + +class MapPixels(unittest.TestCase): + def test_grid_to_png_uses_map_saver_values(self): + image = rev.grid_to_png_array(sample_grid()) + # Row 0 of the image is the *top* of the world, i.e. grid row 3. + self.assertTrue((image[0] == rev.OCCUPIED_PX).all()) + self.assertTrue((image[-1] == rev.FREE_PX).all()) + self.assertEqual(image[2, 2], rev.OCCUPIED_PX) + self.assertEqual(image[1, 0], rev.UNKNOWN_PX) + + def test_png_to_grid_inverts_it(self): + grid = sample_grid() + back = rev.png_to_grid(rev.grid_to_png_array(grid)) + # The trinary round trip is exact on class, not on value: a cell at 100 + # comes back as 100, one at 0 as 0, unknown as -1. + self.assertTrue(((back >= 0) == (grid >= 0)).all()) + self.assertTrue(((back == 100) == (grid == 100)).all()) + self.assertTrue(((back == 0) == (grid == 0)).all()) + + def test_the_declared_thresholds_read_the_written_shades_back(self): + """The bug this exists to stop: unknown space read back as free, i.e. as + somewhere the planner may drive straight through.""" + for shade, expected in ( + (rev.FREE_PX, 0), + (rev.UNKNOWN_PX, -1), + (rev.OCCUPIED_PX, 100), + ): + back = rev.png_to_grid(np.full((1, 1), shade, dtype=np.uint8)) + self.assertEqual(int(back[0, 0]), expected, f"shade {shade}") + + def test_grid_classes_split_where_every_other_reader_splits(self): + grid = np.array([[rev.GRID_FREE_MAX, rev.GRID_OCC_MIN, 50]]) + image = rev.grid_to_png_array(grid) + self.assertEqual(list(image[0]), [rev.FREE_PX, rev.OCCUPIED_PX, rev.UNKNOWN_PX]) + + +class MapPair(unittest.TestCase): + def write(self, tmp: Path, **extra): + npz = tmp / "map.npz" + np.savez_compressed( + npz, + grid=sample_grid(), + resolution=np.float64(0.05), + origin=np.array([-1.25, -2.5]), + **extra, + ) + return rev.write_map_pair(npz, tmp / "revision") + + def test_map_yaml_carries_the_frame(self): + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + frame = self.write(tmp, origin_yaw=np.float64(0.25)) + text = (tmp / "revision" / "map.yaml").read_text() + self.assertIn("image: map.png", text) + self.assertIn("resolution: 0.050", text) + self.assertIn("origin: [-1.250, -2.500, 0.250000]", text) + self.assertEqual(frame["width"], 6) + self.assertEqual(frame["height"], 4) + self.assertTrue(frame["origin_yaw_recorded"]) + + def test_a_missing_origin_yaw_is_zero_and_says_so(self): + """Harness output from before the yaw was recorded must not read as a + measured zero: the report says which it was.""" + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + frame = self.write(tmp) + self.assertIn( + "origin: [-1.250, -2.500, 0.000000]", + (tmp / "revision" / "map.yaml").read_text(), + ) + self.assertFalse(frame["origin_yaw_recorded"]) + + def test_the_pair_passes_the_bundle_validator(self): + """The whole point of writing this layout: the registry accepts it.""" + from mote_bringup import bundle + + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + self.write(tmp, origin_yaw=np.float64(0.0)) + rev_dir = tmp / "revision" + for name in bundle.CONTINUABLE: + (rev_dir / name).write_bytes(b"posegraph bytes") + rev.write_meta(rev_dir, {"schema": 1, "saved": "2026-09-01T00:00:00"}) + report = bundle.validate(rev_dir) + self.assertEqual(report.errors, []) + + +class ServedMap(unittest.TestCase): + """The candidate is scored from the map it will publish, not from the solve. + + A revision keeps two images — the raw solve and the decluttered one it + serves — and only the second is ever published. Reading the wrong one puts + the raw map's speckle beside a baseline's cleaned figure and prints a + regression that is not there. + """ + + def test_revision_metrics_reads_the_image_map_yaml_names(self): + import cv2 + + sys.path.insert(0, str(REPO / "mote_simulation" / "tools" / "map_build")) + sys.path.insert(0, str(REPO / "mote_simulation" / "tools" / "benchmark")) + import map_build + + with tempfile.TemporaryDirectory() as raw: + rev_dir = Path(raw) / "20260901T120000" + npz = Path(raw) / "map.npz" + np.savez_compressed( + npz, + grid=sample_grid(), + resolution=np.float64(0.05), + origin=np.array([0.0, 0.0]), + origin_yaw=np.float64(0.0), + ) + rev.write_map_pair(npz, rev_dir) + served = rev_dir / "map.png" + # The raw image is deliberately the opposite of the served one: a + # reader that took map_raw.png would report every cell inverted. + cv2.imwrite( + str(rev_dir / "map_raw.png"), + 255 - cv2.imread(str(served), cv2.IMREAD_GRAYSCALE), + ) + scored = map_build.revision_metrics(rev_dir) + self.assertAlmostEqual(scored["occ_frac"], 7 / 24) + + +class BagIdentity(unittest.TestCase): + def test_digest_covers_content_and_names(self): + with tempfile.TemporaryDirectory() as raw: + bag = Path(raw) / "20260802_142539" + bag.mkdir() + (bag / "a_0.mcap").write_bytes(b"one") + (bag / "metadata.yaml").write_text("version: 9\n") + first = rev.digest_bag(bag) + self.assertEqual(first["name"], "20260802_142539") + self.assertEqual( + [f["name"] for f in first["files"]], ["a_0.mcap", "metadata.yaml"] + ) + + # Same bytes, different file name: a different bag. + (bag / "a_0.mcap").rename(bag / "b_0.mcap") + self.assertNotEqual(rev.digest_bag(bag)["sha256"], first["sha256"]) + + +class Diff(unittest.TestCase): + def test_direction_decides_better_from_worse(self): + candidate = {"map": {"speckle_frac": 0.10, "explored_area_m2": 50.0}} + baseline = {"map": {"speckle_frac": 0.20, "explored_area_m2": 80.0}} + rows = {row["metric"]: row for row in build_report.compare(candidate, baseline)} + # Less speckle is better; less explored area is worse. + self.assertEqual(rows["map.speckle_frac"]["verdict"], "better") + self.assertEqual(rows["map.explored_area_m2"]["verdict"], "worse") + + def test_a_change_inside_the_deadband_is_the_same(self): + candidate = {"map": {"speckle_frac": 0.2 * (1 + build_report.DEADBAND / 2)}} + rows = { + row["metric"]: row + for row in build_report.compare(candidate, {"map": {"speckle_frac": 0.2}}) + } + self.assertEqual(rows["map.speckle_frac"]["verdict"], "same") + + def test_no_baseline_reports_the_candidate_and_no_verdict(self): + rows = build_report.compare({"map": {"speckle_frac": 0.1}}, None) + self.assertEqual(len(rows), 1) + self.assertNotIn("verdict", rows[0]) + self.assertNotIn("baseline", rows[0]) + + def test_canvas_dependent_metrics_are_not_diffed(self): + """Two metrics ``map_quality`` reports must not appear as ranked rows. + + ``angular_support_deg`` is confounded by coverage; ``unknown_frac`` is a + fraction of the grid, so a candidate whose bounding box is a few pixels + wider reads worse on it while covering more floor. Either one printed + beside a `worse` invites a reviewer to reject a map for a reason that is + not about the map. + """ + diffed = [metric for metric, _, _ in build_report.DIFFED] + self.assertNotIn("map.angular_support_deg", diffed) + self.assertNotIn("map.unknown_frac", diffed) + + +class Report(unittest.TestCase): + def minimal(self) -> dict: + return { + "revision": "20260901T120000", + "built": "20260901T120000Z", + "verdict": "candidate emitted", + "verdict_detail": "somewhere", + "inputs": { + "bag": {"path": "/bags/x", "sha256": "abc", "files": [{"bytes": 3}]}, + "params": {"path": "/p.yaml", "sha256": "def"}, + "frame": None, + "feed": "lockstep", + "harness_commit": "cafe", + }, + "stages": [{"name": "solve", "outcome": "ok", "detail": "186 nodes"}], + "validation": {"summary": "valid", "errors": [], "warnings": []}, + "diff": build_report.compare({"map": {"speckle_frac": 0.1}}, None), + "angular": {"frames": [], "n_peaks": 0}, + "zones": {"added": ["room_01"], "carry_forward": "nothing to carry"}, + "images": [], + "next": "upload it", + } + + def test_markdown_renders_without_a_baseline(self): + text = build_report.build_markdown(self.minimal()) + self.assertIn("# Map build 20260901T120000", text) + self.assertIn("No baseline given", text) + self.assertIn("room_01", text) + + def test_markdown_names_the_alignment_gap(self): + """A reviewer must not read 'no alignment step' as 'the map is square'.""" + text = build_report.build_markdown(self.minimal()) + self.assertIn("does **not** align the map frame", text) + self.assertIn("615", text) + + def test_a_failed_build_still_renders(self): + build = self.minimal() + build["verdict"] = "build failed" + build["validation"] = {"summary": "not reached", "errors": [], "warnings": []} + build["diff"] = [] + self.assertIn("build failed", build_report.build_markdown(build)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/pixi.toml b/pixi.toml index ff3f94e..3e8e20b 100644 --- a/pixi.toml +++ b/pixi.toml @@ -107,6 +107,12 @@ site = "ros2 run mote_bringup site" # pixi run bag-replay -- --bag ~/.mote/bags/mapping/ --params a.yaml b.yaml bag-replay = "python mote_simulation/tools/bag_replay/replay.py" +# Mapping pipeline stage 2: a mapping bag in, a reviewable map candidate out — +# solve, declutter, segment, validate, score, package. Needs no robot and no +# live SLAM session; the bag is the source and the revision is a build artifact. +# pixi run map-build -- --bag ~/.mote/bags/mapping/ --site home --floor ground +map-build = "python mote_simulation/tools/map_build/map_build.py" + # Quick launch commands launch = "ros2 launch mote_bringup mote_launch.py" mapping = "ros2 launch mote_bringup mapping_launch.py"