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
145 lines
7.4 KiB
Python
145 lines
7.4 KiB
Python
"""Тонкий клиент локального 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
|