Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy-zoomies

.nornir/osm-katana-guide.md

osm-katana — guide

Class: guide · Function: osm-katana convert. Build, run, tune. The shape is in osm-katana-design.md; the why in osm-katana-reasoning.md.

Build

CARGO_TARGET_DIR=/mnt/work4t/cargo-target/zoomies cargo build --release -p osm-katana

Binary lands at target/release/osm-katana.

Run

osm-katana convert planet.osm.pbf                 # → out/{nodes,ways,relations}.parquet
osm-katana convert region.osm.bz2 -o region_geo   # bz2 input, custom output dir
osm-katana convert extract.osm.gz                 # gz input
osm-katana convert small.osm                       # plain XML input

Input format is chosen by extension (convert_inner, osm-katana/src/lib.rs:508): .osm.pbf, .osm.bz2, .osm.gz, .osm. Output is always three GeoParquet tables — nodes.parquet, ways.parquet, relations.parquet — in the -o directory (default out).

Every run is the same two-pass, all-core pipeline: pass 1 extracts node coordinates, a serial sort barrier builds the node store, pass 2 resolves ways/relations and encodes geometry, and a single in-order writer stitches the pre-compressed row groups. See the design doc's flow diagram.

Flags

flag default meaning
-o, --output <dir> out output directory for the three parquet files
--nodes / --ways / --relations <bool> true include each table (main.rs:19-23)
--compression <c> zstd parquet compression: zstd | snappy | none
--vtd-workers <N> 0 worker count; 0 ⇒ available_parallelism() − 1 (lib.rs:488)
--geometry <mode> resolved resolved (real coords) or raw (node-ID lists)
--clip <bbox> / --poly <file> — region clip; requires resolved geometry
--skip-changesets false skip the changeset prologue
--version, -V — print osm-katana v<semver> (<git-hash>) and exit — the hash build.rs read off .git at build time; run it before quoting any number from a binary

Notes on the guardrails (all enforced in convert_inner):

Tune

Related subcommands

osm-katana ships sibling verbs for partial conversions (osm-katana/CLAUDE.md): xml2pbf (OSM XML/BZ2 → PBF), pbf2geo (PBF → GeoParquet), geo2arrow (GeoParquet → Arrow IPC), verify (inspect/verify output) and census (below). This guide covers convert, the end-to-end path.

What is actually in these tables? — census

Reach for this instead of writing a script. verify answers is this fit to serve?; census answers what is in it? — and it answers with books that close, so the number you quote cannot have quietly lost part of its population.

osm-katana census ~/.korp/work/sweden                    # the four extract tables
osm-katana census ~/.korp/work/sweden/tiles --pyramid    # 1.6 M tiles, footer only
nodes.parquet  files    1 · bytes 709045761 (0.71 GB) · rows 103488583 · row groups 217
                 rows cross-check: footer num_rows 103488583 vs row-group sum 103488583 — AGREE
                 column bytes: 707771791 compressed / 3618034439 uncompressed
                   geometry   514933550 B compressed (514933550 of 707771791 (72.8 %)) …
                 tags: 103488583 rows = tagged 2406592 of 103488583 (2.3 %)
                       + untagged: {} 101081991 of 103488583 (97.7 %) · UNACCOUNTED 0
VERDICT: books close, every population accounted for.

Read UNACCOUNTED first. It is on every population line, it is the file's own footer num_rows minus what the buckets claim, and if it is not 0 the verb prints no percentage on that line at all and exits non-zero. Every ratio carries its denominator in the same string — 101081991 of 103488583 (97.7 %), never a bare 97.7 %.

flag what it adds what it reads
(none) rows, bytes, row groups, column bytes, covering extents, recipe token footers only
--pyramid one group per z<N>; layout + address checks footers only
--tags tagged / untagged, all three spellings (NULL, empty, {}) the tags column
--geometry Point / LineString / Polygon / Multi* histogram, + geometry × tags the geometry column (the fat one)
--classes per-class breakdown via the ONE classifier the tags column
--json <p> the machine-readable census, with input identities —

Timings on oden (32 cores, page cache warm), the live Sweden pyramid of 1 635 971 tiles / 44.45 GB: footer-only 11.4 s, --tags 11.3 s, --tags --geometry 23.7 s. The four extract tables with --tags: 3.5 s over 127 720 403 rows. If a census ever takes minutes, it is because you asked for --geometry.

When it refuses

It exits non-zero, and says which of these it is:

Quote the JSON, not the terminal

--json writes the whole census plus the identity of what was measured: every input's path relative to the root, its bytes, its mtime, and the osm-katana:tile-recipe token from its footer. A number without provenance is how the 57.0 % survived for a week in two design documents. 53 KB for the whole Sweden pyramid.

Proving the guard works

OSM_KATANA_CENSUS_SABOTAGE=drop-tagged osm-katana census <dir> --pyramid --tags; echo $?

