gendesign/backend/app/services/site_finder/ors_client.py
Light1YT 50555821fb
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
feat(site_finder): routing POI decay (#41) + OSM utility distances (#44) + ЗОУИТ geom (#255bk)
#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
2026-06-13 22:31:56 +05:00

138 lines
6 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.

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