Add R5
This commit is contained in:
parent
1397b6afd5
commit
d39d1b15fd
3 changed files with 175 additions and 0 deletions
17
r5-service/Dockerfile
Normal file
17
r5-service/Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
# r5py needs a JVM to run the R5 routing engine
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends openjdk-21-jre-headless curl libexpat1 libgdal-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
EXPOSE 8003
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8003"]
|
||||
153
r5-service/main.py
Normal file
153
r5-service/main.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""R5 travel time service — FastAPI wrapper around r5py.
|
||||
|
||||
Loads an OSM PBF + GTFS feeds at startup, then serves many-to-one
|
||||
travel time queries via POST /travel-times.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import geopandas as gpd
|
||||
import pandas as pd
|
||||
import r5py
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from shapely.geometry import Point
|
||||
|
||||
logger = logging.getLogger("r5-service")
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
app = FastAPI(title="R5 Travel Time Service")
|
||||
|
||||
# Global transport network — loaded once at startup
|
||||
_transport_network: r5py.TransportNetwork | None = None
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def load_network() -> None:
|
||||
global _transport_network
|
||||
|
||||
data_dir = Path(os.environ.get("TRANSIT_DATA_DIR", "/data/transit"))
|
||||
|
||||
osm_files = list(data_dir.glob("*.osm.pbf"))
|
||||
if not osm_files:
|
||||
raise RuntimeError(f"No .osm.pbf file found in {data_dir}")
|
||||
osm_pbf = osm_files[0]
|
||||
|
||||
gtfs_files = list(data_dir.glob("*.zip"))
|
||||
logger.info(
|
||||
"Loading transport network: OSM=%s, GTFS=%s",
|
||||
osm_pbf.name,
|
||||
[f.name for f in gtfs_files],
|
||||
)
|
||||
|
||||
_transport_network = r5py.TransportNetwork(
|
||||
osm_pbf=osm_pbf,
|
||||
gtfs=gtfs_files if gtfs_files else None,
|
||||
)
|
||||
logger.info("Transport network loaded successfully")
|
||||
|
||||
|
||||
# ── Request / Response models ────────────────────────────────────────────────
|
||||
|
||||
# r5py 1.x uses transport_modes for direct modes and access_modes for
|
||||
# first-mile walking/cycling to transit.
|
||||
MODE_CONFIGS = {
|
||||
"transit": {
|
||||
"transport_modes": [r5py.TransportMode.TRANSIT],
|
||||
"access_modes": [r5py.TransportMode.WALK],
|
||||
},
|
||||
"car": {
|
||||
"transport_modes": [r5py.TransportMode.CAR],
|
||||
},
|
||||
"bicycle": {
|
||||
"transport_modes": [r5py.TransportMode.BICYCLE],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TravelTimeRequest(BaseModel):
|
||||
origins: list[list[float]] # [[lat, lon], ...]
|
||||
destination: list[float] # [lat, lon]
|
||||
mode: str = "transit"
|
||||
departure_time: str | None = None # ISO 8601, defaults to next weekday 8am
|
||||
|
||||
|
||||
class TravelTimeResponse(BaseModel):
|
||||
travel_times: list[float | None] # minutes per origin, null if unreachable
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
if _transport_network is None:
|
||||
raise HTTPException(status_code=503, detail="Network not loaded")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/travel-times", response_model=TravelTimeResponse)
|
||||
def compute_travel_times(req: TravelTimeRequest) -> TravelTimeResponse:
|
||||
if _transport_network is None:
|
||||
raise HTTPException(status_code=503, detail="Network not loaded")
|
||||
|
||||
if req.mode not in MODE_CONFIGS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid mode '{req.mode}'. Must be one of: {list(MODE_CONFIGS)}",
|
||||
)
|
||||
|
||||
if not req.origins:
|
||||
return TravelTimeResponse(travel_times=[])
|
||||
|
||||
# Parse departure time
|
||||
if req.departure_time:
|
||||
departure = datetime.datetime.fromisoformat(req.departure_time)
|
||||
else:
|
||||
# Default: next weekday at 8:00 AM
|
||||
now = datetime.datetime.now()
|
||||
departure = now.replace(hour=8, minute=0, second=0, microsecond=0)
|
||||
# Advance to next weekday if weekend
|
||||
while departure.weekday() >= 5:
|
||||
departure += datetime.timedelta(days=1)
|
||||
|
||||
# Build origin GeoDataFrame (note: Point takes (lon, lat))
|
||||
origin_points = [Point(lon, lat) for lat, lon in req.origins]
|
||||
origins_gdf = gpd.GeoDataFrame(
|
||||
{"id": range(len(origin_points))},
|
||||
geometry=origin_points,
|
||||
crs="EPSG:4326",
|
||||
)
|
||||
|
||||
# Build destination GeoDataFrame
|
||||
dest_lat, dest_lon = req.destination
|
||||
dest_gdf = gpd.GeoDataFrame(
|
||||
{"id": [0]},
|
||||
geometry=[Point(dest_lon, dest_lat)],
|
||||
crs="EPSG:4326",
|
||||
)
|
||||
|
||||
mode_config = MODE_CONFIGS[req.mode]
|
||||
|
||||
# r5py 1.x: TravelTimeMatrix is instantiated directly and IS the result
|
||||
result = r5py.TravelTimeMatrix(
|
||||
_transport_network,
|
||||
origins=origins_gdf,
|
||||
destinations=dest_gdf,
|
||||
departure=departure,
|
||||
**mode_config,
|
||||
)
|
||||
|
||||
# Build response: one travel time per origin
|
||||
# r5py 1.x returns a GeoDataFrame with columns: from_id, to_id, travel_time
|
||||
travel_times: list[float | None] = [None] * len(req.origins)
|
||||
for _, row in result.iterrows():
|
||||
origin_idx = int(row["from_id"])
|
||||
tt = row["travel_time"]
|
||||
if pd.notna(tt) and tt >= 0:
|
||||
travel_times[origin_idx] = float(tt)
|
||||
|
||||
return TravelTimeResponse(travel_times=travel_times)
|
||||
5
r5-service/requirements.txt
Normal file
5
r5-service/requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
r5py>=0.8
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.34
|
||||
geopandas>=1.0
|
||||
shapely>=2.0
|
||||
Loading…
Add table
Add a link
Reference in a new issue