Next alpha stage commit

This commit is contained in:
2026-07-06 20:54:46 +02:00
parent e23387738b
commit 1d7ec956d2
42 changed files with 11603 additions and 2837 deletions

View File

@@ -20,12 +20,18 @@ 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()
@@ -123,6 +129,43 @@ def _metadata(dataset: Dataset) -> dict[str, object]:
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,
*,
@@ -133,9 +176,11 @@ def route_between_points(
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]:
if mode not in {"walk", "drive"}:
raise ValueError("mode must be walk or drive")
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.")
@@ -152,7 +197,8 @@ def route_between_points(
payload = _single_point_route(start, from_lon, from_lat, to_lon, to_lat, mode, dataset_id)
_route_cache_put(cache_key, payload)
return payload
if settings.is_postgresql_database and _pgrouting_installed(db):
pgrouting_available = settings.is_postgresql_database and _pgrouting_installed(db)
if pgrouting_available:
try:
payload = _route_with_pgrouting(
db,
@@ -164,15 +210,23 @@ def route_between_points(
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:
pass
if not allow_python_fallback:
raise
except SQLAlchemyError:
db.rollback()
if not allow_python_fallback:
raise
heuristic_mps = WALK_HEURISTIC_MPS if mode == "walk" else DRIVE_HEURISTIC_MPS
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}
@@ -226,8 +280,7 @@ def direct_route_between_points(
dataset_id: int | None = None,
reason: str | None = None,
) -> dict[str, object]:
if mode not in {"walk", "drive"}:
raise ValueError("mode must be walk or drive")
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),
@@ -251,8 +304,7 @@ def snap_point_to_routing_graph(
dataset_id: int | None = None,
max_distance_m: float = 250,
) -> dict[str, object] | None:
if mode not in {"walk", "drive"}:
raise ValueError("mode must be walk or drive")
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
@@ -288,8 +340,8 @@ def _snap_point_to_routing_edge_postgresql(
mode: str,
max_distance_m: float,
) -> dict[str, object] | None:
cost_column = "walk_cost_s" if mode == "walk" else "drive_cost_s"
reverse_cost_column = "reverse_walk_cost_s" if mode == "walk" else "reverse_drive_cost_s"
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(
@@ -319,6 +371,7 @@ def _snap_point_to_routing_edge_postgresql(
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),
@@ -337,13 +390,16 @@ def _snap_point_to_routing_edge_postgresql(
ST_DistanceSphere(edges.edge_geom, point.geom) AS distance_m,
CASE
WHEN edges.highway IN ('footway', 'pedestrian', 'steps') THEN 0
WHEN edges.highway IN ('path', 'cycleway', 'bridleway') THEN 1
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 != 'walk' THEN 0
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
@@ -359,7 +415,9 @@ def _snap_point_to_routing_edge_postgresql(
)
ORDER BY
ST_DistanceSphere(edges.edge_geom, point.geom) + CASE
WHEN :mode != 'walk' THEN 0
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
@@ -455,15 +513,26 @@ def _route_with_pgrouting(
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 = "walk_cost_s" if mode == "walk" else "drive_cost_s"
reverse_cost_column = "reverse_walk_cost_s" if mode == "walk" else "reverse_drive_cost_s"
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)
for padding_km in PGR_WALK_BBOX_PADDING_KM if mode == "walk" else PGR_DRIVE_BBOX_PADDING_KM:
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,
PGR_WALK_STATEMENT_TIMEOUT_MS if mode == "walk" else PGR_DRIVE_STATEMENT_TIMEOUT_MS,
statement_timeout_ms,
)
bbox = _expanded_bbox(
min(from_lon, to_lon, start.lon, target.lon),
@@ -517,10 +586,7 @@ def _route_with_pgrouting(
edge.highway,
edge.name,
edge.geometry_geojson,
CASE
WHEN steps.from_node = edge.source_osm_node_id THEN edge.{cost_column}
ELSE edge.{reverse_cost_column}
END AS actual_cost_s
{actual_cost_sql} AS actual_cost_s
FROM steps
JOIN routing_edges AS edge ON edge.id = steps.edge
WHERE steps.edge <> -1
@@ -616,33 +682,109 @@ def _pgrouting_payload(
return payload
def _routing_cost_expression(column: str, mode: str) -> str:
if mode != "walk":
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.70
WHEN highway = 'path' THEN 0.78
WHEN highway = 'steps' THEN 0.95
WHEN highway = 'cycleway' THEN 1.05
WHEN highway = 'bridleway' THEN 1.10
WHEN highway IN ('living_street', 'track') THEN 1.15
WHEN highway IN ('residential', 'service') THEN 1.35
WHEN highway IN ('unclassified', 'road') THEN 1.55
WHEN highway IN ('tertiary', 'tertiary_link') THEN 1.80
WHEN highway IN ('secondary', 'secondary_link') THEN 2.15
WHEN highway IN ('primary', 'primary_link') THEN 2.50
ELSE 1.30
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 = "walk_cost_s" if mode == "walk" else "drive_cost_s"
reverse_cost_column = "reverse_walk_cost_s" if mode == "walk" else "reverse_drive_cost_s"
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(
@@ -663,6 +805,7 @@ def _nearest_node(db: Session, dataset_id: int, lon: float, lat: float, mode: st
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)
@@ -686,8 +829,7 @@ def _nearest_node(db: Session, dataset_id: int, lon: float, lat: float, mode: st
def _outgoing_edges(db: Session, dataset_id: int, node_id: int, mode: str) -> list[_Traversal]:
cost_column = "walk_cost_s" if mode == "walk" else "drive_cost_s"
reverse_cost_column = "reverse_walk_cost_s" if mode == "walk" else "reverse_drive_cost_s"
cost_column, reverse_cost_column = _cost_columns(mode)
rows = db.execute(
text(
f"""
@@ -695,6 +837,7 @@ def _outgoing_edges(db: Session, dataset_id: int, node_id: int, mode: str) -> li
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}
@@ -713,6 +856,14 @@ def _outgoing_edges(db: Session, dataset_id: int, node_id: int, mode: str) -> li
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)
@@ -730,7 +881,7 @@ def _outgoing_edges(db: Session, dataset_id: int, node_id: int, mode: str) -> li
from_lat=from_lat,
to_lon=to_lon,
to_lat=to_lat,
cost_s=float(row.cost_s),
cost_s=cost_s,
length_m=float(row.length_m),
highway=row.highway,
name=row.name,
@@ -869,10 +1020,82 @@ def _connector_feature(kind: str, mode: str, coordinates: list[list[float]], dis
def _connector_seconds(distance_m: float, mode: str) -> float:
speed = 1.35 if mode == "walk" else 8.0
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