gendesign/backend/app/services/site_finder/osrm_client_local.py
Light1YT 8a3bae0cda
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m46s
CI / backend-tests (pull_request) Successful in 11m55s
feat(analyze): per-category OSRM routing (foot vs driving) (#39 A3)
Walk-relevant POIs (school/shop/park/kindergarten/pharmacy/stops) now route
via a FOOT OSRM graph (osrm-walk service), car-relevant POIs (mall/hospital +
unknown) keep the DRIVING graph. Validation showed driving overstated
pedestrian-proximity distance — median 1.6-2.9x straight-line (#39).

- config: osrm_walk_local_url + osrm_walk_categories (frozenset, 9 walk cats)
- osrm_client_local: base_url override on get_road_distances_m (default unchanged)
- _apply_osrm_road_distances: split POIs by category, per-group OSRM call with
  independent graceful fallback (one server down -> its group keeps straight-line),
  in-place write-back by original index; never raises; flag-OFF byte-identical
- docker-compose: osrm-walk service (foot graph, internal, mem_limit 1.5g)
- build_osrm.sh: CAR=0 gate for foot-only refresh (doesn't touch live car graph)
- tests: per-category split, per-group fallback, asymmetric intra-group write-back

Still flag-gated (use_osrm_distances OFF) — enabling is a product decision.
Refs #39
2026-06-27 04:30:25 +05:00

145 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тонкий клиент локального OSRM — реальное дорожное расстояние для /analyze (#39 A2).
В отличие от ORS (`ors_client.py`, внешний SaaS с лимитом 2000/день и /matrix POST),
это self-hosted OSRM (docker-compose service `osrm`, `settings.osrm_local_url`). Его
table-API — это **GET** с координатами в URL и `annotations=distance`, отдающий
**реальное дорожное расстояние в МЕТРАХ** (а не durations×скорость — это была бы
ORS-семантика):
GET {url}/table/v1/driving/{lon0},{lat0};{lon1},{lat1};...?sources=0&annotations=distance
{"code":"Ok","distances":[[0, d1, d2, ...]]} # метры; row[0] = origin→каждый dest
origin = центроид участка (первая координата, sources=0); destinations = POI.
Читаем `distances[0][1:]` (пропускаем self origin→origin = 0). Недостижимый POI =
`null` → возвращаем None по этому индексу (caller оставит straight-line).
Graceful: при ЛЮБОМ сбое (HTTP, timeout, code!=Ok, битая форма) → `OsrmLocalUnavailableError`.
Caller обязан поймать и сделать fallback на straight-line, НЕ валя /analyze.
OSRM запускается с `--max-table-size 8000`, поэтому ≤100 POI на analyze проходят
без усечения.
"""
from __future__ import annotations
import logging
from urllib.parse import quote
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
# OSRM `--max-table-size` (число пар sources+destinations). Дефолт сервиса — 8000;
# с 1 origin это 7999 POI, что с запасом покрывает наши ≤100. Держим как guard.
MAX_TABLE_SIZE = 8000
class OsrmLocalUnavailableError(Exception):
"""Локальный OSRM недоступен (сетевой сбой / timeout / code!=Ok / битый ответ).
Caller обязан поймать и сделать fallback (straight-line), не валя /analyze.
"""
def _fmt_coord(lon: float, lat: float) -> str:
"""OSRM-координата `lon,lat`, URL-safe (на случай экзотических локалей/инпута)."""
return f"{quote(str(lon), safe='')},{quote(str(lat), safe='')}"
def get_road_distances_m(
origin_lon: float,
origin_lat: float,
destinations: list[tuple[float, float]],
*,
profile: str = "driving",
timeout_s: float | None = None,
base_url: str | None = None,
) -> list[float | None]:
"""Дорожное расстояние (метры) от одного origin до каждого destination через OSRM /table.
Args:
origin_lon, origin_lat: координаты источника (центроид участка) = sources=0.
destinations: список (lon, lat) — POI. ≤MAX_TABLE_SIZE-1 (для нас всегда).
profile: OSRM-профиль ('driving' по умолчанию; 'foot' для пешего графа).
timeout_s: httpx timeout (обязателен — иначе worker зависает).
None → settings.osrm_distance_timeout_s.
base_url: базовый URL OSRM-сервиса. None → settings.osrm_local_url
(DRIVING-граф, поведение по умолчанию). Передаётся, например,
settings.osrm_walk_local_url для маршрутизации walk-POI по FOOT-графу
(#39 A3).
Returns:
Список той же длины, что `destinations`, выровненный по индексу. Элемент —
дорожное расстояние в метрах (float) или None, если OSRM не смог построить
маршрут до этой точки (POI на острове/без дорожной связи) — caller оставит
straight-line по этому POI. Пустой `destinations` → [].
Raises:
OsrmLocalUnavailableError: сетевой сбой / timeout / HTTP!=2xx / code!=Ok /
неожиданный формат / число расстояний != числу destinations. Caller
делает straight-line fallback, /analyze не падает.
"""
if not destinations:
return []
if len(destinations) > MAX_TABLE_SIZE - 1:
# OSRM отвергнет таблицу больше max-table-size — считаем это «недоступностью»
# для данного запроса (caller → straight-line по всем POI).
raise OsrmLocalUnavailableError(
f"OSRM table: {len(destinations)} destinations > max-table-size "
f"({MAX_TABLE_SIZE - 1} с одним origin)"
)
effective_timeout = timeout_s if timeout_s is not None else settings.osrm_distance_timeout_s
# Координаты `;`-joined: origin первой (→ sources=0), затем POI по порядку.
coords = ";".join(
[_fmt_coord(origin_lon, origin_lat)]
+ [_fmt_coord(lon, lat) for lon, lat in destinations]
)
base = (base_url if base_url is not None else settings.osrm_local_url).rstrip("/")
url = f"{base}/table/v1/{profile}/{coords}"
params = {"sources": "0", "annotations": "distance"}
try:
with httpx.Client(timeout=effective_timeout) as client:
resp = client.get(url, params=params)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
raise OsrmLocalUnavailableError(
f"OSRM table HTTP {exc.response.status_code}: {exc.response.text[:200]}"
) from exc
except Exception as exc: # сетевой сбой / timeout / JSON decode → fallback, не 500
raise OsrmLocalUnavailableError(f"OSRM table request failed: {exc}") from exc
if not isinstance(data, dict) or data.get("code") != "Ok":
code = data.get("code") if isinstance(data, dict) else "<non-dict>"
raise OsrmLocalUnavailableError(f"OSRM table: code != Ok ({code!r})")
# Ответ: {"distances": [[0, d1, d2, ...]]} — метры, одна строка (один source).
distances = data.get("distances")
if not isinstance(distances, list) or not distances:
raise OsrmLocalUnavailableError("OSRM table: пустой/битый distances в ответе")
row = distances[0]
if not isinstance(row, list) or not row:
raise OsrmLocalUnavailableError("OSRM table: distances[0] не список / пуст")
# row[0] = origin→origin = 0 (self) — пропускаем, читаем 1:.
dest_distances = row[1:]
out: list[float | None] = []
for d in dest_distances:
if d is None:
out.append(None) # OSRM не построил маршрут до этой точки
else:
out.append(float(d))
# Длина должна совпадать с числом destinations — иначе zip у вызывающего
# рассинхронит POI↔distance. Закрываем как OSRM-сбой → straight-line fallback.
if len(out) != len(destinations):
raise OsrmLocalUnavailableError(
f"OSRM table: distances ({len(out)}) != destinations ({len(destinations)})"
)
return out