Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m42s
CI / backend-tests (pull_request) Successful in 7m40s
Deploy / changes (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
#41: per-category routing-radius decay через reusable ORS /matrix client (foot-walk время + per-category пороги + decay-curves), opt-in ?decay=routing, graceful straight-line fallback (analyze не 500 при ORS down) + length-guard durations==destinations. #44: +5 OSM-категорий (transformer/gas/water/sewerage/heat) via Overpass nwr; nearest_{water_main,substation,gas,heat}_m в analyze.utilities. #255bk: ST_AsGeoJSON(CAST(geom AS geometry)) в _get_cad_zouit_overlaps → geom_geojson. Closes #41 Closes #44
138 lines
6 KiB
Python
138 lines
6 KiB
Python
"""Тонкий клиент 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
|