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.
- hum_router-0.2.0/.github/workflows/build.yml +32 -0
- hum_router-0.2.0/.github/workflows/publish.yml +47 -0
- hum_router-0.2.0/.gitignore +30 -0
- hum_router-0.2.0/.python-version +1 -0
- hum_router-0.2.0/PKG-INFO +223 -0
- hum_router-0.2.0/README.md +193 -0
- hum_router-0.2.0/foundry_deployment/README.md +117 -0
- hum_router-0.2.0/foundry_deployment/compute_module/Dockerfile +33 -0
- hum_router-0.2.0/foundry_deployment/compute_module/README.md +82 -0
- hum_router-0.2.0/foundry_deployment/compute_module/app.py +211 -0
- hum_router-0.2.0/foundry_deployment/compute_module/bootstrap.py +101 -0
- hum_router-0.2.0/foundry_deployment/meta.yml +28 -0
- hum_router-0.2.0/foundry_deployment/ontology.md +109 -0
- hum_router-0.2.0/foundry_deployment/transforms/__init__.py +0 -0
- hum_router-0.2.0/foundry_deployment/transforms/deployment.py +56 -0
- hum_router-0.2.0/foundry_deployment/transforms/io_helpers.py +79 -0
- hum_router-0.2.0/foundry_deployment/transforms/t1_osm_ingest.py +51 -0
- hum_router-0.2.0/foundry_deployment/transforms/t2_build_region.py +72 -0
- hum_router-0.2.0/foundry_deployment/transforms/t3_route_pairs.py +110 -0
- hum_router-0.2.0/foundry_deployment/transforms/t4_merge_results.py +26 -0
- hum_router-0.2.0/pipeline/01_load_osm.py +153 -0
- hum_router-0.2.0/pipeline/02_build_road.py +98 -0
- hum_router-0.2.0/pipeline/03_build_modal.py +161 -0
- hum_router-0.2.0/pipeline/04_build_unified.py +105 -0
- hum_router-0.2.0/pipeline/05_route_pairs.py +139 -0
- hum_router-0.2.0/pipeline/__init__.py +0 -0
- hum_router-0.2.0/pipeline/config.py +94 -0
- hum_router-0.2.0/pipeline/filter_osm.py +193 -0
- hum_router-0.2.0/pipeline/helpers.py +126 -0
- hum_router-0.2.0/pipeline/merge_results.py +77 -0
- hum_router-0.2.0/pipeline/run_all.py +458 -0
- hum_router-0.2.0/pipeline/visualize_routes.py +353 -0
- hum_router-0.2.0/pyproject.toml +65 -0
- hum_router-0.2.0/scripts/download_natural_earth.py +80 -0
- hum_router-0.2.0/scripts/download_planet.py +93 -0
- hum_router-0.2.0/src/multimodal_router/__init__.py +2 -0
- hum_router-0.2.0/src/multimodal_router/app/__init__.py +1 -0
- hum_router-0.2.0/src/multimodal_router/app/__main__.py +34 -0
- hum_router-0.2.0/src/multimodal_router/app/server.py +285 -0
- hum_router-0.2.0/src/multimodal_router/app/static/index.html +334 -0
- hum_router-0.2.0/src/multimodal_router/build/__init__.py +0 -0
- hum_router-0.2.0/src/multimodal_router/build/ferry_builder.py +221 -0
- hum_router-0.2.0/src/multimodal_router/build/rail_builder.py +237 -0
- hum_router-0.2.0/src/multimodal_router/build/road_builder.py +174 -0
- hum_router-0.2.0/src/multimodal_router/build/sea_builder.py +155 -0
- hum_router-0.2.0/src/multimodal_router/build/topology.py +216 -0
- hum_router-0.2.0/src/multimodal_router/build/unified_graph.py +507 -0
- hum_router-0.2.0/src/multimodal_router/build/waterway_builder.py +276 -0
- hum_router-0.2.0/src/multimodal_router/config/country_modes.yaml +64 -0
- hum_router-0.2.0/src/multimodal_router/config/routing.yaml +46 -0
- hum_router-0.2.0/src/multimodal_router/config/seed_ports.yaml +83 -0
- hum_router-0.2.0/src/multimodal_router/engine/__init__.py +22 -0
- hum_router-0.2.0/src/multimodal_router/engine/engine.py +558 -0
- hum_router-0.2.0/src/multimodal_router/engine/graph.py +224 -0
- hum_router-0.2.0/src/multimodal_router/engine/overrides.py +168 -0
- hum_router-0.2.0/src/multimodal_router/engine/profiles.py +202 -0
- hum_router-0.2.0/src/multimodal_router/engine/types.py +80 -0
- hum_router-0.2.0/src/multimodal_router/io/__init__.py +0 -0
- hum_router-0.2.0/src/multimodal_router/io/country_tagger.py +204 -0
- hum_router-0.2.0/src/multimodal_router/io/osm_loader.py +26 -0
- hum_router-0.2.0/src/multimodal_router/io/osm_planet_loader.py +476 -0
- hum_router-0.2.0/src/multimodal_router/io/seed_data.py +70 -0
- hum_router-0.2.0/src/multimodal_router/models.py +106 -0
- hum_router-0.2.0/src/multimodal_router/py.typed +0 -0
- hum_router-0.2.0/src/multimodal_router/settings.py +158 -0
- hum_router-0.2.0/src/multimodal_router/stages.py +403 -0
- hum_router-0.2.0/src/multimodal_router/utils/__init__.py +0 -0
- hum_router-0.2.0/src/multimodal_router/utils/duckdb_helpers.py +180 -0
- hum_router-0.2.0/src/multimodal_router/utils/geo.py +65 -0
- hum_router-0.2.0/tests/__init__.py +0 -0
- hum_router-0.2.0/tests/conftest.py +13 -0
- hum_router-0.2.0/tests/test_app.py +112 -0
- hum_router-0.2.0/tests/test_country_tagger.py +210 -0
- hum_router-0.2.0/tests/test_duckdb_helpers.py +108 -0
- hum_router-0.2.0/tests/test_engine.py +292 -0
- hum_router-0.2.0/tests/test_geo.py +79 -0
- hum_router-0.2.0/tests/test_models.py +129 -0
- hum_router-0.2.0/tests/test_osm_loader.py +110 -0
- hum_router-0.2.0/tests/test_rail_builder.py +135 -0
- hum_router-0.2.0/tests/test_road_builder.py +161 -0
- hum_router-0.2.0/tests/test_sea_builder.py +92 -0
- hum_router-0.2.0/tests/test_seed_data.py +68 -0
- hum_router-0.2.0/tests/test_stages.py +92 -0
- hum_router-0.2.0/tests/test_topology.py +211 -0
- hum_router-0.2.0/tests/test_waterway_builder.py +108 -0
- 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"]
|