Add self-hosted routing clients and distance calculator

RoutingConfig loads OSRM/OTP URLs from env vars. OSRM client uses the
/table endpoint for batch NxM distance matrices (walk/cycle). OTP client
uses GraphQL API for transit routes. POI distance calculator orchestrates
both, skipping already-computed distances and reporting progress.
This commit is contained in:
Viktor Barzin 2026-02-08 13:14:37 +00:00
parent 8a31e5449c
commit da0a56895d
No known key found for this signature in database
GPG key ID: 0EB088298288D958
4 changed files with 538 additions and 0 deletions

143
rec/osrm_client.py Normal file
View file

@ -0,0 +1,143 @@
"""OSRM HTTP client for walk and cycle routing.
Uses the OSRM /table endpoint for efficient batch distance/duration matrix
calculations, and /route for single-pair fallback.
"""
import logging
from dataclasses import dataclass
import aiohttp
from config.routing_config import RoutingConfig
logger = logging.getLogger("uvicorn.error")
@dataclass(frozen=True)
class OSRMResult:
"""Result of an OSRM route calculation."""
duration_seconds: int
distance_meters: int
async def osrm_table(
origins: list[tuple[float, float]],
destinations: list[tuple[float, float]],
profile: str,
config: RoutingConfig,
session: aiohttp.ClientSession | None = None,
) -> list[list[OSRMResult | None]]:
"""Compute an NxM duration/distance matrix using OSRM /table endpoint.
Args:
origins: List of (longitude, latitude) pairs for sources.
destinations: List of (longitude, latitude) pairs for destinations.
profile: OSRM profile ("foot" or "bicycle").
config: Routing configuration.
session: Optional aiohttp session for connection reuse.
Returns:
NxM matrix where result[i][j] is the route from origins[i] to destinations[j],
or None if no route was found.
"""
base_url = config.get_osrm_url(profile)
# Build coordinates string: origins first, then destinations
all_coords = origins + destinations
coords_str = ";".join(f"{lng},{lat}" for lng, lat in all_coords)
# Source/destination indices
source_indices = ",".join(str(i) for i in range(len(origins)))
dest_indices = ",".join(str(i) for i in range(len(origins), len(all_coords)))
url = (
f"{base_url}/table/v1/{profile}/{coords_str}"
f"?sources={source_indices}"
f"&destinations={dest_indices}"
f"&annotations=duration,distance"
)
should_close = session is None
if session is None:
session = aiohttp.ClientSession()
try:
async with session.get(url) as resp:
if resp.status != 200:
logger.error(f"OSRM /table returned {resp.status}: {await resp.text()}")
return [[None] * len(destinations) for _ in origins]
data = await resp.json()
if data.get("code") != "Ok":
logger.error(f"OSRM /table error: {data.get('message', data.get('code'))}")
return [[None] * len(destinations) for _ in origins]
durations = data["durations"]
distances = data["distances"]
results: list[list[OSRMResult | None]] = []
for i in range(len(origins)):
row: list[OSRMResult | None] = []
for j in range(len(destinations)):
dur = durations[i][j]
dist = distances[i][j]
if dur is None or dist is None:
row.append(None)
else:
row.append(OSRMResult(
duration_seconds=int(dur),
distance_meters=int(dist),
))
results.append(row)
return results
finally:
if should_close:
await session.close()
async def osrm_route(
origin: tuple[float, float],
destination: tuple[float, float],
profile: str,
config: RoutingConfig,
session: aiohttp.ClientSession | None = None,
) -> OSRMResult | None:
"""Compute a single route using OSRM /route endpoint.
Args:
origin: (longitude, latitude) of the source.
destination: (longitude, latitude) of the destination.
profile: OSRM profile ("foot" or "bicycle").
config: Routing configuration.
session: Optional aiohttp session.
Returns:
OSRMResult or None if no route was found.
"""
base_url = config.get_osrm_url(profile)
coords_str = f"{origin[0]},{origin[1]};{destination[0]},{destination[1]}"
url = f"{base_url}/route/v1/{profile}/{coords_str}?overview=false"
should_close = session is None
if session is None:
session = aiohttp.ClientSession()
try:
async with session.get(url) as resp:
if resp.status != 200:
return None
data = await resp.json()
if data.get("code") != "Ok" or not data.get("routes"):
return None
route = data["routes"][0]
return OSRMResult(
duration_seconds=int(route["duration"]),
distance_meters=int(route["distance"]),
)
finally:
if should_close:
await session.close()

120
rec/otp_client.py Normal file
View file

@ -0,0 +1,120 @@
"""OpenTripPlanner 2.x GraphQL client for transit routing.
Uses OTP's GTFS GraphQL API to compute transit routes between points.
Since OTP has no matrix endpoint, individual requests are made with
concurrency controlled via asyncio.Semaphore.
"""
import logging
from dataclasses import dataclass
import aiohttp
from config.routing_config import RoutingConfig
from rec.utils import nextMonday
logger = logging.getLogger("uvicorn.error")
# OTP 2.x GraphQL query for transit plan
_PLAN_QUERY = """
query Plan($fromLat: Float!, $fromLon: Float!, $toLat: Float!, $toLon: Float!, $dateTime: DateTime!) {
plan(
from: {lat: $fromLat, lon: $fromLon}
to: {lat: $toLat, lon: $toLon}
dateTime: $dateTime
transportModes: [{mode: TRANSIT}, {mode: WALK}]
numItineraries: 1
) {
itineraries {
duration
walkDistance
legs {
mode
duration
distance
}
}
}
}
"""
@dataclass(frozen=True)
class OTPResult:
"""Result of an OTP transit route calculation."""
duration_seconds: int
distance_meters: int
async def otp_transit_route(
origin_lat: float,
origin_lon: float,
dest_lat: float,
dest_lon: float,
config: RoutingConfig,
session: aiohttp.ClientSession | None = None,
) -> OTPResult | None:
"""Compute a transit route using OTP 2.x GraphQL API.
Uses next Monday 9AM as departure time for consistent results.
Args:
origin_lat: Origin latitude.
origin_lon: Origin longitude.
dest_lat: Destination latitude.
dest_lon: Destination longitude.
config: Routing configuration.
session: Optional aiohttp session.
Returns:
OTPResult or None if no transit route was found.
"""
url = f"{config.otp_url}/otp/gtfs/v1"
departure_time = nextMonday().isoformat()
payload = {
"query": _PLAN_QUERY,
"variables": {
"fromLat": origin_lat,
"fromLon": origin_lon,
"toLat": dest_lat,
"toLon": dest_lon,
"dateTime": departure_time,
},
}
should_close = session is None
if session is None:
session = aiohttp.ClientSession()
try:
async with session.post(url, json=payload) as resp:
if resp.status != 200:
logger.error(f"OTP returned {resp.status}: {await resp.text()}")
return None
data = await resp.json()
plan = data.get("data", {}).get("plan")
if not plan:
errors = data.get("errors")
if errors:
logger.warning(f"OTP GraphQL errors: {errors}")
return None
itineraries = plan.get("itineraries", [])
if not itineraries:
return None
best = itineraries[0]
total_distance = sum(
leg.get("distance", 0) for leg in best.get("legs", [])
)
return OTPResult(
duration_seconds=int(best["duration"]),
distance_meters=int(total_distance),
)
finally:
if should_close:
await session.close()