# Nav2 costmap inflation: check the raw costs

This exercise loads an authored PGM/YAML map through the real Nav2 map server, then runs the real static and inflation plugins in `Costmap2DROS`. Two fresh cases differ only in `inflation_radius`: 0.3 m and 0.6 m. Every raw cost byte is compared with an independently computed nearest-obstacle distance and the documented inflation formula.

The fixture supplies the robot pose. It does not run a planner, controller, AMCL, sensor obstacle layer, or robot.

## Build and run

Save `Dockerfile`, `costmap_workflow.py`, `verify.sh`, `narrow.yaml`, `wide.yaml`, `map.yaml`, and `map.pgm` together. The environment record and captured output describe verification. `reference-costmaps.json` contains one captured comparison; the runner does not read it.

```sh
docker build --platform linux/arm64 -t dustin-nav2-costmap-lesson:jazzy .
mkdir -p costmap-output
docker run --rm --network none --platform linux/arm64 \
  --mount "type=bind,source=$(pwd),target=/lesson,readonly" \
  --mount "type=bind,source=$(pwd)/costmap-output,target=/output" \
  dustin-nav2-costmap-lesson:jazzy \
  bash /lesson/verify.sh --output /output/run
```

The tested platform is Docker Desktop on macOS ARM64, with Ubuntu 24.04 and ROS 2 Jazzy inside the container. Other architectures and emulation are unverified. The base digest and two Nav2 binary packages are pinned. Other apt dependencies resolve at build time. The minimal installation added about 176 MB before Docker build/export overhead; leave several GB free before building.

Build-time package downloads need network access. The exercise runs with external networking disabled. Fast DDS participants communicate inside the container, using domain 77 for the narrow case and 78 for the wide case. Both ROS nodes use system time; this static exercise has no `/clock` publisher.

Choose a new output directory for each run. Existing paths are refused before the workflow writes to them. Omit `--output` to check temporary outputs and remove them afterward. All discovery and service waits use bounded wall-time deadlines.

## Inputs and frame choices

The authored image is 21 × 21 cells at 0.1 m per cell. Its map-frame origin is (−1.05, −1.05), with no rotation. Grid coordinates start at the lower left. Cell (10, 10) is occupied, rows 18 through 20 are unknown, and the other 377 cells are free. PGM image rows run in the opposite vertical direction; the runner checks the file layout and every cell published by `map_server`.

Pixel values are 0 for the obstacle, 254 for free cells, and 205 for unknown cells. `map.yaml` uses trinary mode, `occupied_thresh: 0.65`, and `free_thresh: 0.196`. A gray pixel only becomes unknown when its interpreted occupancy falls between the configured thresholds.

The fixture publishes the static transform `map → base_link` at (−0.8, −0.8), yaw 0. This supplied pose satisfies the costmap's frame requirement; it is not an estimated localization result. The robot remains in a free area throughout both cases.

The configurations explicitly set:

- `plugins: [static_layer, inflation_layer]`, with their full plugin types.
- `robot_radius: 0.15`, an empty polygon override, and `footprint_padding: 0.0`.
- `rolling_window: false` and `track_unknown_space: true`.
- `static_layer.footprint_clearing_enabled: false`.
- `inflation_layer.cost_scaling_factor: 3.0`.
- `inflate_unknown: false` and `inflate_around_unknown: false`.

The runner compares both flattened YAML dictionaries and requires inflation radius to be their only difference. It configures and activates each lifecycle node, checks the active state, and reads back the configured values. The static layer receives the map through a reliable transient-local subscription.

## Raw-cost checks

The script calls `/get_costmap` using `nav2_msgs/srv/GetCostmap`. Its response contains raw unsigned cost bytes. Nav2's `/costmap` visualization topic uses `OccupancyGrid` values that compress this scale, so the checker does not use that display representation as its oracle.

Raw costs mean:

- `0`: free.
- `1` through `252`: inflation gradient.
- `253`: inscribed collision cost.
- `254`: lethal obstacle.
- `255`: unknown.

In this Nav2 version, `robot_radius: 0.15` generates a regular 16-vertex footprint. Zero padding gives an inscribed radius of `0.15 × cos(π/16) ≈ 0.14711779 m`. The runner checks the published, transformed footprint vertices and their edge distances against that value.

For each cell, the reference calculation finds the nearest obstacle directly in the authored grid. It does not reuse Nav2's distance cache or propagation algorithm. Within the cell-radius cutoff, a nonzero distance `d` beyond the inscribed radius receives:

```text
floor(252 × exp(−3 × (d − 0.14711779206048456)))
```

Distance zero gives 254; distances at or inside the inscribed radius give 253. Nav2 converts inflation radius to `ceil(radius / resolution)` cells. These cases use cutoffs of three and six cells and compare Euclidean cell-center distance with that cutoff. The calculation uses the resolution reported by the loaded map, including its float32 representation.

At cell (12, 10), two cells from the obstacle, both cases produce cost 215. At cell (14, 10), four cells away, the narrow case produces 0 and the wide case 118. Increasing radius extends the gradient while the cost formula in the overlapping region stays the same.

The unknown band lies beyond both inflation cutoffs and remains 255. `inflate_around_unknown: false` keeps it from seeding inflation. `inflate_unknown: false` preserves unknown cells against lower gradient values, but Nav2 can still replace an unknown cell with an inscribed or lethal cost. This distant-band fixture does not exercise that overlap exception.

## Retained evidence

The output root keeps copies of the map and both configurations, plus `comparison.json` containing the source map, both captured raw grids, parameters, counts, and changed-cell indices.

Each case directory contains:

- `source-map.json` and `raw-costmap.json`, with dimensions, origin, frame, resolution, timestamp, and row-major cells.
- `expected-costmap.json`, including the independently calculated costs and nearest-obstacle distances.
- `published-footprint.json`, loaded parameter records, and the costmap configuration.
- Map-server and costmap logs, `report.json`, and process exit codes.

The report requires exact equality for all 441 raw cost bytes in each case. It also checks that the wider radius changes only previously free cells to gradient costs, leaving this fixture's inscribed, lethal, and unknown cells fixed. The captures describe this small authored input, not navigation performance or a collision-free route. Changes to the map or other parameters require corresponding changes to the fixed reference checks.

## Runtime teardown note

The tested standalone costmap binary reproduced a shutdown fault after its numerical checks, including when lifecycle cleanup was requested first. A debugger placed the failure in `rclcpp::CallbackGroup` destruction. The runner therefore keeps the installed `/opt/ros/jazzy/lib/liblayers.so` loaded through process exit, using a `ros2 run --prefix` environment setting scoped to the costmap executable. It checks that the library exists and records process exit codes after explicit lifecycle deactivation and cleanup.

This workaround is tied to the tested binary set. The map server, Python fixture, ROS command-line process, and host environment receive no preload setting. The runner uses the official plugin code without patching its cost computation. The observation does not establish a general cause for Nav2 shutdown failures.
