Multimodal freight routing engine — road, rail, waterway, sea
Project description
Multimodal Router
A freight routing engine that finds and compares routes across road, ferry, rail, sea, and inland waterway networks. Built for humanitarian logistics, designed to run anywhere OpenStreetMap covers — and to deploy on Palantir Foundry as batch transforms plus an interactive service.
Given an origin and destination, the engine returns ranked alternatives ("possible routes", not one answer): the truck route, the rail option, the sea option, the ferry crossing — each with distance, time, and cost, with a leg-by-leg breakdown and geometry. Field users can edit the network (close a road, flag a slow segment, avoid a border crossing) and reroutes honor the edit in milliseconds.
The core idea: one topology, many metrics
The engine is built on Customizable Contraction Hierarchies (CCH, via pyroutingkit). CCH splits work into three phases:
| Phase | Cost | When |
|---|---|---|
| Topology preprocessing (nested dissection) | seconds–minutes | once per graph build, cached to disk |
| Metric customization (apply a weight vector) | seconds | per profile, lazily |
| Partial customization (change a few weights) | milliseconds | per field edit |
| Query | microseconds–ms | per route |
Every product feature is the same operation — a weight vector over the frozen arc order of one unified multimodal graph:
mode filter ("trucks only") -> excluded modes get INF weight
avoid border crossing -> arcs at that crossing get INF
avoid area (polygon) -> arcs inside it get INF
field edit (closed road, speed) -> partial weight update, ~ms
impedance distance | time | cost -> which base value is quantized
Nothing is precomputed against fixed weights, so edits, avoids, and mode filters are always honored. There is no gateway path cache to invalidate.
Alternatives model
[road] road + ro-ro ferry metric, end to end — the truck option
[road_no_ferry] shown when the truck route rides a ferry
[multimodal] everything the request allows — the unconstrained optimum
[via_rail] first/last mile by road, the haul on a rail-only metric
[via_sea] …on a sea-only metric (seaport to seaport)
[via_ferry] …explicit ferry crossing
[via_inland_waterway] …barge corridor
Via-options are diverse by construction — the rail option genuinely rides rail between real terminals. Mode changes happen only at gateways (ports, rail terminals, ferry docks) through explicit transfer edges that carry the dwell time (port handling 48 h, rail terminal 12 h, ro-ro 2 h…). Line-haul edges carry pure travel time, so nothing is double-counted. All gateways attach to the road network; alternatives are ranked by the requested impedance and all three totals are always reported.
Field edits (overrides)
Overrides live in their own Parquet dataset keyed by stable OSM identity
(way_id, osm_from, osm_to), so they survive monthly OSM refreshes and
graph rebuilds:
engine.set_overrides([
EdgeOverride(override_id="fld-001", action="close", way_id=478662651,
note="ferry suspended", author="field-team"),
EdgeOverride(override_id="fld-002", action="speed_kmh", value=15,
way_id=22397122, note="washboard surface"),
]) # applies to every live metric via partial customization, ~100ms
Actions: close, speed_kmh, factor. The same dataset is an input to
the batch pipeline (05_route_pairs.py --overrides …).
Pipeline (Parquet in, Parquet out — Foundry-transform shaped)
01_load_osm.py PBF + Natural Earth -> country-tagged OSM Parquets
(roads, rail, waterways, ferries, terminals)
filter_osm.py global -> per-country/region subsets (keeps countries)
02_build_road.py ways -> road_edges/road_nodes (speeds, oneway, way_id)
03_build_modal.py rail / waterway / sea (searoute) / ferry networks
04_build_unified.py ONE graph: contiguous int32 vertex/arc ids,
transfer edges at gateways, border-crossing detection,
per-arc geometry. Row order == arc order, FROZEN.
05_route_pairs.py batch O/D list -> one row PER ALTERNATIVE
(rank, label, km, hours, cost, crossings, WKT)
run_all.py orchestrator: ingest -> per-country/region -> merge
The unified graph (stage 04) is the single artifact the engine consumes:
unified_nodes, unified_edges, unified_gateways, unified_crossings.
The CCH topology cache is built next to it on first use.
Quick start
git clone <repo-url> && cd hum-router
uv sync --all-extras
# data: an OSM PBF (planet or regional extract) + Natural Earth boundaries
uv run python scripts/download_natural_earth.py
# ingest + build + route one region
uv run python pipeline/01_load_osm.py --pbf data/east_africa.osm.pbf \
--country ET,DJ --output-dir pipeline_output/01_osm_global
uv run python pipeline/filter_osm.py --source-dir pipeline_output/01_osm_global \
--countries ET,DJ --output-dir pipeline_output/regions/et_dj/01_osm_raw
uv run python pipeline/02_build_road.py --input-dir pipeline_output/regions/et_dj/01_osm_raw --output-dir pipeline_output/regions/et_dj/02_road_network
uv run python pipeline/03_build_modal.py --input-dir pipeline_output/regions/et_dj/01_osm_raw --output-dir pipeline_output/regions/et_dj/03_modal_networks
uv run python pipeline/04_build_unified.py --road-dir pipeline_output/regions/et_dj/02_road_network --modal-dir pipeline_output/regions/et_dj/03_modal_networks --output-dir pipeline_output/regions/et_dj/04_unified
uv run python pipeline/05_route_pairs.py --graph-dir pipeline_output/regions/et_dj/04_unified --country ET,DJ --limit 50
Interactive map app
uv run python -m multimodal_router.app \
--graph-dir pipeline_output/regions/et_dj/04_unified
# open http://127.0.0.1:8000
Click to set origin/destination/waypoints; pick impedance and modes;
alternatives render as colored lines with side-by-side km/hours/cost cards.
Border crossings are clickable to avoid. Close segment / Slow
segment apply field edits to the live engine (~100 ms) and persist to
<graph_dir>/edge_overrides.parquet — the same file 05_route_pairs.py --overrides accepts, so the app and batch runs share one picture of the
network.
Engine API
from multimodal_router.engine import RoutingEngine, RouteRequest, EdgeOverride
engine = RoutingEngine("pipeline_output/regions/et_dj/04_unified")
result = engine.route(RouteRequest(
o_lon=38.74, o_lat=9.03, # Addis Ababa
d_lon=43.145, d_lat=11.595, # Djibouti City
impedance="time", # distance | time | cost
modes=frozenset({"road", "ferry", "rail", "sea"}),
waypoints=[(41.0, 9.4)], # must pass through
avoid_crossings=frozenset({17}), # skip Galafi border post
))
for opt in result.options:
print(opt.label, opt.total_distance_km, opt.total_time_hours,
opt.total_cost_usd, opt.crossings_used)
for leg in opt.legs:
print(" ", leg.mode, leg.distance_km, leg.time_hours, leg.geometry_wkt[:60])
Measured performance (10-core / 64 GB laptop)
| Operation | ET+DJ region (2.8M nodes, 5.6M arcs) |
|---|---|
| Stage 01 ingest (4.1 GB East Africa PBF, 10 countries) | ~2.5 min |
| Stages 02–04 build | ~1 min |
| CCH topology build (first run, then cached) | ~25 s |
| Engine start with cached topology | ~10 s |
| Metric customization (per profile, lazy) | ~1 s |
| Route query (warm, with alternatives) | ~10 ms |
| Field-edit override apply (3 live metrics) | ~150 ms |
| Batch routing | ~50+ pairs/s |
Single node, fits comfortably in 8 cores / 64 GB — the Foundry target.
Configuration
config/routing.yaml — speeds per mode/class, ferry default speed,
transfer dwell hours per mode pair, intermodal connection radii.
config/country_modes.yaml — which modes each country gets
(waterway only where there are navigable corridors).
config/seed_ports.yaml — curated seaports, inland ports, rail terminals.
Cost defaults (USD/km by mode + per-transfer fees) live in
engine/profiles.py and can be overridden via RoutingEngine(cost_config=…).
Tests
uv run pytest tests/ -m "not slow" # ~120 tests
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file hum_router-0.2.0.tar.gz.
File metadata
- Download URL: hum_router-0.2.0.tar.gz
- Upload date:
- Size: 204.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
15f39572127bb3b57ca3dac84f528efb8bc6743931d1611f03fc5f1c9a0e5d5b
|
|
| MD5 |
610dfea99b4c0b6ff80499e5f7644e61
|
|
| BLAKE2b-256 |
1e1ae4e95172fecd0c2179e49c91f86ea15e256137db6fe1e5daa7cd0dea3d04
|
Provenance
The following attestation bundles were made for hum_router-0.2.0.tar.gz:
Publisher:
publish.yml on nullbutt/hum-router
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hum_router-0.2.0.tar.gz -
Subject digest:
15f39572127bb3b57ca3dac84f528efb8bc6743931d1611f03fc5f1c9a0e5d5b - Sigstore transparency entry: 1781833493
- Sigstore integration time:
-
Permalink:
nullbutt/hum-router@623c04cc5ba13802a0b87372db83be27fee48499 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/nullbutt
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@623c04cc5ba13802a0b87372db83be27fee48499 -
Trigger Event:
push
-
Statement type:
File details
Details for the file hum_router-0.2.0-py3-none-any.whl.
File metadata
- Download URL: hum_router-0.2.0-py3-none-any.whl
- Upload date:
- Size: 72.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0c0e20bb6c93fbbcde53147670f1f2d9686aea49e605c3acf52dee28e4228a76
|
|
| MD5 |
0f00bd6e9c0ff861ad9b5b88c2ebc313
|
|
| BLAKE2b-256 |
ff7e707d38369497d6c7170d41f18d8fc4899525c9a86cb93f356c0ed32a291b
|
Provenance
The following attestation bundles were made for hum_router-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on nullbutt/hum-router
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hum_router-0.2.0-py3-none-any.whl -
Subject digest:
0c0e20bb6c93fbbcde53147670f1f2d9686aea49e605c3acf52dee28e4228a76 - Sigstore transparency entry: 1781833615
- Sigstore integration time:
-
Permalink:
nullbutt/hum-router@623c04cc5ba13802a0b87372db83be27fee48499 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/nullbutt
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@623c04cc5ba13802a0b87372db83be27fee48499 -
Trigger Event:
push
-
Statement type: