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:
parent
8a31e5449c
commit
da0a56895d
4 changed files with 538 additions and 0 deletions
143
rec/osrm_client.py
Normal file
143
rec/osrm_client.py
Normal 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
120
rec/otp_client.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue