hum-router 0.2.0__py3-none-any.whl

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 (37) hide show
  1. hum_router-0.2.0.dist-info/METADATA +223 -0
  2. hum_router-0.2.0.dist-info/RECORD +37 -0
  3. hum_router-0.2.0.dist-info/WHEEL +4 -0
  4. multimodal_router/__init__.py +2 -0
  5. multimodal_router/app/__init__.py +1 -0
  6. multimodal_router/app/__main__.py +34 -0
  7. multimodal_router/app/server.py +285 -0
  8. multimodal_router/app/static/index.html +334 -0
  9. multimodal_router/build/__init__.py +0 -0
  10. multimodal_router/build/ferry_builder.py +221 -0
  11. multimodal_router/build/rail_builder.py +237 -0
  12. multimodal_router/build/road_builder.py +174 -0
  13. multimodal_router/build/sea_builder.py +155 -0
  14. multimodal_router/build/topology.py +216 -0
  15. multimodal_router/build/unified_graph.py +507 -0
  16. multimodal_router/build/waterway_builder.py +276 -0
  17. multimodal_router/config/country_modes.yaml +64 -0
  18. multimodal_router/config/routing.yaml +46 -0
  19. multimodal_router/config/seed_ports.yaml +83 -0
  20. multimodal_router/engine/__init__.py +22 -0
  21. multimodal_router/engine/engine.py +558 -0
  22. multimodal_router/engine/graph.py +224 -0
  23. multimodal_router/engine/overrides.py +168 -0
  24. multimodal_router/engine/profiles.py +202 -0
  25. multimodal_router/engine/types.py +80 -0
  26. multimodal_router/io/__init__.py +0 -0
  27. multimodal_router/io/country_tagger.py +204 -0
  28. multimodal_router/io/osm_loader.py +26 -0
  29. multimodal_router/io/osm_planet_loader.py +476 -0
  30. multimodal_router/io/seed_data.py +70 -0
  31. multimodal_router/models.py +106 -0
  32. multimodal_router/py.typed +0 -0
  33. multimodal_router/settings.py +158 -0
  34. multimodal_router/stages.py +403 -0
  35. multimodal_router/utils/__init__.py +0 -0
  36. multimodal_router/utils/duckdb_helpers.py +180 -0
  37. multimodal_router/utils/geo.py +65 -0