It drops a whole population inside the real counting loop. Expect UNACCOUNTED non-zero, every % gone from that line, VERDICT: REFUSED and exit 1. Arms: drop-tagged, drop-points, inflate-total.

buildings — the height table

osm-katana buildings <extract-dir> -o <out-dir>

Cuts buildings.parquet + buildings.json out of ways.parquet + relations.resolved.parquet: one row per building=* / building:part=* feature, with height_m, min_height_m, levels and a height_source stamp. Hilbert-packed with covering.bbox through the same spatial::pack_batches the networks use, so korp reads it through the same seam. Write it BESIDE a served pyramid, never into it.

Read the histogram it prints — that is the point of the verb. Sweden, 2026-08-31, 3 841 483 rows / 297.5 MB / 16.0 s (154.3 MB / 11.3 s with --no-generalise, which drops DELTA 2's geometry_z6/z9/z12 — +92.8 % of the file is those three columns):

           absent                 3675499  95.6792 %
           tagged_metres             9551   0.2486 %
           tagged_feet                  1   0.0000 %
           derived_from_levels     156167   4.0653 %
           rejected                   265   0.0069 %

95.68 % of Swedish buildings have no height in OSM at all. derived_from_levels is building:levels × 3.2 m — an assumption with a ±20 % median error, which is why it is a separate bucket from tagged_metres rather than the same f32. rejected means a tag WAS there and could not be used (height=1;12, height=B): if that number jumps, the parser regressed — the mappers did not. Every number is in buildings.json too, so an operator never has to open the parquet. See .nornir/osm-katana-v2-design.md § DELTA 1b.

Did my change alter the output? — verify --digest

sha256 nodes.parquet is not the check: row groups are encoded on the workers and their boundaries move run-to-run, and the tail row groups land in worker-completion order, so two runs of the same binary give different bytes. Compare the content digest instead:

osm-katana convert in.osm.pbf -o out-before && osm-katana verify out-before --digest
# …change something…
osm-katana convert in.osm.pbf -o out-after  && osm-katana verify out-after  --digest
       nodes.parquet:    353167 rows  set 16a8386cf4b7ad76  ord 057676bc3141a944
        ways.parquet:     41702 rows  set 02ed64602fadfe31  ord 1a55d7e5a445c7a6
   relations.parquet:       938 rows  set 119bffec8cc479f0  ord 03cc98ab2913b696

Compare the set column (and the row count): it is the order-independent multiset digest, so it ignores the layout/order the pipeline legitimately varies while still catching any changed, dropped, added or duplicated row. ord additionally pins row order — use it when a change is expected to preserve order too. And cmp a/node_coords.arrow b/node_coords.arrow: that file is byte-identical.

Bench

Driven by nornir through the shared harness. Honest all-core numbers land in the README.md zoomies-katana mashup after the quiet-box planet run reaches the warehouse. Run nornir bench run znippy-zoomies, then nornir docs book znippy-zoomies to fill the mashup.

PREVIEW — the seconds-turnaround lens for the optimise loop

The heavy bake-off builds a 1 GB corpus in every rival format before it gets anywhere near osm-katana. NORNIR_ZOOMIES_CORPUS_MB=0 means "no decompressor corpus", so the decoder roster is empty and only the osm-katana arm runs:

NORNIR_ZOOMIES_CORPUS_MB=0 NORNIR_ZOOMIES_WORK=/tmp/zb \
  cargo run --release --example nornir-bench           # uses ~/work/liechtenstein-latest.osm.pbf

NORNIR_ZOOMIES_CORPUS_MB=0 NORNIR_ZOOMIES_WORK=/tmp/zb \
NORNIR_OSM_KATANA_INPUT=/path/big.osm.pbf NORNIR_ZOOMIES_ITERS=3 \
  cargo run --release --example nornir-bench

Same code path, same rows, same metric keys as the heavy run — including the per-phase occupancy rows and the digest gate — just small. It prints, per phase:

osm-katana per-phase core occupancy (fastest iteration):
  pass1                1.145s   16.35 / 32 cores  busy  51.1%  collector  55.3%
  sort_and_stree       0.450s    4.74 / 32 cores  busy  14.8%  collector   0.0%
  pass2                1.346s   20.70 / 32 cores  busy  64.7%  collector  13.5%
  convert              2.963s   16.45 / 32 cores  busy  51.4%  collector   0.0%
osm-katana output digest (3 runs): nodes.parquet=…/35316700 …  → STABLE

A run counts as PREVIEW when the corpus is under the heavy 1024 MB default or the osm-katana input is under 1024 MB, and a preview never emits the BenchRun contract line — stdout says PREVIEW RUN, the numbers go to stderr. So nornir bench run cannot warehouse a light number and it can never replace a real host row in a README table. There is no override; run it heavy to publish.