Files
meubility-workbench/app/routing.py

1135 lines
41 KiB
Python

from __future__ import annotations
import copy
import heapq
import json
import math
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass
from sqlalchemy import func, select, text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.config import settings
from app.models import Dataset, RoutingEdge, RoutingNode
from app.pipeline.routing_layer import active_routing_dataset
from app.serializers import feature_collection
WALK_HEURISTIC_MPS = 1.6
BIKE_HEURISTIC_MPS = 6.0
DRIVE_HEURISTIC_MPS = 36.0
BIKE_SPEED_MPS = 4.8
DEFAULT_MAX_VISITED = 160_000
PGR_WALK_BBOX_PADDING_KM = [0.5, 1.5, 4, 10, 25]
PGR_BIKE_BBOX_PADDING_KM = [1, 3, 8, 20, 60]
PGR_DRIVE_BBOX_PADDING_KM = [2, 8, 25, 75, 200]
PGR_WALK_STATEMENT_TIMEOUT_MS = 2_500
PGR_BIKE_STATEMENT_TIMEOUT_MS = 4_000
PGR_DRIVE_STATEMENT_TIMEOUT_MS = 7_500
ROUTING_MODES = {"walk", "bike", "drive"}
ROUTING_MODE_ALIASES = {"bicycle": "bike", "cycle": "bike", "cycling": "bike", "car": "drive"}
ROUTE_CACHE_TTL_SECONDS = 15 * 60
ROUTE_CACHE_MAX_ENTRIES = 512
_route_cache_lock = threading.RLock()
_route_cache: OrderedDict[tuple[object, ...], tuple[float, dict[str, object]]] = OrderedDict()
@dataclass(frozen=True)
class _GraphNode:
osm_node_id: int
lon: float
lat: float
distance_m: float
@dataclass(frozen=True)
class _Traversal:
edge_id: int
from_node: int
to_node: int
from_lon: float
from_lat: float
to_lon: float
to_lat: float
cost_s: float
length_m: float
highway: str | None
name: str | None
geometry_geojson: str
reversed: bool
def routing_status(db: Session) -> dict[str, object]:
dataset = active_routing_dataset(db)
dataset_id = None if dataset is None else int(dataset.id)
node_count = 0
edge_count = 0
if dataset_id is not None:
node_count, edge_count = _routing_status_counts(db, dataset, dataset_id)
pgrouting_available = False
pgrouting_installed = False
if settings.is_postgresql_database:
pgrouting_available = bool(
db.execute(text("SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pgrouting')")).scalar()
)
pgrouting_installed = bool(
db.execute(text("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgrouting')")).scalar()
)
return {
"dataset_id": dataset_id,
"nodes": node_count,
"edges": edge_count,
"available": edge_count > 0,
"engine": "pgrouting" if pgrouting_installed else "python_astar",
"pgrouting_available": pgrouting_available,
"pgrouting_installed": pgrouting_installed,
}
def _routing_status_counts(db: Session, dataset: Dataset, dataset_id: int) -> tuple[int, int]:
metadata = _metadata(dataset)
routing_layer = metadata.get("routing_layer")
if isinstance(routing_layer, dict):
try:
nodes = int(routing_layer.get("nodes") or 0)
edges = int(routing_layer.get("edges") or 0)
except (TypeError, ValueError):
nodes = 0
edges = 0
if nodes or edges:
return nodes, edges
if settings.is_postgresql_database:
rows = db.execute(
text(
"""
SELECT relname, COALESCE(reltuples, 0)::bigint AS estimate
FROM pg_class
WHERE oid IN ('routing_nodes'::regclass, 'routing_edges'::regclass)
"""
)
).mappings()
estimates = {str(row["relname"]): int(row["estimate"] or 0) for row in rows}
return estimates.get("routing_nodes", 0), estimates.get("routing_edges", 0)
node_count = int(db.scalar(select(func.count()).select_from(RoutingNode).where(RoutingNode.dataset_id == dataset_id)) or 0)
edge_count = int(db.scalar(select(func.count()).select_from(RoutingEdge).where(RoutingEdge.dataset_id == dataset_id)) or 0)
return node_count, edge_count
def _metadata(dataset: Dataset) -> dict[str, object]:
if not dataset.metadata_json:
return {}
try:
value = json.loads(dataset.metadata_json)
except json.JSONDecodeError:
return {}
return value if isinstance(value, dict) else {}
def _normalize_routing_mode(mode: str) -> str:
normalized = ROUTING_MODE_ALIASES.get(str(mode or "").lower(), str(mode or "").lower())
if normalized not in ROUTING_MODES:
raise ValueError("mode must be walk, bike, or drive")
return normalized
def _cost_columns(mode: str) -> tuple[str, str]:
if mode == "drive":
return "drive_cost_s", "reverse_drive_cost_s"
return "walk_cost_s", "reverse_walk_cost_s"
def _heuristic_mps(mode: str) -> float:
if mode == "walk":
return WALK_HEURISTIC_MPS
if mode == "bike":
return BIKE_HEURISTIC_MPS
return DRIVE_HEURISTIC_MPS
def _pgrouting_padding_km(mode: str) -> list[float]:
if mode == "walk":
return PGR_WALK_BBOX_PADDING_KM
if mode == "bike":
return PGR_BIKE_BBOX_PADDING_KM
return PGR_DRIVE_BBOX_PADDING_KM
def _pgrouting_timeout_ms(mode: str) -> int:
if mode == "walk":
return PGR_WALK_STATEMENT_TIMEOUT_MS
if mode == "bike":
return PGR_BIKE_STATEMENT_TIMEOUT_MS
return PGR_DRIVE_STATEMENT_TIMEOUT_MS
def route_between_points(
db: Session,
*,
from_lon: float,
from_lat: float,
to_lon: float,
to_lat: float,
mode: str = "walk",
dataset_id: int | None = None,
max_visited: int = DEFAULT_MAX_VISITED,
allow_python_fallback: bool = True,
pgrouting_timeout_ms: int | None = None,
pgrouting_padding_km: tuple[float, ...] | list[float] | None = None,
) -> dict[str, object]:
mode = _normalize_routing_mode(mode)
dataset = db.get(Dataset, dataset_id) if dataset_id is not None else active_routing_dataset(db)
if dataset is None:
raise ValueError("No routing dataset is available.")
dataset_id = int(dataset.id)
cache_key = _route_cache_key(dataset_id, mode, from_lon, from_lat, to_lon, to_lat)
cached = _route_cache_get(cache_key)
if cached is not None:
return cached
start = _nearest_node(db, dataset_id, from_lon, from_lat, mode)
target = _nearest_node(db, dataset_id, to_lon, to_lat, mode)
if start is None or target is None:
raise ValueError("Routing graph has no nearby nodes for the requested mode.")
if start.osm_node_id == target.osm_node_id:
payload = _single_point_route(start, from_lon, from_lat, to_lon, to_lat, mode, dataset_id)
_route_cache_put(cache_key, payload)
return payload
pgrouting_available = settings.is_postgresql_database and _pgrouting_installed(db)
if pgrouting_available:
try:
payload = _route_with_pgrouting(
db,
dataset_id=dataset_id,
mode=mode,
start=start,
target=target,
from_lon=from_lon,
from_lat=from_lat,
to_lon=to_lon,
to_lat=to_lat,
timeout_ms=pgrouting_timeout_ms,
padding_km=pgrouting_padding_km,
)
_route_cache_put(cache_key, payload)
return payload
except ValueError:
if not allow_python_fallback:
raise
except SQLAlchemyError:
db.rollback()
if not allow_python_fallback:
raise
if not allow_python_fallback:
raise ValueError("pgRouting did not find a bounded route and Python fallback is disabled.")
heuristic_mps = _heuristic_mps(mode)
queue: list[tuple[float, float, int]] = []
heapq.heappush(queue, (0.0, 0.0, start.osm_node_id))
costs: dict[int, float] = {start.osm_node_id: 0.0}
coords: dict[int, tuple[float, float]] = {start.osm_node_id: (start.lon, start.lat), target.osm_node_id: (target.lon, target.lat)}
previous: dict[int, tuple[int, _Traversal]] = {}
adjacency_cache: dict[int, list[_Traversal]] = {}
visited: set[int] = set()
while queue and len(visited) < max(1, max_visited):
_, cost, node_id = heapq.heappop(queue)
if node_id in visited:
continue
visited.add(node_id)
if node_id == target.osm_node_id:
payload = _route_payload(
dataset_id=dataset_id,
mode=mode,
start=start,
target=target,
from_lon=from_lon,
from_lat=from_lat,
to_lon=to_lon,
to_lat=to_lat,
previous=previous,
total_cost_s=cost,
visited=len(visited),
)
_route_cache_put(cache_key, payload)
return payload
for edge in adjacency_cache.setdefault(node_id, _outgoing_edges(db, dataset_id, node_id, mode)):
coords[edge.to_node] = (edge.to_lon, edge.to_lat)
next_cost = cost + edge.cost_s
if next_cost >= costs.get(edge.to_node, float("inf")):
continue
costs[edge.to_node] = next_cost
previous[edge.to_node] = (node_id, edge)
heuristic = _distance_m(edge.to_lat, edge.to_lon, target.lat, target.lon) / heuristic_mps
heapq.heappush(queue, (next_cost + heuristic, next_cost, edge.to_node))
raise ValueError(f"No {mode} route found within {max_visited:,} visited graph nodes.")
def direct_route_between_points(
db: Session,
*,
from_lon: float,
from_lat: float,
to_lon: float,
to_lat: float,
mode: str = "walk",
dataset_id: int | None = None,
reason: str | None = None,
) -> dict[str, object]:
mode = _normalize_routing_mode(mode)
dataset = db.get(Dataset, dataset_id) if dataset_id is not None else active_routing_dataset(db)
payload = _direct_route_payload(
dataset_id=0 if dataset is None else int(dataset.id),
mode=mode,
from_lon=float(from_lon),
from_lat=float(from_lat),
to_lon=float(to_lon),
to_lat=float(to_lat),
)
if reason:
payload["warning"] = reason
return payload
def snap_point_to_routing_graph(
db: Session,
*,
lon: float,
lat: float,
mode: str = "walk",
dataset_id: int | None = None,
max_distance_m: float = 250,
) -> dict[str, object] | None:
mode = _normalize_routing_mode(mode)
dataset = db.get(Dataset, dataset_id) if dataset_id is not None else active_routing_dataset(db)
if dataset is None:
return None
dataset_id = int(dataset.id)
if settings.is_postgresql_database:
return _snap_point_to_routing_edge_postgresql(
db,
dataset_id=dataset_id,
lon=float(lon),
lat=float(lat),
mode=mode,
max_distance_m=float(max_distance_m),
)
node = _nearest_node(db, dataset_id, float(lon), float(lat), mode)
if node is None or node.distance_m > max_distance_m:
return None
return {
"dataset_id": dataset_id,
"lon": node.lon,
"lat": node.lat,
"distance_m": round(node.distance_m, 1),
"source": "routing_node",
"osm_node_id": node.osm_node_id,
}
def _snap_point_to_routing_edge_postgresql(
db: Session,
*,
dataset_id: int,
lon: float,
lat: float,
mode: str,
max_distance_m: float,
) -> dict[str, object] | None:
cost_column, reverse_cost_column = _cost_columns(mode)
access_filter = f"AND {_bike_access_sql('edge')}" if mode == "bike" else ""
radius_deg = max_distance_m / 111_320
row = db.execute(
text(
f"""
WITH point AS (
SELECT ST_SetSRID(ST_MakePoint(:lon, :lat), 4326) AS geom
),
edges AS MATERIALIZED (
SELECT
edge.id,
edge.highway,
edge.name,
CASE
WHEN edge.tags_json IS NULL OR edge.tags_json = '' THEN NULL
ELSE edge.tags_json::jsonb ->> 'service'
END AS service,
edge.source_osm_node_id,
edge.target_osm_node_id,
ST_SetSRID(
ST_MakeLine(
ST_MakePoint(edge.source_lon, edge.source_lat),
ST_MakePoint(edge.target_lon, edge.target_lat)
),
4326
) AS edge_geom
FROM routing_edges AS edge
CROSS JOIN point
WHERE edge.dataset_id = :dataset_id
AND (edge.{cost_column} IS NOT NULL OR edge.{reverse_cost_column} IS NOT NULL)
{access_filter}
AND box(point(edge.max_lon, edge.max_lat), point(edge.min_lon, edge.min_lat))
&& box(
point(:lon + :radius_deg, :lat + :radius_deg),
point(:lon - :radius_deg, :lat - :radius_deg)
)
),
candidate AS (
SELECT
edges.id,
edges.highway,
edges.name,
edges.service,
edges.source_osm_node_id,
edges.target_osm_node_id,
ST_ClosestPoint(edges.edge_geom, point.geom) AS snapped_geom,
ST_DistanceSphere(edges.edge_geom, point.geom) AS distance_m,
CASE
WHEN edges.highway IN ('footway', 'pedestrian', 'steps') THEN 0
WHEN edges.highway = 'cycleway' THEN 0
WHEN edges.highway IN ('path', 'bridleway') THEN 1
WHEN edges.highway IN ('living_street', 'residential') THEN 2
WHEN edges.highway = 'service' THEN 3
ELSE 4
END AS highway_rank,
CASE
WHEN :mode NOT IN ('walk', 'bike') THEN 0
WHEN :mode = 'bike' AND edges.highway = 'cycleway' THEN -12
WHEN :mode = 'bike' AND edges.highway IN ('path', 'living_street', 'residential') THEN -4
WHEN edges.highway = 'service' THEN 20
WHEN edges.highway IN ('primary', 'primary_link', 'secondary', 'secondary_link') THEN 10
WHEN edges.highway IN ('tertiary', 'tertiary_link', 'unclassified', 'road') THEN 5
ELSE 0
END AS snap_penalty_m
FROM edges
CROSS JOIN point
WHERE ST_DWithin(edges.edge_geom::geography, point.geom::geography, :max_distance_m)
AND NOT (
:mode = 'walk'
AND edges.highway = 'service'
AND COALESCE(edges.service, '') IN ('driveway', 'parking_aisle', 'drive-through')
)
ORDER BY
ST_DistanceSphere(edges.edge_geom, point.geom) + CASE
WHEN :mode NOT IN ('walk', 'bike') THEN 0
WHEN :mode = 'bike' AND edges.highway = 'cycleway' THEN -12
WHEN :mode = 'bike' AND edges.highway IN ('path', 'living_street', 'residential') THEN -4
WHEN edges.highway = 'service' THEN 20
WHEN edges.highway IN ('primary', 'primary_link', 'secondary', 'secondary_link') THEN 10
WHEN edges.highway IN ('tertiary', 'tertiary_link', 'unclassified', 'road') THEN 5
ELSE 0
END,
ST_DistanceSphere(edges.edge_geom, point.geom),
highway_rank,
edges.id
LIMIT 1
)
SELECT
id,
highway,
name,
source_osm_node_id,
target_osm_node_id,
ST_X(snapped_geom) AS lon,
ST_Y(snapped_geom) AS lat,
distance_m
FROM candidate
"""
),
{
"dataset_id": dataset_id,
"lon": lon,
"lat": lat,
"radius_deg": radius_deg,
"max_distance_m": max_distance_m,
"mode": mode,
},
).mappings().first()
if row is None:
return None
return {
"dataset_id": dataset_id,
"lon": float(row["lon"]),
"lat": float(row["lat"]),
"distance_m": round(float(row["distance_m"] or 0), 1),
"source": "routing_edge",
"edge_id": int(row["id"]),
"highway": row["highway"],
"name": row["name"],
"source_osm_node_id": int(row["source_osm_node_id"]),
"target_osm_node_id": int(row["target_osm_node_id"]),
}
def _route_cache_key(dataset_id: int, mode: str, from_lon: float, from_lat: float, to_lon: float, to_lat: float) -> tuple[object, ...]:
return (
int(dataset_id),
mode,
round(float(from_lon), 6),
round(float(from_lat), 6),
round(float(to_lon), 6),
round(float(to_lat), 6),
)
def _route_cache_get(key: tuple[object, ...]) -> dict[str, object] | None:
now = time.monotonic()
with _route_cache_lock:
cached = _route_cache.get(key)
if cached is None:
return None
expires_at, payload = cached
if expires_at <= now:
_route_cache.pop(key, None)
return None
_route_cache.move_to_end(key)
return copy.deepcopy(payload)
def _route_cache_put(key: tuple[object, ...], payload: dict[str, object]) -> None:
with _route_cache_lock:
_route_cache[key] = (time.monotonic() + ROUTE_CACHE_TTL_SECONDS, copy.deepcopy(payload))
_route_cache.move_to_end(key)
while len(_route_cache) > ROUTE_CACHE_MAX_ENTRIES:
_route_cache.popitem(last=False)
def _pgrouting_installed(db: Session) -> bool:
return bool(db.execute(text("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgrouting')")).scalar())
def _route_with_pgrouting(
db: Session,
*,
dataset_id: int,
mode: str,
start: _GraphNode,
target: _GraphNode,
from_lon: float,
from_lat: float,
to_lon: float,
to_lat: float,
timeout_ms: int | None = None,
padding_km: tuple[float, ...] | list[float] | None = None,
) -> dict[str, object]:
cost_column, reverse_cost_column = _cost_columns(mode)
routing_cost = _routing_cost_expression(cost_column, mode)
reverse_routing_cost = _routing_cost_expression(reverse_cost_column, mode, reverse=True)
actual_cost_sql = "steps.cost" if mode == "bike" else f"""
CASE
WHEN steps.from_node = edge.source_osm_node_id THEN edge.{cost_column}
ELSE edge.{reverse_cost_column}
END
"""
paddings = list(padding_km or _pgrouting_padding_km(mode))
statement_timeout_ms = int(
timeout_ms if timeout_ms is not None else _pgrouting_timeout_ms(mode)
)
for padding_km in paddings:
_set_local_statement_timeout(
db,
statement_timeout_ms,
)
bbox = _expanded_bbox(
min(from_lon, to_lon, start.lon, target.lon),
min(from_lat, to_lat, start.lat, target.lat),
max(from_lon, to_lon, start.lon, target.lon),
max(from_lat, to_lat, start.lat, target.lat),
padding_km,
)
edge_sql = f"""
SELECT
id,
source_osm_node_id AS source,
target_osm_node_id AS target,
COALESCE({routing_cost}, -1)::float8 AS cost,
COALESCE({reverse_routing_cost}, -1)::float8 AS reverse_cost
FROM routing_edges
WHERE dataset_id = {int(dataset_id)}
AND ({cost_column} IS NOT NULL OR {reverse_cost_column} IS NOT NULL)
AND box(point(max_lon, max_lat), point(min_lon, min_lat))
&& box(point({bbox[2]:.8f}, {bbox[3]:.8f}), point({bbox[0]:.8f}, {bbox[1]:.8f}))
"""
rows = db.execute(
text(
f"""
WITH route AS (
SELECT *
FROM pgr_dijkstra(:edge_sql, :start_node, :target_node, directed := true)
),
steps AS (
SELECT
route.path_seq,
route.node AS from_node,
LEAD(route.node) OVER (ORDER BY route.path_seq) AS to_node,
route.edge,
route.cost
FROM route
)
SELECT
steps.path_seq,
steps.from_node,
steps.to_node,
steps.cost,
edge.id,
edge.source_osm_node_id,
edge.target_osm_node_id,
edge.source_lon,
edge.source_lat,
edge.target_lon,
edge.target_lat,
edge.length_m,
edge.highway,
edge.name,
edge.geometry_geojson,
{actual_cost_sql} AS actual_cost_s
FROM steps
JOIN routing_edges AS edge ON edge.id = steps.edge
WHERE steps.edge <> -1
ORDER BY steps.path_seq
"""
),
{"edge_sql": edge_sql, "start_node": start.osm_node_id, "target_node": target.osm_node_id},
).all()
if rows:
return _pgrouting_payload(
dataset_id=dataset_id,
mode=mode,
start=start,
target=target,
from_lon=from_lon,
from_lat=from_lat,
to_lon=to_lon,
to_lat=to_lat,
rows=rows,
padding_km=padding_km,
)
raise ValueError("pgRouting did not find a route in the bounded search area.")
def _set_local_statement_timeout(db: Session, timeout_ms: int) -> None:
db.execute(text("SELECT set_config('statement_timeout', :timeout, true)"), {"timeout": f"{int(timeout_ms)}ms"})
def _pgrouting_payload(
*,
dataset_id: int,
mode: str,
start: _GraphNode,
target: _GraphNode,
from_lon: float,
from_lat: float,
to_lon: float,
to_lat: float,
rows,
padding_km: float,
) -> dict[str, object]:
previous: dict[int, tuple[int, _Traversal]] = {}
total_cost = 0.0
for row in rows:
if row.to_node is None:
continue
from_node = int(row.from_node)
to_node = int(row.to_node)
source_node = int(row.source_osm_node_id)
target_node = int(row.target_osm_node_id)
actual_cost = float(row.actual_cost_s if row.actual_cost_s is not None else row.cost or 0)
reversed_edge = from_node == target_node and to_node == source_node
if reversed_edge:
from_lon_edge, from_lat_edge = float(row.target_lon), float(row.target_lat)
to_lon_edge, to_lat_edge = float(row.source_lon), float(row.source_lat)
else:
from_lon_edge, from_lat_edge = float(row.source_lon), float(row.source_lat)
to_lon_edge, to_lat_edge = float(row.target_lon), float(row.target_lat)
total_cost += actual_cost
previous[to_node] = (
from_node,
_Traversal(
edge_id=int(row.id),
from_node=from_node,
to_node=to_node,
from_lon=from_lon_edge,
from_lat=from_lat_edge,
to_lon=to_lon_edge,
to_lat=to_lat_edge,
cost_s=actual_cost,
length_m=float(row.length_m),
highway=row.highway,
name=row.name,
geometry_geojson=str(row.geometry_geojson),
reversed=reversed_edge,
),
)
payload = _route_payload(
dataset_id=dataset_id,
mode=mode,
start=start,
target=target,
from_lon=from_lon,
from_lat=from_lat,
to_lon=to_lon,
to_lat=to_lat,
previous=previous,
total_cost_s=total_cost,
visited=len(rows),
)
payload["engine"] = "pgrouting"
payload["bbox_padding_km"] = padding_km
return payload
def _routing_cost_expression(column: str, mode: str, *, reverse: bool = False) -> str:
if mode == "drive":
return column
if mode == "bike":
direction_filter = f"WHEN {_bike_direction_forbidden_sql(reverse=reverse)} THEN NULL"
return f"""
CASE
WHEN {column} IS NULL THEN NULL
WHEN NOT ({_bike_access_sql()}) THEN NULL
{direction_filter}
ELSE (length_m / {BIKE_SPEED_MPS:.4f}) * CASE
WHEN highway = 'cycleway' THEN 0.82
WHEN highway = 'path' THEN 0.95
WHEN highway IN ('living_street', 'residential') THEN 1.00
WHEN highway = 'service' THEN 1.08
WHEN highway = 'track' THEN 1.10
WHEN highway IN ('unclassified', 'road', 'tertiary', 'tertiary_link') THEN 1.12
WHEN highway IN ('secondary', 'secondary_link') THEN 1.22
WHEN highway IN ('primary', 'primary_link') THEN 1.35
WHEN highway IN ('footway', 'pedestrian', 'bridleway') THEN 1.40
ELSE 1.05
END
END
"""
return f"""
CASE
WHEN {column} IS NULL THEN NULL
ELSE {column} * CASE
WHEN highway IN ('footway', 'pedestrian') THEN 0.92
WHEN highway = 'path' THEN 0.94
WHEN highway = 'steps' THEN 1.05
WHEN highway = 'cycleway' THEN 1.02
WHEN highway = 'bridleway' THEN 1.05
WHEN highway = 'living_street' THEN 1.00
WHEN highway IN ('residential', 'service') THEN 1.05
WHEN highway = 'track' THEN 1.10
WHEN highway IN ('unclassified', 'road') THEN 1.12
WHEN highway IN ('tertiary', 'tertiary_link') THEN 1.18
WHEN highway IN ('secondary', 'secondary_link') THEN 1.28
WHEN highway IN ('primary', 'primary_link') THEN 1.40
ELSE 1.08
END
END
"""
def _routing_tags_sql(alias: str | None = None) -> str:
prefix = f"{alias}." if alias else ""
return f"COALESCE(NULLIF({prefix}tags_json, '')::jsonb, '{{}}'::jsonb)"
def _bike_access_sql(alias: str | None = None) -> str:
tags = _routing_tags_sql(alias)
highway = f"{alias}.highway" if alias else "highway"
return f"""
(
{highway} NOT IN ('motorway', 'motorway_link', 'steps', 'platform')
AND NOT (
COALESCE({tags} ->> 'bicycle', '') IN ('no', 'private')
OR (
COALESCE({tags} ->> 'access', '') IN ('no', 'private')
AND COALESCE({tags} ->> 'bicycle', '') NOT IN ('yes', 'designated', 'permissive', 'destination')
AND COALESCE({tags} ->> 'vehicle', '') NOT IN ('yes', 'designated', 'permissive', 'destination')
)
)
AND NOT (
{highway} IN ('footway', 'pedestrian', 'bridleway')
AND COALESCE({tags} ->> 'bicycle', '') NOT IN ('yes', 'designated', 'permissive', 'destination')
)
AND NOT (
{highway} IN ('trunk', 'trunk_link')
AND COALESCE({tags} ->> 'bicycle', '') NOT IN ('yes', 'designated', 'permissive', 'destination')
)
)
"""
def _bike_direction_forbidden_sql(alias: str | None = None, *, reverse: bool) -> str:
tags = _routing_tags_sql(alias)
highway = f"{alias}.highway" if alias else "highway"
bicycle_direction_key = "bicycle:backward" if reverse else "bicycle:forward"
one_way_condition = (
f"COALESCE({tags} ->> 'oneway', '') IN ('yes', 'true', '1') "
f"OR COALESCE({tags} ->> 'junction', '') = 'roundabout' "
f"OR {highway} = 'motorway'"
if reverse
else f"COALESCE({tags} ->> 'oneway', '') = '-1'"
)
return f"""
(
(
{one_way_condition}
)
AND COALESCE({tags} ->> 'oneway:bicycle', '') != 'no'
AND COALESCE({tags} ->> 'bicycle:oneway', '') != 'no'
AND COALESCE({tags} ->> '{bicycle_direction_key}', '') NOT IN ('yes', 'designated', 'permissive')
)
"""
def _nearest_node(db: Session, dataset_id: int, lon: float, lat: float, mode: str) -> _GraphNode | None:
cost_column, reverse_cost_column = _cost_columns(mode)
access_filter = f"AND {_bike_access_sql('edge')}" if mode == "bike" else ""
row = None
for candidate_limit in (64, 512, 4096):
row = db.execute(
text(
f"""
WITH nearest AS MATERIALIZED (
SELECT node.osm_node_id, node.lon, node.lat, node.geom
FROM routing_nodes AS node
WHERE node.dataset_id = :dataset_id
AND node.geom IS NOT NULL
ORDER BY node.geom <-> ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)
LIMIT :candidate_limit
),
candidate AS (
SELECT nearest.osm_node_id, nearest.lon, nearest.lat, nearest.geom
FROM nearest
WHERE EXISTS (
SELECT 1
FROM routing_edges AS edge
WHERE edge.dataset_id = :dataset_id
{access_filter}
AND (
(edge.source_osm_node_id = nearest.osm_node_id AND edge.{cost_column} IS NOT NULL)
OR (edge.target_osm_node_id = nearest.osm_node_id AND edge.{reverse_cost_column} IS NOT NULL)
)
LIMIT 1
)
ORDER BY nearest.geom <-> ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)
LIMIT 1
)
SELECT osm_node_id, lon, lat, ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)) AS distance_m
FROM candidate
"""
),
{"dataset_id": dataset_id, "lon": lon, "lat": lat, "candidate_limit": candidate_limit},
).first()
if row is not None:
break
if row is None:
return None
return _GraphNode(osm_node_id=int(row.osm_node_id), lon=float(row.lon), lat=float(row.lat), distance_m=float(row.distance_m or 0))
def _outgoing_edges(db: Session, dataset_id: int, node_id: int, mode: str) -> list[_Traversal]:
cost_column, reverse_cost_column = _cost_columns(mode)
rows = db.execute(
text(
f"""
SELECT
id, source_osm_node_id, target_osm_node_id,
source_lon, source_lat, target_lon, target_lat,
length_m, highway, name, geometry_geojson,
tags_json,
CASE
WHEN source_osm_node_id = :node_id THEN {cost_column}
ELSE {reverse_cost_column}
END AS cost_s,
target_osm_node_id != :node_id AS forward
FROM routing_edges
WHERE dataset_id = :dataset_id
AND (
(source_osm_node_id = :node_id AND {cost_column} IS NOT NULL)
OR (target_osm_node_id = :node_id AND {reverse_cost_column} IS NOT NULL)
)
"""
),
{"dataset_id": dataset_id, "node_id": node_id},
).all()
edges = []
for row in rows:
forward = bool(row.forward)
cost_s = float(row.cost_s)
if mode == "bike":
tags = _json_object(str(row.tags_json or ""))
if not _bike_access_python(row.highway, tags):
continue
if _bike_direction_forbidden_python(row.highway, tags, reverse=not forward):
continue
cost_s = _bike_edge_seconds(float(row.length_m), row.highway, tags)
if forward:
to_node = int(row.target_osm_node_id)
from_lon, from_lat = float(row.source_lon), float(row.source_lat)
to_lon, to_lat = float(row.target_lon), float(row.target_lat)
else:
to_node = int(row.source_osm_node_id)
from_lon, from_lat = float(row.target_lon), float(row.target_lat)
to_lon, to_lat = float(row.source_lon), float(row.source_lat)
edges.append(
_Traversal(
edge_id=int(row.id),
from_node=node_id,
to_node=to_node,
from_lon=from_lon,
from_lat=from_lat,
to_lon=to_lon,
to_lat=to_lat,
cost_s=cost_s,
length_m=float(row.length_m),
highway=row.highway,
name=row.name,
geometry_geojson=str(row.geometry_geojson),
reversed=not forward,
)
)
return edges
def _route_payload(
*,
dataset_id: int,
mode: str,
start: _GraphNode,
target: _GraphNode,
from_lon: float,
from_lat: float,
to_lon: float,
to_lat: float,
previous: dict[int, tuple[int, _Traversal]],
total_cost_s: float,
visited: int,
) -> dict[str, object]:
edges: list[_Traversal] = []
current = target.osm_node_id
while current != start.osm_node_id:
prior, edge = previous[current]
edges.append(edge)
current = prior
edges.reverse()
network_distance = sum(edge.length_m for edge in edges)
access_distance = start.distance_m + target.distance_m
features = []
if start.distance_m:
features.append(_connector_feature("access", mode, [[from_lon, from_lat], [start.lon, start.lat]], start.distance_m))
for index, edge in enumerate(edges, start=1):
geometry = json.loads(edge.geometry_geojson)
if edge.reversed:
geometry["coordinates"] = list(reversed(geometry.get("coordinates", [])))
features.append(
{
"type": "Feature",
"geometry": geometry,
"properties": {
"feature_type": "routing_edge",
"sequence": index,
"mode": mode,
"edge_id": edge.edge_id,
"highway": edge.highway,
"name": edge.name,
"length_m": edge.length_m,
"cost_s": edge.cost_s,
},
}
)
if target.distance_m:
features.append(_connector_feature("egress", mode, [[target.lon, target.lat], [to_lon, to_lat]], target.distance_m))
duration_seconds = total_cost_s + _connector_seconds(access_distance, mode)
return {
"dataset_id": dataset_id,
"mode": mode,
"engine": "python_astar",
"distance_m": round(network_distance + access_distance, 1),
"network_distance_m": round(network_distance, 1),
"access_distance_m": round(access_distance, 1),
"duration_seconds": round(duration_seconds, 1),
"duration_minutes": _duration_minutes_ceil(duration_seconds),
"duration_label": _duration_label(duration_seconds),
"visited_nodes": visited,
"start_node": {"osm_node_id": start.osm_node_id, "distance_m": round(start.distance_m, 1)},
"target_node": {"osm_node_id": target.osm_node_id, "distance_m": round(target.distance_m, 1)},
"features": feature_collection(features),
}
def _single_point_route(start: _GraphNode, from_lon: float, from_lat: float, to_lon: float, to_lat: float, mode: str, dataset_id: int) -> dict[str, object]:
return _direct_route_payload(
dataset_id=dataset_id,
mode=mode,
from_lon=from_lon,
from_lat=from_lat,
to_lon=to_lon,
to_lat=to_lat,
engine="python_astar",
start_node={"osm_node_id": start.osm_node_id, "distance_m": round(start.distance_m, 1)},
target_node={"osm_node_id": start.osm_node_id, "distance_m": round(start.distance_m, 1)},
visited_nodes=1,
)
def _direct_route_payload(
*,
dataset_id: int,
mode: str,
from_lon: float,
from_lat: float,
to_lon: float,
to_lat: float,
engine: str = "direct_fallback",
start_node: dict[str, object] | None = None,
target_node: dict[str, object] | None = None,
visited_nodes: int = 0,
) -> dict[str, object]:
distance = _distance_m(from_lat, from_lon, to_lat, to_lon)
duration_seconds = _connector_seconds(distance, mode)
return {
"dataset_id": dataset_id,
"mode": mode,
"engine": engine,
"distance_m": round(distance, 1),
"network_distance_m": 0,
"access_distance_m": round(distance, 1),
"duration_seconds": round(duration_seconds, 1),
"duration_minutes": _duration_minutes_ceil(duration_seconds),
"duration_label": _duration_label(duration_seconds),
"visited_nodes": visited_nodes,
"start_node": start_node,
"target_node": target_node,
"features": feature_collection([_connector_feature("direct", mode, [[from_lon, from_lat], [to_lon, to_lat]], distance)]),
}
def _connector_feature(kind: str, mode: str, coordinates: list[list[float]], distance_m: float) -> dict:
return {
"type": "Feature",
"geometry": {"type": "LineString", "coordinates": coordinates},
"properties": {
"feature_type": "routing_connector",
"connector": kind,
"mode": mode,
"length_m": distance_m,
"cost_s": _connector_seconds(distance_m, mode),
},
}
def _connector_seconds(distance_m: float, mode: str) -> float:
speed = 1.35 if mode == "walk" else BIKE_SPEED_MPS if mode == "bike" else 8.0
return float(distance_m) / speed
def _bike_edge_seconds(length_m: float, highway: str | None, tags: dict[str, object]) -> float:
factor = {
"cycleway": 0.82,
"path": 0.95,
"living_street": 1.0,
"residential": 1.0,
"service": 1.08,
"track": 1.10,
"unclassified": 1.12,
"road": 1.12,
"tertiary": 1.12,
"tertiary_link": 1.12,
"secondary": 1.22,
"secondary_link": 1.22,
"primary": 1.35,
"primary_link": 1.35,
"footway": 1.40,
"pedestrian": 1.40,
"bridleway": 1.40,
}.get(str(highway or ""), 1.05)
return float(length_m) / BIKE_SPEED_MPS * factor
def _bike_access_python(highway: str | None, tags: dict[str, object]) -> bool:
highway_text = str(highway or "")
bicycle = _tag_text(tags, "bicycle")
access = _tag_text(tags, "access")
vehicle = _tag_text(tags, "vehicle")
if highway_text in {"motorway", "motorway_link", "steps", "platform"}:
return False
if bicycle in {"no", "private"}:
return False
if access in {"no", "private"} and bicycle not in {"yes", "designated", "permissive", "destination"} and vehicle not in {"yes", "designated", "permissive", "destination"}:
return False
if highway_text in {"footway", "pedestrian", "bridleway"} and bicycle not in {"yes", "designated", "permissive", "destination"}:
return False
if highway_text in {"trunk", "trunk_link"} and bicycle not in {"yes", "designated", "permissive", "destination"}:
return False
return True
def _bike_direction_forbidden_python(highway: str | None, tags: dict[str, object], *, reverse: bool) -> bool:
oneway = _tag_text(tags, "oneway")
junction = _tag_text(tags, "junction")
if reverse:
one_way = oneway in {"yes", "true", "1"} or junction == "roundabout" or str(highway or "") == "motorway"
else:
one_way = oneway == "-1"
if not one_way:
return False
bicycle_direction_key = "bicycle:backward" if reverse else "bicycle:forward"
return (
_tag_text(tags, "oneway:bicycle") != "no"
and _tag_text(tags, "bicycle:oneway") != "no"
and _tag_text(tags, bicycle_direction_key) not in {"yes", "designated", "permissive"}
)
def _tag_text(tags: dict[str, object], key: str) -> str:
return str(tags.get(key) or "").strip().lower()
def _json_object(value: str | None) -> dict[str, object]:
if not value:
return {}
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _duration_minutes_ceil(seconds: int | float | None) -> int | None:
if seconds is None:
return None
return max(0, int(math.ceil(float(seconds) / 60)))
def _duration_label(seconds: int | float | None) -> str | None:
minutes_total = _duration_minutes_ceil(seconds)
if minutes_total is None:
return None
days = minutes_total // (24 * 60)
remaining = minutes_total % (24 * 60)
hours = remaining // 60
minutes = remaining % 60
if days:
return f"{days}d {hours:02d}:{minutes:02d}"
if hours:
return f"{hours}:{minutes:02d}"
return f"{minutes} min"
def _expanded_bbox(min_lon: float, min_lat: float, max_lon: float, max_lat: float, padding_km: float) -> tuple[float, float, float, float]:
mid_lat = (min_lat + max_lat) / 2
lat_delta = padding_km / 111.0
lon_delta = padding_km / max(1.0, 111.0 * math.cos(math.radians(mid_lat)))
return (min_lon - lon_delta, min_lat - lat_delta, max_lon + lon_delta, max_lat + lat_delta)
def _distance_m(lat_a: float, lon_a: float, lat_b: float, lon_b: float) -> float:
radius = 6_371_000.0
phi_a = math.radians(lat_a)
phi_b = math.radians(lat_b)
delta_phi = math.radians(lat_b - lat_a)
delta_lambda = math.radians(lon_b - lon_a)
hav = math.sin(delta_phi / 2) ** 2 + math.cos(phi_a) * math.cos(phi_b) * math.sin(delta_lambda / 2) ** 2
return radius * 2 * math.atan2(math.sqrt(hav), math.sqrt(1 - hav))