@@ -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,37 @@
1
+ multimodal_router/__init__.py,sha256=14EX64jECkWBjE0duMA0wyqdl2M9hyBRerVZEpbfrhc,63
2
+ multimodal_router/models.py,sha256=oinWH4CE8poksyaAEpF8LQ3rKJVngZn0DJT2TAifd24,2692
3
+ multimodal_router/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ multimodal_router/settings.py,sha256=Qej2HdNPTGwOCNEN-1_UMdnKRsuGDeQYVjI7B-kCQp0,5605
5
+ multimodal_router/stages.py,sha256=LrD6HmNh9FjdQMhHWgTAIezXWj0voYLZynSs8kmFBn8,14925
6
+ multimodal_router/app/__init__.py,sha256=56Q4kjn-bbXaV4YoPSmkNF6cbwuxrQy1zbQmjcxz2gg,68
7
+ multimodal_router/app/__main__.py,sha256=TCGIAYJ0LCSccdpUBg4rBq80ah9WAdBNtcZ-nZjcumQ,949
8
+ multimodal_router/app/server.py,sha256=W2zKp3Dg9Klp0J_4fNRolVRRUcMJzy1Sqthpkh3Uosw,10311
9
+ multimodal_router/app/static/index.html,sha256=z7TtoMesqhiCPo3_x4CUhgYaCwFHzXEzWrK6vGYcFs4,14005
10
+ multimodal_router/build/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ multimodal_router/build/ferry_builder.py,sha256=eKha1gCOhRQoOwlr25x-ZzHgqiL6gX-I8MNuBTaGoHs,8359
12
+ multimodal_router/build/rail_builder.py,sha256=tRETHFb49in-mVGa2oIzypmF9DzhwRYjKmp8XLLrEFA,8280
13
+ multimodal_router/build/road_builder.py,sha256=H1Jrv6n3VQPL6p6EshYjZxPDoZsjcqKZwFiyIsLO8DQ,5867
14
+ multimodal_router/build/sea_builder.py,sha256=nNmfPZhilzFRJ_Xmj1fBa9vj-EkqC5W3FWt-FbS2GJY,5137
15
+ multimodal_router/build/topology.py,sha256=AyPQBXvJpgDMGwSu-s3jB5LjwvP7EHY6d5_rEesA990,7013
16
+ multimodal_router/build/unified_graph.py,sha256=_zIOsAessj6woosThZnzzSOpsdTjcYEc9pUMaZqG7Cs,18599
17
+ multimodal_router/build/waterway_builder.py,sha256=lT7EdQKK3m6EGViqHc0onyatK7DmATmoW9H2ercAfXU,9200
18
+ multimodal_router/config/country_modes.yaml,sha256=azPDS8ZSMv7H0Jnn_uJjoG2vTINblY-wgbLfn6p3rLM,2104
19
+ multimodal_router/config/routing.yaml,sha256=BnqqW7iptuFsxevMQm9uxVt2m2DcZZ7ESlYt2tWCNsc,954
20
+ multimodal_router/config/seed_ports.yaml,sha256=Vcyx9Q17sT4Vqwy0cyPK9-od0Gvf-S79CJ0kejZ04M4,1561
21
+ multimodal_router/engine/__init__.py,sha256=ioWkRqZWlVQuaHwBUYB11Jpv4HKtcW75VjHBCWVoUsA,756
22
+ multimodal_router/engine/engine.py,sha256=gYbIjSGN1OogrfOkQ7j2vA7qV8EOHZX7cTvoRUhGJA4,21895
23
+ multimodal_router/engine/graph.py,sha256=7nYsYR2HF7tbWyPCDp5ynFPUffV12vwCTySnB-oPkwQ,8713
24
+ multimodal_router/engine/overrides.py,sha256=qNAhVaWD8gAQ-daYLyJScuAAmPnZXpdq82BQiWNeEyg,5359
25
+ multimodal_router/engine/profiles.py,sha256=jDXLRJspIuIvIE4Mlc7SZ7ybB-9BzXdJrPBsgE-ayl0,7136
26
+ multimodal_router/engine/types.py,sha256=47tFCja_l9ncwGzwIZPxmY9Pbuzz4RyrSeqMzgw1-fI,2448
27
+ multimodal_router/io/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
+ multimodal_router/io/country_tagger.py,sha256=UUrtnesuEIPoKinNPQn1HSNTPAouLjPusjtCwW_qtEU,6774
29
+ multimodal_router/io/osm_loader.py,sha256=ja1gGx0MghrjDnj-B8TEsIlu_lv23yRCzOb-iSuObnI,782
30
+ multimodal_router/io/osm_planet_loader.py,sha256=F9Ryeo2FZ9ayKY8dM06Jwvfaj6og6TpGwmA7YANfIpI,19241
31
+ multimodal_router/io/seed_data.py,sha256=oxUFxWUjHVy6fdC0sJb35mBEF7D6ACDtD9rSljZMuPc,2031
32
+ multimodal_router/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
+ multimodal_router/utils/duckdb_helpers.py,sha256=Z-KXz-qsk6VZCSWYTKLM8viBeT96P-EbNbdgZFQFXtY,6457
34
+ multimodal_router/utils/geo.py,sha256=vzLHhd6WiBde4YkWQmrK6rmlvG2GOus0CLvrFo_VWdQ,1812
35
+ hum_router-0.2.0.dist-info/METADATA,sha256=FdiWUYMhpOe7s4Cis6u9XJhTJ5YealMEvHmES_opGYc,9377
36
+ hum_router-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
37
+ hum_router-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from multimodal-router!"
@@ -0,0 +1 @@
1
+ """Interactive routing app: FastAPI backend + MapLibre frontend."""
@@ -0,0 +1,34 @@
1
+ """Run the interactive routing app.
2
+
3
+ uv run python -m multimodal_router.app --graph-dir pipeline_output/regions/et_dj/04_unified
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import logging
10
+
11
+
12
+ def main() -> None:
13
+ logging.basicConfig(
14
+ level=logging.INFO,
15
+ format="%(asctime)s %(levelname)-8s %(name)s — %(message)s",
16
+ datefmt="%H:%M:%S",
17
+ )
18
+ parser = argparse.ArgumentParser(description="Multimodal routing app")
19
+ parser.add_argument("--graph-dir", required=True,
20
+ help="Stage 04 unified graph directory")
21
+ parser.add_argument("--host", default="127.0.0.1")
22
+ parser.add_argument("--port", type=int, default=8000)
23
+ args = parser.parse_args()
24
+
25
+ import uvicorn
26
+
27
+ from multimodal_router.app.server import create_app
28
+
29
+ app = create_app(args.graph_dir)
30
+ uvicorn.run(app, host=args.host, port=args.port, log_level="info")
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
@@ -0,0 +1,285 @@
1
+ """
2
+ FastAPI server wrapping the RoutingEngine for the interactive map app.
3
+
4
+ uv run python -m multimodal_router.app --graph-dir <stage 04 dir>
5
+
6
+ Endpoints:
7
+ GET / MapLibre single-page app
8
+ GET /api/meta graph stats + map bounds + config
9
+ GET /api/gateways GeoJSON of gateways (ports, terminals, docks)
10
+ GET /api/crossings GeoJSON of border crossings
11
+ POST /api/route alternatives for an O/D (+waypoints/avoids)
12
+ POST /api/locate nearest edge to a clicked point
13
+ GET /api/overrides active field edits
14
+ POST /api/overrides add an edit (applies live + persists)
15
+ DELETE /api/overrides/{id} remove an edit
16
+
17
+ Field edits persist to <graph_dir>/edge_overrides.parquet — the same file
18
+ the batch pipeline accepts via --overrides, so the app and batch runs see
19
+ one consistent picture of the network.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import threading
26
+ import time
27
+ import uuid
28
+ from pathlib import Path
29
+
30
+ import numpy as np
31
+ from fastapi import FastAPI, HTTPException
32
+ from fastapi.responses import FileResponse
33
+ from pydantic import BaseModel, Field
34
+
35
+ from multimodal_router.engine import (
36
+ EdgeOverride,
37
+ RouteRequest,
38
+ RoutingEngine,
39
+ load_overrides,
40
+ )
41
+ from multimodal_router.engine.graph import MODE_CODES, MODE_NAMES
42
+ from multimodal_router.engine.overrides import save_overrides
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ STATIC_DIR = Path(__file__).parent / "static"
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Request/response models
51
+ # ---------------------------------------------------------------------------
52
+
53
+ class RoutePayload(BaseModel):
54
+ o_lon: float
55
+ o_lat: float
56
+ d_lon: float
57
+ d_lat: float
58
+ impedance: str = "time"
59
+ modes: list[str] = ["road", "ferry", "rail", "sea", "inland_waterway"]
60
+ waypoints: list[list[float]] = Field(default_factory=list) # [[lon, lat]]
61
+ avoid_crossings: list[int] = Field(default_factory=list)
62
+ avoid_areas: list[list[list[float]]] = Field(default_factory=list)
63
+ max_alternatives: int = 5
64
+ max_detour_factor: float = 8.0
65
+
66
+
67
+ class LocatePayload(BaseModel):
68
+ lon: float
69
+ lat: float
70
+
71
+
72
+ class OverridePayload(BaseModel):
73
+ action: str # close | speed_kmh | factor
74
+ value: float = 0.0
75
+ way_id: int = -1
76
+ osm_from: int = -1
77
+ osm_to: int = -1
78
+ arc: int = -1
79
+ note: str = ""
80
+ author: str = "app"
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # App factory
85
+ # ---------------------------------------------------------------------------
86
+
87
+ def _wkt_to_coords(wkt: str) -> list[list[float]]:
88
+ if not wkt or "(" not in wkt:
89
+ return []
90
+ inner = wkt[wkt.index("(") + 1: wkt.rindex(")")]
91
+ coords = []
92
+ for pt in inner.split(","):
93
+ x, y = pt.strip().split(" ")
94
+ coords.append([round(float(x), 6), round(float(y), 6)])
95
+ return coords
96
+
97
+
98
+ def create_app(graph_dir: str | Path) -> FastAPI:
99
+ graph_dir = Path(graph_dir)
100
+ app = FastAPI(title="Multimodal Router")
101
+
102
+ t0 = time.time()
103
+ engine = RoutingEngine(graph_dir)
104
+ logger.info("Engine ready in %.1fs", time.time() - t0)
105
+
106
+ # The engine reuses CCH query objects per metric — serialize access.
107
+ lock = threading.Lock()
108
+
109
+ overrides_path = graph_dir / "edge_overrides.parquet"
110
+ overrides: list[EdgeOverride] = load_overrides(overrides_path)
111
+ if overrides:
112
+ with lock:
113
+ engine.set_overrides(overrides)
114
+ logger.info("Loaded %d persisted overrides", len(overrides))
115
+
116
+ # Lazy KD-tree over edge midpoints for click-to-edge lookup
117
+ _edge_index: dict = {}
118
+
119
+ def edge_index():
120
+ if "tree" not in _edge_index:
121
+ from scipy.spatial import cKDTree
122
+ g = engine.graph
123
+ sel = np.nonzero(g.arc_mode != MODE_CODES["transfer"])[0]
124
+ mx = (g.node_x[g.tail[sel]] + g.node_x[g.head[sel]]) / 2
125
+ my = (g.node_y[g.tail[sel]] + g.node_y[g.head[sel]]) / 2
126
+ _edge_index["arcs"] = sel
127
+ _edge_index["tree"] = cKDTree(np.column_stack([mx, my]))
128
+ return _edge_index["tree"], _edge_index["arcs"]
129
+
130
+ # ------------------------------------------------------------------
131
+
132
+ @app.get("/")
133
+ def index():
134
+ return FileResponse(STATIC_DIR / "index.html")
135
+
136
+ @app.get("/api/meta")
137
+ def meta():
138
+ g = engine.graph
139
+ return {
140
+ "nodes": g.node_count,
141
+ "arcs": g.arc_count,
142
+ "gateways": len(g.gateways),
143
+ "crossings": len(g.crossings),
144
+ "bounds": [
145
+ float(g.node_x.min()), float(g.node_y.min()),
146
+ float(g.node_x.max()), float(g.node_y.max()),
147
+ ],
148
+ "modes": ["road", "ferry", "rail", "sea", "inland_waterway"],
149
+ "impedances": ["time", "distance", "cost"],
150
+ }
151
+
152
+ @app.get("/api/gateways")
153
+ def gateways():
154
+ feats = []
155
+ for gw in engine.graph.gateways:
156
+ if gw.road_vertex < 0:
157
+ continue
158
+ feats.append({
159
+ "type": "Feature",
160
+ "geometry": {"type": "Point", "coordinates": [gw.x, gw.y]},
161
+ "properties": {
162
+ "kind": gw.kind, "name": gw.name or "",
163
+ "country": gw.country or "",
164
+ },
165
+ })
166
+ return {"type": "FeatureCollection", "features": feats}
167
+
168
+ @app.get("/api/crossings")
169
+ def crossings():
170
+ feats = []
171
+ for c in engine.graph.crossings:
172
+ feats.append({
173
+ "type": "Feature",
174
+ "geometry": {"type": "Point", "coordinates": [c["x"], c["y"]]},
175
+ "properties": {
176
+ "crossing_id": c["crossing_id"],
177
+ "countries": f'{c["country_a"]}–{c["country_b"]}',
178
+ "mode": c["mode"],
179
+ },
180
+ })
181
+ return {"type": "FeatureCollection", "features": feats}
182
+
183
+ @app.post("/api/route")
184
+ def route(p: RoutePayload):
185
+ req = RouteRequest(
186
+ o_lon=p.o_lon, o_lat=p.o_lat, d_lon=p.d_lon, d_lat=p.d_lat,
187
+ impedance=p.impedance,
188
+ modes=frozenset(p.modes),
189
+ waypoints=[(w[0], w[1]) for w in p.waypoints],
190
+ avoid_crossings=frozenset(p.avoid_crossings),
191
+ avoid_areas=[[(pt[0], pt[1]) for pt in ring] for ring in p.avoid_areas],
192
+ max_alternatives=p.max_alternatives,
193
+ max_detour_factor=p.max_detour_factor,
194
+ )
195
+ t0 = time.time()
196
+ with lock:
197
+ result = engine.route(req)
198
+ elapsed_ms = (time.time() - t0) * 1000
199
+
200
+ options = []
201
+ for opt in result.options:
202
+ options.append({
203
+ "label": opt.label,
204
+ "modes": opt.modes,
205
+ "is_multimodal": opt.is_multimodal,
206
+ "total_distance_km": opt.total_distance_km,
207
+ "total_time_hours": opt.total_time_hours,
208
+ "total_cost_usd": opt.total_cost_usd,
209
+ "gateway_origin": opt.gateway_origin,
210
+ "gateway_destination": opt.gateway_destination,
211
+ "crossings_used": opt.crossings_used,
212
+ "legs": [{
213
+ "mode": leg.mode,
214
+ "distance_km": leg.distance_km,
215
+ "time_hours": leg.time_hours,
216
+ "cost_usd": leg.cost_usd,
217
+ "from_name": leg.from_name,
218
+ "to_name": leg.to_name,
219
+ "coordinates": _wkt_to_coords(leg.geometry_wkt),
220
+ } for leg in opt.legs],
221
+ })
222
+ return {"options": options, "elapsed_ms": round(elapsed_ms, 1)}
223
+
224
+ @app.post("/api/locate")
225
+ def locate(p: LocatePayload):
226
+ tree, arcs = edge_index()
227
+ dist, i = tree.query([p.lon, p.lat])
228
+ if dist > 0.05: # ~5 km — too far from any edge
229
+ raise HTTPException(404, "No edge near this point")
230
+ arc = int(arcs[int(i)])
231
+ g = engine.graph
232
+ return {
233
+ "arc": arc,
234
+ "way_id": int(g.way_id[arc]),
235
+ "osm_from": int(g.osm_from[arc]),
236
+ "osm_to": int(g.osm_to[arc]),
237
+ "mode": MODE_NAMES[int(g.arc_mode[arc])],
238
+ "distance_km": round(float(g.distance_km[arc]), 3),
239
+ "crossing_id": int(g.crossing_id[arc]),
240
+ "coordinates": [
241
+ [float(g.node_x[g.tail[arc]]), float(g.node_y[g.tail[arc]])],
242
+ [float(g.node_x[g.head[arc]]), float(g.node_y[g.head[arc]])],
243
+ ],
244
+ }
245
+
246
+ @app.get("/api/overrides")
247
+ def list_overrides():
248
+ return [{
249
+ "override_id": o.override_id, "action": o.action, "value": o.value,
250
+ "way_id": o.way_id, "osm_from": o.osm_from, "osm_to": o.osm_to,
251
+ "arc": o.arc, "note": o.note, "author": o.author,
252
+ "created_at": o.created_at,
253
+ } for o in overrides]
254
+
255
+ @app.post("/api/overrides")
256
+ def add_override(p: OverridePayload):
257
+ if p.action not in ("close", "speed_kmh", "factor"):
258
+ raise HTTPException(400, "action must be close|speed_kmh|factor")
259
+ if p.way_id < 0 and p.arc < 0:
260
+ raise HTTPException(400, "way_id or arc required")
261
+ o = EdgeOverride(
262
+ override_id=uuid.uuid4().hex[:12],
263
+ action=p.action, value=p.value,
264
+ way_id=p.way_id, osm_from=p.osm_from, osm_to=p.osm_to, arc=p.arc,
265
+ note=p.note, author=p.author,
266
+ created_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
267
+ )
268
+ overrides.append(o)
269
+ with lock:
270
+ stats = engine.set_overrides(overrides)
271
+ save_overrides(overrides, overrides_path)
272
+ return {"override_id": o.override_id, "applied": stats}
273
+
274
+ @app.delete("/api/overrides/{override_id}")
275
+ def remove_override(override_id: str):
276
+ before = len(overrides)
277
+ overrides[:] = [o for o in overrides if o.override_id != override_id]
278
+ if len(overrides) == before:
279
+ raise HTTPException(404, "Unknown override id")
280
+ with lock:
281
+ stats = engine.set_overrides(overrides)
282
+ save_overrides(overrides, overrides_path)
283
+ return {"removed": override_id, "applied": stats}
284
+
285
+ return app