hum-router 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. hum_router-0.2.0/.github/workflows/build.yml +32 -0
  2. hum_router-0.2.0/.github/workflows/publish.yml +47 -0
  3. hum_router-0.2.0/.gitignore +30 -0
  4. hum_router-0.2.0/.python-version +1 -0
  5. hum_router-0.2.0/PKG-INFO +223 -0
  6. hum_router-0.2.0/README.md +193 -0
  7. hum_router-0.2.0/foundry_deployment/README.md +117 -0
  8. hum_router-0.2.0/foundry_deployment/compute_module/Dockerfile +33 -0
  9. hum_router-0.2.0/foundry_deployment/compute_module/README.md +82 -0
  10. hum_router-0.2.0/foundry_deployment/compute_module/app.py +211 -0
  11. hum_router-0.2.0/foundry_deployment/compute_module/bootstrap.py +101 -0
  12. hum_router-0.2.0/foundry_deployment/meta.yml +28 -0
  13. hum_router-0.2.0/foundry_deployment/ontology.md +109 -0
  14. hum_router-0.2.0/foundry_deployment/transforms/__init__.py +0 -0
  15. hum_router-0.2.0/foundry_deployment/transforms/deployment.py +56 -0
  16. hum_router-0.2.0/foundry_deployment/transforms/io_helpers.py +79 -0
  17. hum_router-0.2.0/foundry_deployment/transforms/t1_osm_ingest.py +51 -0
  18. hum_router-0.2.0/foundry_deployment/transforms/t2_build_region.py +72 -0
  19. hum_router-0.2.0/foundry_deployment/transforms/t3_route_pairs.py +110 -0
  20. hum_router-0.2.0/foundry_deployment/transforms/t4_merge_results.py +26 -0
  21. hum_router-0.2.0/pipeline/01_load_osm.py +153 -0
  22. hum_router-0.2.0/pipeline/02_build_road.py +98 -0
  23. hum_router-0.2.0/pipeline/03_build_modal.py +161 -0
  24. hum_router-0.2.0/pipeline/04_build_unified.py +105 -0
  25. hum_router-0.2.0/pipeline/05_route_pairs.py +139 -0
  26. hum_router-0.2.0/pipeline/__init__.py +0 -0
  27. hum_router-0.2.0/pipeline/config.py +94 -0
  28. hum_router-0.2.0/pipeline/filter_osm.py +193 -0
  29. hum_router-0.2.0/pipeline/helpers.py +126 -0
  30. hum_router-0.2.0/pipeline/merge_results.py +77 -0
  31. hum_router-0.2.0/pipeline/run_all.py +458 -0
  32. hum_router-0.2.0/pipeline/visualize_routes.py +353 -0
  33. hum_router-0.2.0/pyproject.toml +65 -0
  34. hum_router-0.2.0/scripts/download_natural_earth.py +80 -0
  35. hum_router-0.2.0/scripts/download_planet.py +93 -0
  36. hum_router-0.2.0/src/multimodal_router/__init__.py +2 -0
  37. hum_router-0.2.0/src/multimodal_router/app/__init__.py +1 -0
  38. hum_router-0.2.0/src/multimodal_router/app/__main__.py +34 -0
  39. hum_router-0.2.0/src/multimodal_router/app/server.py +285 -0
  40. hum_router-0.2.0/src/multimodal_router/app/static/index.html +334 -0
  41. hum_router-0.2.0/src/multimodal_router/build/__init__.py +0 -0
  42. hum_router-0.2.0/src/multimodal_router/build/ferry_builder.py +221 -0
  43. hum_router-0.2.0/src/multimodal_router/build/rail_builder.py +237 -0
  44. hum_router-0.2.0/src/multimodal_router/build/road_builder.py +174 -0
  45. hum_router-0.2.0/src/multimodal_router/build/sea_builder.py +155 -0
  46. hum_router-0.2.0/src/multimodal_router/build/topology.py +216 -0
  47. hum_router-0.2.0/src/multimodal_router/build/unified_graph.py +507 -0
  48. hum_router-0.2.0/src/multimodal_router/build/waterway_builder.py +276 -0
  49. hum_router-0.2.0/src/multimodal_router/config/country_modes.yaml +64 -0
  50. hum_router-0.2.0/src/multimodal_router/config/routing.yaml +46 -0
  51. hum_router-0.2.0/src/multimodal_router/config/seed_ports.yaml +83 -0
  52. hum_router-0.2.0/src/multimodal_router/engine/__init__.py +22 -0
  53. hum_router-0.2.0/src/multimodal_router/engine/engine.py +558 -0
  54. hum_router-0.2.0/src/multimodal_router/engine/graph.py +224 -0
  55. hum_router-0.2.0/src/multimodal_router/engine/overrides.py +168 -0
  56. hum_router-0.2.0/src/multimodal_router/engine/profiles.py +202 -0
  57. hum_router-0.2.0/src/multimodal_router/engine/types.py +80 -0
  58. hum_router-0.2.0/src/multimodal_router/io/__init__.py +0 -0
  59. hum_router-0.2.0/src/multimodal_router/io/country_tagger.py +204 -0
  60. hum_router-0.2.0/src/multimodal_router/io/osm_loader.py +26 -0
  61. hum_router-0.2.0/src/multimodal_router/io/osm_planet_loader.py +476 -0
  62. hum_router-0.2.0/src/multimodal_router/io/seed_data.py +70 -0
  63. hum_router-0.2.0/src/multimodal_router/models.py +106 -0
  64. hum_router-0.2.0/src/multimodal_router/py.typed +0 -0
  65. hum_router-0.2.0/src/multimodal_router/settings.py +158 -0
  66. hum_router-0.2.0/src/multimodal_router/stages.py +403 -0
  67. hum_router-0.2.0/src/multimodal_router/utils/__init__.py +0 -0
  68. hum_router-0.2.0/src/multimodal_router/utils/duckdb_helpers.py +180 -0
  69. hum_router-0.2.0/src/multimodal_router/utils/geo.py +65 -0
  70. hum_router-0.2.0/tests/__init__.py +0 -0
  71. hum_router-0.2.0/tests/conftest.py +13 -0
  72. hum_router-0.2.0/tests/test_app.py +112 -0
  73. hum_router-0.2.0/tests/test_country_tagger.py +210 -0
  74. hum_router-0.2.0/tests/test_duckdb_helpers.py +108 -0
  75. hum_router-0.2.0/tests/test_engine.py +292 -0
  76. hum_router-0.2.0/tests/test_geo.py +79 -0
  77. hum_router-0.2.0/tests/test_models.py +129 -0
  78. hum_router-0.2.0/tests/test_osm_loader.py +110 -0
  79. hum_router-0.2.0/tests/test_rail_builder.py +135 -0
  80. hum_router-0.2.0/tests/test_road_builder.py +161 -0
  81. hum_router-0.2.0/tests/test_sea_builder.py +92 -0
  82. hum_router-0.2.0/tests/test_seed_data.py +68 -0
  83. hum_router-0.2.0/tests/test_stages.py +92 -0
  84. hum_router-0.2.0/tests/test_topology.py +211 -0
  85. hum_router-0.2.0/tests/test_waterway_builder.py +108 -0
  86. hum_router-0.2.0/uv.lock +1660 -0
@@ -0,0 +1,32 @@
1
+ name: Build & Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ${{ matrix.os }}
12
+ strategy:
13
+ matrix:
14
+ os: [ubuntu-latest, macos-latest]
15
+ python-version: ["3.12"]
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Install uv
21
+ uses: astral-sh/setup-uv@v4
22
+
23
+ - name: Set up Python ${{ matrix.python-version }}
24
+ run: uv python install ${{ matrix.python-version }}
25
+
26
+ # --no-sources ignores the local-path pyroutingkit override
27
+ # (dev convenience only) and resolves it from PyPI instead.
28
+ - name: Install dependencies
29
+ run: uv sync --all-extras --no-sources
30
+
31
+ - name: Run tests
32
+ run: uv run --no-sources pytest tests/ -m "not slow" -v --tb=short
@@ -0,0 +1,47 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v4
14
+ - run: uv python install 3.12
15
+ - run: uv sync --all-extras --no-sources
16
+ - run: uv run --no-sources pytest tests/ -m "not slow" --tb=short
17
+
18
+ build-and-publish:
19
+ name: Build & publish to PyPI
20
+ runs-on: ubuntu-latest
21
+ needs: test
22
+ permissions:
23
+ id-token: write # trusted publishing (OIDC)
24
+ contents: read # declaring permissions zeroes the rest; checkout needs this
25
+
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: astral-sh/setup-uv@v4
29
+
30
+ - name: Build wheel + sdist (pure Python)
31
+ run: uv build
32
+
33
+ # Regression guard: non-.py package data must ship in the wheel
34
+ # (config YAMLs and the app frontend were once lost to .gitignore).
35
+ - name: Verify wheel contents
36
+ run: |
37
+ unzip -l dist/*.whl | grep -q "multimodal_router/config/routing.yaml"
38
+ unzip -l dist/*.whl | grep -q "multimodal_router/config/country_modes.yaml"
39
+ unzip -l dist/*.whl | grep -q "multimodal_router/config/seed_ports.yaml"
40
+ unzip -l dist/*.whl | grep -q "multimodal_router/app/static/index.html"
41
+ echo "wheel contents OK"
42
+
43
+ - name: Publish to PyPI
44
+ uses: pypa/gh-action-pypi-publish@release/v1
45
+ # Trusted publishing (OIDC) — configure at
46
+ # https://pypi.org/manage/account/publishing/
47
+ # Publisher: GitHub, repo: nullbutt/hum-router, workflow: publish.yml
@@ -0,0 +1,30 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ /build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # OSM data files
13
+ data/
14
+ *.osm.pbf
15
+
16
+ # C++ build artifacts
17
+ routingkit_wrapper/build/
18
+ *.so
19
+ *.dylib
20
+
21
+ # Generated visualizations
22
+ *.html
23
+ *.png
24
+
25
+ # Pipeline output (generated artifacts)
26
+ pipeline_output*/
27
+
28
+ # macOS
29
+ .DS_Store
30
+ !src/multimodal_router/app/static/*.html
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,223 @@
1
+ Metadata-Version: 2.4
2
+ Name: hum-router
3
+ Version: 0.2.0
4
+ Summary: Multimodal freight routing engine — road, rail, waterway, sea
5
+ License-Expression: MIT
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Programming Language :: Python :: 3.12
8
+ Classifier: Topic :: Scientific/Engineering :: GIS
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: apache-sedona[db]
11
+ Requires-Dist: duckdb==1.5.0
12
+ Requires-Dist: fastapi>=0.115
13
+ Requires-Dist: geopandas>=1.1
14
+ Requires-Dist: igraph>=1.0.0
15
+ Requires-Dist: matplotlib>=3.10.8
16
+ Requires-Dist: polars==1.29.0
17
+ Requires-Dist: pyarrow>=23.0
18
+ Requires-Dist: pyroutingkit>=0.2.1
19
+ Requires-Dist: pyyaml>=6.0
20
+ Requires-Dist: scipy>=1.11
21
+ Requires-Dist: searoute>=1.5
22
+ Requires-Dist: snkit>=1.9
23
+ Requires-Dist: uvicorn>=0.32
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy; extra == 'dev'
26
+ Requires-Dist: pytest-benchmark; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # Multimodal Router
32
+
33
+ A freight routing engine that finds and compares routes across **road, ferry,
34
+ rail, sea, and inland waterway** networks. Built for humanitarian logistics,
35
+ designed to run anywhere OpenStreetMap covers — and to deploy on Palantir
36
+ Foundry as batch transforms plus an interactive service.
37
+
38
+ Given an origin and destination, the engine returns **ranked alternatives**
39
+ ("possible routes", not one answer): the truck route, the rail option, the
40
+ sea option, the ferry crossing — each with distance, time, and cost, with a
41
+ leg-by-leg breakdown and geometry. Field users can **edit the network**
42
+ (close a road, flag a slow segment, avoid a border crossing) and reroutes
43
+ honor the edit in milliseconds.
44
+
45
+ ## The core idea: one topology, many metrics
46
+
47
+ The engine is built on Customizable Contraction Hierarchies (CCH, via
48
+ [pyroutingkit](https://github.com/nullbutt/pyroutingkit)). CCH splits work
49
+ into three phases:
50
+
51
+ | Phase | Cost | When |
52
+ |-------|------|------|
53
+ | Topology preprocessing (nested dissection) | seconds–minutes | once per graph build, cached to disk |
54
+ | Metric customization (apply a weight vector) | seconds | per profile, lazily |
55
+ | Partial customization (change a few weights) | milliseconds | per field edit |
56
+ | Query | microseconds–ms | per route |
57
+
58
+ Every product feature is the same operation — a weight vector over the
59
+ frozen arc order of one unified multimodal graph:
60
+
61
+ ```
62
+ mode filter ("trucks only") -> excluded modes get INF weight
63
+ avoid border crossing -> arcs at that crossing get INF
64
+ avoid area (polygon) -> arcs inside it get INF
65
+ field edit (closed road, speed) -> partial weight update, ~ms
66
+ impedance distance | time | cost -> which base value is quantized
67
+ ```
68
+
69
+ Nothing is precomputed against fixed weights, so edits, avoids, and mode
70
+ filters are always honored. There is no gateway path cache to invalidate.
71
+
72
+ ## Alternatives model
73
+
74
+ ```
75
+ [road] road + ro-ro ferry metric, end to end — the truck option
76
+ [road_no_ferry] shown when the truck route rides a ferry
77
+ [multimodal] everything the request allows — the unconstrained optimum
78
+ [via_rail] first/last mile by road, the haul on a rail-only metric
79
+ [via_sea] …on a sea-only metric (seaport to seaport)
80
+ [via_ferry] …explicit ferry crossing
81
+ [via_inland_waterway] …barge corridor
82
+ ```
83
+
84
+ Via-options are diverse *by construction* — the rail option genuinely rides
85
+ rail between real terminals. Mode changes happen only at gateways (ports,
86
+ rail terminals, ferry docks) through explicit transfer edges that carry the
87
+ dwell time (port handling 48 h, rail terminal 12 h, ro-ro 2 h…). Line-haul
88
+ edges carry pure travel time, so nothing is double-counted. All gateways
89
+ attach to the road network; alternatives are ranked by the requested
90
+ impedance and all three totals are always reported.
91
+
92
+ ## Field edits (overrides)
93
+
94
+ Overrides live in their own Parquet dataset keyed by **stable OSM identity**
95
+ `(way_id, osm_from, osm_to)`, so they survive monthly OSM refreshes and
96
+ graph rebuilds:
97
+
98
+ ```python
99
+ engine.set_overrides([
100
+ EdgeOverride(override_id="fld-001", action="close", way_id=478662651,
101
+ note="ferry suspended", author="field-team"),
102
+ EdgeOverride(override_id="fld-002", action="speed_kmh", value=15,
103
+ way_id=22397122, note="washboard surface"),
104
+ ]) # applies to every live metric via partial customization, ~100ms
105
+ ```
106
+
107
+ Actions: `close`, `speed_kmh`, `factor`. The same dataset is an input to
108
+ the batch pipeline (`05_route_pairs.py --overrides …`).
109
+
110
+ ## Pipeline (Parquet in, Parquet out — Foundry-transform shaped)
111
+
112
+ ```
113
+ 01_load_osm.py PBF + Natural Earth -> country-tagged OSM Parquets
114
+ (roads, rail, waterways, ferries, terminals)
115
+ filter_osm.py global -> per-country/region subsets (keeps countries)
116
+ 02_build_road.py ways -> road_edges/road_nodes (speeds, oneway, way_id)
117
+ 03_build_modal.py rail / waterway / sea (searoute) / ferry networks
118
+ 04_build_unified.py ONE graph: contiguous int32 vertex/arc ids,
119
+ transfer edges at gateways, border-crossing detection,
120
+ per-arc geometry. Row order == arc order, FROZEN.
121
+ 05_route_pairs.py batch O/D list -> one row PER ALTERNATIVE
122
+ (rank, label, km, hours, cost, crossings, WKT)
123
+ run_all.py orchestrator: ingest -> per-country/region -> merge
124
+ ```
125
+
126
+ The unified graph (stage 04) is the single artifact the engine consumes:
127
+ `unified_nodes`, `unified_edges`, `unified_gateways`, `unified_crossings`.
128
+ The CCH topology cache is built next to it on first use.
129
+
130
+ ## Quick start
131
+
132
+ ```bash
133
+ git clone <repo-url> && cd hum-router
134
+ uv sync --all-extras
135
+
136
+ # data: an OSM PBF (planet or regional extract) + Natural Earth boundaries
137
+ uv run python scripts/download_natural_earth.py
138
+
139
+ # ingest + build + route one region
140
+ uv run python pipeline/01_load_osm.py --pbf data/east_africa.osm.pbf \
141
+ --country ET,DJ --output-dir pipeline_output/01_osm_global
142
+ uv run python pipeline/filter_osm.py --source-dir pipeline_output/01_osm_global \
143
+ --countries ET,DJ --output-dir pipeline_output/regions/et_dj/01_osm_raw
144
+ 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
145
+ 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
146
+ 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
147
+ uv run python pipeline/05_route_pairs.py --graph-dir pipeline_output/regions/et_dj/04_unified --country ET,DJ --limit 50
148
+ ```
149
+
150
+ ## Interactive map app
151
+
152
+ ```bash
153
+ uv run python -m multimodal_router.app \
154
+ --graph-dir pipeline_output/regions/et_dj/04_unified
155
+ # open http://127.0.0.1:8000
156
+ ```
157
+
158
+ Click to set origin/destination/waypoints; pick impedance and modes;
159
+ alternatives render as colored lines with side-by-side km/hours/cost cards.
160
+ Border crossings are clickable to avoid. **Close segment** / **Slow
161
+ segment** apply field edits to the live engine (~100 ms) and persist to
162
+ `<graph_dir>/edge_overrides.parquet` — the same file `05_route_pairs.py
163
+ --overrides` accepts, so the app and batch runs share one picture of the
164
+ network.
165
+
166
+ ## Engine API
167
+
168
+ ```python
169
+ from multimodal_router.engine import RoutingEngine, RouteRequest, EdgeOverride
170
+
171
+ engine = RoutingEngine("pipeline_output/regions/et_dj/04_unified")
172
+
173
+ result = engine.route(RouteRequest(
174
+ o_lon=38.74, o_lat=9.03, # Addis Ababa
175
+ d_lon=43.145, d_lat=11.595, # Djibouti City
176
+ impedance="time", # distance | time | cost
177
+ modes=frozenset({"road", "ferry", "rail", "sea"}),
178
+ waypoints=[(41.0, 9.4)], # must pass through
179
+ avoid_crossings=frozenset({17}), # skip Galafi border post
180
+ ))
181
+
182
+ for opt in result.options:
183
+ print(opt.label, opt.total_distance_km, opt.total_time_hours,
184
+ opt.total_cost_usd, opt.crossings_used)
185
+ for leg in opt.legs:
186
+ print(" ", leg.mode, leg.distance_km, leg.time_hours, leg.geometry_wkt[:60])
187
+ ```
188
+
189
+ ## Measured performance (10-core / 64 GB laptop)
190
+
191
+ | Operation | ET+DJ region (2.8M nodes, 5.6M arcs) |
192
+ |-----------|--------------------------------------|
193
+ | Stage 01 ingest (4.1 GB East Africa PBF, 10 countries) | ~2.5 min |
194
+ | Stages 02–04 build | ~1 min |
195
+ | CCH topology build (first run, then cached) | ~25 s |
196
+ | Engine start with cached topology | ~10 s |
197
+ | Metric customization (per profile, lazy) | ~1 s |
198
+ | Route query (warm, with alternatives) | ~10 ms |
199
+ | Field-edit override apply (3 live metrics) | ~150 ms |
200
+ | Batch routing | ~50+ pairs/s |
201
+
202
+ Single node, fits comfortably in 8 cores / 64 GB — the Foundry target.
203
+
204
+ ## Configuration
205
+
206
+ `config/routing.yaml` — speeds per mode/class, ferry default speed,
207
+ transfer dwell hours per mode pair, intermodal connection radii.
208
+ `config/country_modes.yaml` — which modes each country gets
209
+ (waterway only where there are navigable corridors).
210
+ `config/seed_ports.yaml` — curated seaports, inland ports, rail terminals.
211
+
212
+ Cost defaults (USD/km by mode + per-transfer fees) live in
213
+ `engine/profiles.py` and can be overridden via `RoutingEngine(cost_config=…)`.
214
+
215
+ ## Tests
216
+
217
+ ```bash
218
+ uv run pytest tests/ -m "not slow" # ~120 tests
219
+ ```
220
+
221
+ ## License
222
+
223
+ MIT
@@ -0,0 +1,193 @@
1
+ # Multimodal Router
2
+
3
+ A freight routing engine that finds and compares routes across **road, ferry,
4
+ rail, sea, and inland waterway** networks. Built for humanitarian logistics,
5
+ designed to run anywhere OpenStreetMap covers — and to deploy on Palantir
6
+ Foundry as batch transforms plus an interactive service.
7
+
8
+ Given an origin and destination, the engine returns **ranked alternatives**
9
+ ("possible routes", not one answer): the truck route, the rail option, the
10
+ sea option, the ferry crossing — each with distance, time, and cost, with a
11
+ leg-by-leg breakdown and geometry. Field users can **edit the network**
12
+ (close a road, flag a slow segment, avoid a border crossing) and reroutes
13
+ honor the edit in milliseconds.
14
+
15
+ ## The core idea: one topology, many metrics
16
+
17
+ The engine is built on Customizable Contraction Hierarchies (CCH, via
18
+ [pyroutingkit](https://github.com/nullbutt/pyroutingkit)). CCH splits work
19
+ into three phases:
20
+
21
+ | Phase | Cost | When |
22
+ |-------|------|------|
23
+ | Topology preprocessing (nested dissection) | seconds–minutes | once per graph build, cached to disk |
24
+ | Metric customization (apply a weight vector) | seconds | per profile, lazily |
25
+ | Partial customization (change a few weights) | milliseconds | per field edit |
26
+ | Query | microseconds–ms | per route |
27
+
28
+ Every product feature is the same operation — a weight vector over the
29
+ frozen arc order of one unified multimodal graph:
30
+
31
+ ```
32
+ mode filter ("trucks only") -> excluded modes get INF weight
33
+ avoid border crossing -> arcs at that crossing get INF
34
+ avoid area (polygon) -> arcs inside it get INF
35
+ field edit (closed road, speed) -> partial weight update, ~ms
36
+ impedance distance | time | cost -> which base value is quantized
37
+ ```
38
+
39
+ Nothing is precomputed against fixed weights, so edits, avoids, and mode
40
+ filters are always honored. There is no gateway path cache to invalidate.
41
+
42
+ ## Alternatives model
43
+
44
+ ```
45
+ [road] road + ro-ro ferry metric, end to end — the truck option
46
+ [road_no_ferry] shown when the truck route rides a ferry
47
+ [multimodal] everything the request allows — the unconstrained optimum
48
+ [via_rail] first/last mile by road, the haul on a rail-only metric
49
+ [via_sea] …on a sea-only metric (seaport to seaport)
50
+ [via_ferry] …explicit ferry crossing
51
+ [via_inland_waterway] …barge corridor
52
+ ```
53
+
54
+ Via-options are diverse *by construction* — the rail option genuinely rides
55
+ rail between real terminals. Mode changes happen only at gateways (ports,
56
+ rail terminals, ferry docks) through explicit transfer edges that carry the
57
+ dwell time (port handling 48 h, rail terminal 12 h, ro-ro 2 h…). Line-haul
58
+ edges carry pure travel time, so nothing is double-counted. All gateways
59
+ attach to the road network; alternatives are ranked by the requested
60
+ impedance and all three totals are always reported.
61
+
62
+ ## Field edits (overrides)
63
+
64
+ Overrides live in their own Parquet dataset keyed by **stable OSM identity**
65
+ `(way_id, osm_from, osm_to)`, so they survive monthly OSM refreshes and
66
+ graph rebuilds:
67
+
68
+ ```python
69
+ engine.set_overrides([
70
+ EdgeOverride(override_id="fld-001", action="close", way_id=478662651,
71
+ note="ferry suspended", author="field-team"),
72
+ EdgeOverride(override_id="fld-002", action="speed_kmh", value=15,
73
+ way_id=22397122, note="washboard surface"),
74
+ ]) # applies to every live metric via partial customization, ~100ms
75
+ ```
76
+
77
+ Actions: `close`, `speed_kmh`, `factor`. The same dataset is an input to
78
+ the batch pipeline (`05_route_pairs.py --overrides …`).
79
+
80
+ ## Pipeline (Parquet in, Parquet out — Foundry-transform shaped)
81
+
82
+ ```
83
+ 01_load_osm.py PBF + Natural Earth -> country-tagged OSM Parquets
84
+ (roads, rail, waterways, ferries, terminals)
85
+ filter_osm.py global -> per-country/region subsets (keeps countries)
86
+ 02_build_road.py ways -> road_edges/road_nodes (speeds, oneway, way_id)
87
+ 03_build_modal.py rail / waterway / sea (searoute) / ferry networks
88
+ 04_build_unified.py ONE graph: contiguous int32 vertex/arc ids,
89
+ transfer edges at gateways, border-crossing detection,
90
+ per-arc geometry. Row order == arc order, FROZEN.
91
+ 05_route_pairs.py batch O/D list -> one row PER ALTERNATIVE
92
+ (rank, label, km, hours, cost, crossings, WKT)
93
+ run_all.py orchestrator: ingest -> per-country/region -> merge
94
+ ```
95
+
96
+ The unified graph (stage 04) is the single artifact the engine consumes:
97
+ `unified_nodes`, `unified_edges`, `unified_gateways`, `unified_crossings`.
98
+ The CCH topology cache is built next to it on first use.
99
+
100
+ ## Quick start
101
+
102
+ ```bash
103
+ git clone <repo-url> && cd hum-router
104
+ uv sync --all-extras
105
+
106
+ # data: an OSM PBF (planet or regional extract) + Natural Earth boundaries
107
+ uv run python scripts/download_natural_earth.py
108
+
109
+ # ingest + build + route one region
110
+ uv run python pipeline/01_load_osm.py --pbf data/east_africa.osm.pbf \
111
+ --country ET,DJ --output-dir pipeline_output/01_osm_global
112
+ uv run python pipeline/filter_osm.py --source-dir pipeline_output/01_osm_global \
113
+ --countries ET,DJ --output-dir pipeline_output/regions/et_dj/01_osm_raw
114
+ 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
115
+ 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
116
+ 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
117
+ uv run python pipeline/05_route_pairs.py --graph-dir pipeline_output/regions/et_dj/04_unified --country ET,DJ --limit 50
118
+ ```
119
+
120
+ ## Interactive map app
121
+
122
+ ```bash
123
+ uv run python -m multimodal_router.app \
124
+ --graph-dir pipeline_output/regions/et_dj/04_unified
125
+ # open http://127.0.0.1:8000
126
+ ```
127
+
128
+ Click to set origin/destination/waypoints; pick impedance and modes;
129
+ alternatives render as colored lines with side-by-side km/hours/cost cards.
130
+ Border crossings are clickable to avoid. **Close segment** / **Slow
131
+ segment** apply field edits to the live engine (~100 ms) and persist to
132
+ `<graph_dir>/edge_overrides.parquet` — the same file `05_route_pairs.py
133
+ --overrides` accepts, so the app and batch runs share one picture of the
134
+ network.
135
+
136
+ ## Engine API
137
+
138
+ ```python
139
+ from multimodal_router.engine import RoutingEngine, RouteRequest, EdgeOverride
140
+
141
+ engine = RoutingEngine("pipeline_output/regions/et_dj/04_unified")
142
+
143
+ result = engine.route(RouteRequest(
144
+ o_lon=38.74, o_lat=9.03, # Addis Ababa
145
+ d_lon=43.145, d_lat=11.595, # Djibouti City
146
+ impedance="time", # distance | time | cost
147
+ modes=frozenset({"road", "ferry", "rail", "sea"}),
148
+ waypoints=[(41.0, 9.4)], # must pass through
149
+ avoid_crossings=frozenset({17}), # skip Galafi border post
150
+ ))
151
+
152
+ for opt in result.options:
153
+ print(opt.label, opt.total_distance_km, opt.total_time_hours,
154
+ opt.total_cost_usd, opt.crossings_used)
155
+ for leg in opt.legs:
156
+ print(" ", leg.mode, leg.distance_km, leg.time_hours, leg.geometry_wkt[:60])
157
+ ```
158
+
159
+ ## Measured performance (10-core / 64 GB laptop)
160
+
161
+ | Operation | ET+DJ region (2.8M nodes, 5.6M arcs) |
162
+ |-----------|--------------------------------------|
163
+ | Stage 01 ingest (4.1 GB East Africa PBF, 10 countries) | ~2.5 min |
164
+ | Stages 02–04 build | ~1 min |
165
+ | CCH topology build (first run, then cached) | ~25 s |
166
+ | Engine start with cached topology | ~10 s |
167
+ | Metric customization (per profile, lazy) | ~1 s |
168
+ | Route query (warm, with alternatives) | ~10 ms |
169
+ | Field-edit override apply (3 live metrics) | ~150 ms |
170
+ | Batch routing | ~50+ pairs/s |
171
+
172
+ Single node, fits comfortably in 8 cores / 64 GB — the Foundry target.
173
+
174
+ ## Configuration
175
+
176
+ `config/routing.yaml` — speeds per mode/class, ferry default speed,
177
+ transfer dwell hours per mode pair, intermodal connection radii.
178
+ `config/country_modes.yaml` — which modes each country gets
179
+ (waterway only where there are navigable corridors).
180
+ `config/seed_ports.yaml` — curated seaports, inland ports, rail terminals.
181
+
182
+ Cost defaults (USD/km by mode + per-transfer fees) live in
183
+ `engine/profiles.py` and can be overridden via `RoutingEngine(cost_config=…)`.
184
+
185
+ ## Tests
186
+
187
+ ```bash
188
+ uv run pytest tests/ -m "not slow" # ~120 tests
189
+ ```
190
+
191
+ ## License
192
+
193
+ MIT
@@ -0,0 +1,117 @@
1
+ # Foundry Deployment
2
+
3
+ The engine maps onto Palantir Foundry in three pieces — batch transforms,
4
+ ontology objects, and a compute module for interactive serving. Nothing in
5
+ `src/multimodal_router` imports Foundry: transforms and the compute module
6
+ call the wheel's `multimodal_router.stages` / `multimodal_router.engine`
7
+ APIs with plain local paths, and all configuration ships inside the wheel
8
+ (`multimodal_router.settings`).
9
+
10
+ ```
11
+ Monthly (OSM refresh) On demand
12
+ ───────────────────── ─────────
13
+ T1 osm_ingest T3 route_<region>
14
+ PBF + boundaries -> tagged Parquets graph + od_pairs +
15
+ │ EDGE_OVERRIDES -> alternatives
16
+ T2 build_<region> (parallel per region) │
17
+ -> graph files (unified_* + CCH topology) T4 merge -> route_results
18
+ -> gateways, border_crossings (tabular) │
19
+ │ ▼
20
+ └───────────► ONTOLOGY ◄───────────────────┘
21
+ RouteAlternative · Gateway · BorderCrossing · EdgeOverride
22
+
23
+ Workshop map ───┤ actions: Close/Reopen Segment…
24
+
25
+ COMPUTE MODULE (RoutingEngine resident, ~10 ms queries,
26
+ overrides re-applied via partial customization ~100 ms)
27
+ ```
28
+
29
+ ## Contents
30
+
31
+ ```
32
+ foundry_deployment/
33
+ ├── README.md this file
34
+ ├── ontology.md object types, actions, function wiring
35
+ ├── meta.yml package config for the transforms repo
36
+ ├── transforms/
37
+ │ ├── deployment.py EDIT ME: project path + enabled regions
38
+ │ ├── io_helpers.py the only file touching Foundry I/O APIs
39
+ │ ├── t1_osm_ingest.py
40
+ │ ├── t2_build_region.py one transform generated per region
41
+ │ ├── t3_route_pairs.py one transform generated per region
42
+ │ └── t4_merge_results.py
43
+ └── compute_module/
44
+ ├── app.py functions mode: route / locate_edge / network_meta
45
+ ├── bootstrap.py graph download via public datasets REST API
46
+ ├── Dockerfile
47
+ └── README.md
48
+ ```
49
+
50
+ ## Transform tiers (measured locally on equivalent hardware)
51
+
52
+ | Transform | Wraps (stages API) | Frequency | Duration | Memory |
53
+ |-----------|--------------------|-----------|----------|--------|
54
+ | T1 osm_ingest | `stages.ingest_osm` | monthly | ~3 min / 4 GB PBF | 64 GB |
55
+ | T2 build_<region> | `stages.filter_countries` + `stages.build_region` | monthly, parallel | ~2 min / 2.8M-node region | 64 GB |
56
+ | T3 route_<region> | `stages.route_pairs` | on demand | 50+ pairs/s after ~10 s engine start | 30 GB |
57
+ | T4 merge | concat | after T3 | seconds | 8 GB |
58
+
59
+ T2's output dataset holds files (unified Parquets + the CCH topology
60
+ binaries `tail/head/order/rank/node_count`), so T3 and the compute module
61
+ skip the expensive nested dissection.
62
+
63
+ ## Setup
64
+
65
+ 1. Build + upload wheels (`pyroutingkit` manylinux from its CI,
66
+ `hum-router` via `uv build`) to Foundry's package repository.
67
+ 2. Upload source datasets: the PBF (file dataset), Natural Earth
68
+ boundaries (file dataset with .shp + sidecars), O/D pairs (tabular).
69
+ 3. Create the **ontology-writable** `edge_overrides` dataset and the
70
+ object types/actions per `ontology.md`.
71
+ 4. Copy `transforms/` into a transforms-python repository; set the
72
+ project path + regions in `deployment.py`. The per-region T2/T3
73
+ transforms are generated from that one list.
74
+ 5. Build T1 → T2s; schedule monthly. T3/T4 run on demand (new O/D pairs
75
+ or changed overrides).
76
+ 6. Deploy the compute module (see `compute_module/README.md`) and wire the
77
+ Workshop map per `ontology.md`.
78
+
79
+ ## Do we need a compute module at all?
80
+
81
+ Alternatives considered, in order of how much interactivity survives:
82
+
83
+ 1. **No custom serving — precompute + ontology only.** T3 routes every
84
+ known O/D pair (alternatives included); Workshop just displays
85
+ RouteAlternative objects. An "on-demand" feel comes from the *Request
86
+ Routes* action appending to `od_pairs` and triggering T3 (minutes
87
+ latency). Zero containers, fully transform-native — but no
88
+ click-anywhere O/D, no waypoints, no instant what-if on edits. **This
89
+ works today and is the right fallback** if compute modules aren't
90
+ enabled on the stack; everything else in this deployment is unchanged.
91
+ 2. **Serverless Foundry Functions (Python/TS).** Not viable as the query
92
+ engine: functions are short-lived with limited memory, and the engine
93
+ needs ~10 s and several GB to come up — per invocation, that's the
94
+ whole budget. Functions are the right *caller* (they fetch overrides
95
+ and invoke the module), not the right host.
96
+ 3. **Container models / live model deployments.** Foundry can host a
97
+ container behind a model live deployment. Workable, but the engine
98
+ isn't a model: the model-adapter I/O contract and model lifecycle
99
+ (versions, objectives) add friction for plain request/response logic.
100
+ Compute modules are Palantir's intended primitive for exactly this.
101
+ 4. **External hosting.** Run the FastAPI app on a VM; Foundry calls out
102
+ via external functions/egress. Maximum flexibility, but data leaves
103
+ platform governance and you own the infra — against the goal of a
104
+ Foundry-native deployment.
105
+
106
+ Compute modules remain the recommendation: they are precisely
107
+ "long-running custom container, callable as Foundry Functions", which is
108
+ this workload. Option 1 is the graceful degradation path.
109
+
110
+ ## Platform-API notes
111
+
112
+ The transforms use the lightweight API (`@lightweight` +
113
+ `Input/Output`); dataset file access is isolated in
114
+ `transforms/io_helpers.py` (tries the lightweight local-download path,
115
+ falls back to `filesystem()` streaming). If your Foundry version's
116
+ lightweight file API differs, that one file is the only thing to adjust.
117
+ The compute module's bootstrap uses only stable public REST endpoints.
@@ -0,0 +1,33 @@
1
+ # Compute module image for the interactive routing engine.
2
+ #
3
+ # Foundry requires linux/amd64 images and a numeric non-root USER.
4
+ #
5
+ # Build context: this directory, with the two wheels copied into ./wheels:
6
+ # pyroutingkit-*-manylinux*x86_64.whl (from pyroutingkit's cibuildwheel CI)
7
+ # hum_router-*-py3-*.whl (uv build in hum-router)
8
+ #
9
+ # docker build --platform linux/amd64 -t hum-router-cm .
10
+ #
11
+ # Graph access (see bootstrap.py): mount the T2 graph dataset as a module
12
+ # resource aliased 'graph' (preferred), or set FOUNDRY_URL + FOUNDRY_TOKEN
13
+ # + GRAPH_DATASET_RID, or pre-mount files at /data/graph.
14
+
15
+ FROM --platform=linux/amd64 python:3.12-slim
16
+
17
+ # Foundry compute modules require a non-root user
18
+ RUN useradd --uid 5000 --create-home user
19
+ WORKDIR /app
20
+
21
+ COPY wheels/ /tmp/wheels/
22
+ RUN pip install --no-cache-dir \
23
+ /tmp/wheels/*.whl \
24
+ foundry-compute-modules \
25
+ requests \
26
+ && rm -rf /tmp/wheels
27
+
28
+ COPY app.py bootstrap.py /app/
29
+
30
+ RUN mkdir -p /data/graph && chown -R 5000:5000 /data /app
31
+ USER 5000
32
+
33
+ ENTRYPOINT ["python", "/app/app.py"]