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