"""Тонкий клиент OpenRouteService (ORS) — переиспользуемый между фичами. Сейчас ORS используется для: - изохрон доступности (`/parcels/{cad}/isochrones`, см. parcels.get_isochrones) - routing-based POI decay (#41, см. poi_score.compute_poi_routing_decay) — `/matrix` Free tier: 2000 запросов/день. Поэтому: - один HTTP-вызов на analyze (батч до ~1000 destinations в /matrix) - явный httpx timeout (без него worker зависает) - graceful: нет API-ключа / 429 / сетевой сбой → `OrsUnavailableError`, caller делает fallback на straight-line, а не валит analyze. """ from __future__ import annotations import logging import httpx from app.core.config import settings logger = logging.getLogger(__name__) _ORS_MATRIX_BASE = "https://api.openrouteservice.org/v2/matrix" # Профили ORS-routing. foot-walking — пеший радиус (метро/школа/магазин), # driving-car — авто (для будущих авто-категорий). VALID_PROFILES: frozenset[str] = frozenset( {"foot-walking", "cycling-regular", "driving-car"} ) # ORS /matrix ограничивает foot-walking ~2000 пар (sources×destinations) на free tier. MAX_MATRIX_DESTINATIONS = 1000 _DEFAULT_TIMEOUT_S = 12.0 class OrsUnavailableError(Exception): """ORS недоступен (нет ключа / rate-limit / сетевой сбой / битый ответ). Caller обязан поймать и сделать fallback (straight-line), не валя запрос. """ def is_configured() -> bool: """True если задан API-ключ ORS (иначе сразу fallback без HTTP).""" return bool(settings.openrouteservice_api_key) def matrix_durations_min( origin_lon: float, origin_lat: float, destinations: list[tuple[float, float]], *, profile: str = "foot-walking", timeout_s: float = _DEFAULT_TIMEOUT_S, ) -> list[float | None]: """Время в пути (минуты) от одного origin до каждого destination через ORS /matrix. Args: origin_lon, origin_lat: координаты источника (центроид участка). destinations: список (lon, lat) — POI. Усечётся до MAX_MATRIX_DESTINATIONS. profile: ORS-профиль ('foot-walking' по умолчанию — пешая доступность). timeout_s: httpx timeout (обязателен — иначе worker зависает). Returns: Список той же длины, что усечённый `destinations`. Элемент — минуты в пути (float) или None, если ORS не смог построить маршрут до этой точки (например, POI на острове без пешеходной связи). Raises: OrsUnavailableError: нет ключа / mode невалиден / rate-limit / сетевой сбой / неожиданный формат ответа. Caller делает straight-line fallback. """ if not is_configured(): raise OrsUnavailableError("OPENROUTESERVICE_API_KEY не задан") if profile not in VALID_PROFILES: raise OrsUnavailableError(f"Недопустимый ORS profile: {profile!r}") if not destinations: return [] dests = destinations[:MAX_MATRIX_DESTINATIONS] if len(destinations) > MAX_MATRIX_DESTINATIONS: logger.warning( "ors matrix: %d destinations усечено до %d (free-tier limit)", len(destinations), MAX_MATRIX_DESTINATIONS, ) # ORS /matrix: locations[0] — source, остальные — destinations. locations: list[list[float]] = [[origin_lon, origin_lat]] locations.extend([lon, lat] for lon, lat in dests) body = { "locations": locations, "sources": [0], "destinations": list(range(1, len(locations))), "metrics": ["duration"], "units": "m", } headers = { "Authorization": settings.openrouteservice_api_key, "Content-Type": "application/json", "Accept": "application/json", } url = f"{_ORS_MATRIX_BASE}/{profile}" try: with httpx.Client(timeout=timeout_s) as client: resp = client.post(url, json=body, headers=headers) resp.raise_for_status() data = resp.json() except httpx.HTTPStatusError as exc: if exc.response.status_code == 429: raise OrsUnavailableError("ORS daily limit (2000 req) exceeded") from exc raise OrsUnavailableError( f"ORS matrix HTTP {exc.response.status_code}: {exc.response.text[:200]}" ) from exc except Exception as exc: # сетевой сбой / JSON decode — fallback, не 500 raise OrsUnavailableError(f"ORS matrix request failed: {exc}") from exc # Ответ: {"durations": [[d1, d2, ...]]} — секунды, одна строка (один source). durations = data.get("durations") if not isinstance(durations, list) or not durations: raise OrsUnavailableError("ORS matrix: пустой/битый durations в ответе") row = durations[0] if not isinstance(row, list): raise OrsUnavailableError("ORS matrix: durations[0] не список") out: list[float | None] = [] for sec in row: if sec is None: out.append(None) # ORS не построил маршрут до этой точки else: out.append(float(sec) / 60.0) # Длина durations должна совпадать с числом destinations — иначе zip(strict=True) # у вызывающего бросит ValueError (не OrsUnavailableError) → 500. Закрываем как ORS-сбой. if len(out) != len(dests): raise OrsUnavailableError( f"ORS matrix: durations ({len(out)}) != destinations ({len(dests)})" ) return out