feat(site-finder): /parcels/{cad}/isochrones via OpenRouteService

This commit is contained in:
lekss361 2026-05-11 21:44:22 +03:00
parent 6ce634a9b9
commit 923f250926
2 changed files with 127 additions and 1 deletions

View file

@ -4,10 +4,11 @@ import math
from typing import Annotated, Any
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.schemas.parcel import ParcelDetail, ParcelSearchRequest, ParcelSearchResponse
@ -805,4 +806,124 @@ def analyze_parcel(
"utilities": utilities,
"geotech_risk": _geotech_risk(66, db, geom_wkt),
"market_trend": market_trend,
"isochrones_available": bool(settings.openrouteservice_api_key),
}
_ORS_BASE = "https://api.openrouteservice.org/v2/isochrones"
_ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"})
@router.get("/{cad_num}/isochrones")
def get_isochrones(
cad_num: str,
db: Annotated[Session, Depends(get_db)],
mode: str = "foot-walking",
times_min: Annotated[list[int], Query()] = [10, 15], # noqa: B006
) -> dict[str, Any]:
"""Изохроны доступности от центроида участка через OpenRouteService.
Modes: foot-walking | cycling-regular | driving-car.
times_min список минут (1-60), например ?times_min=10&times_min=15.
Возвращает GeoJSON FeatureCollection для рендера на карте.
"""
if not settings.openrouteservice_api_key:
raise HTTPException(
status_code=503,
detail=(
"OPENROUTESERVICE_API_KEY не задан в env. "
"Получи free key на https://openrouteservice.org/dev/#/signup "
"и пропиши в backend/.env.runtime"
),
)
if mode not in _ORS_VALID_MODES:
raise HTTPException(
status_code=422,
detail=f"Недопустимый mode '{mode}'. Допустимые: {sorted(_ORS_VALID_MODES)}",
)
invalid_times = [t for t in times_min if not (1 <= t <= 60)]
if invalid_times:
raise HTTPException(
status_code=422,
detail=f"times_min значения вне диапазона 1-60: {invalid_times}",
)
# Получить координаты центроида из доступных геометрий участка
coord_row = (
db.execute(
text("""
SELECT ST_X(ST_Centroid(g.geom)) AS lon,
ST_Y(ST_Centroid(g.geom)) AS lat
FROM (
SELECT geom FROM cad_quarters_geom WHERE cad_number = :c
UNION ALL
SELECT geom FROM cad_buildings WHERE cad_num = :c
UNION ALL
SELECT geom FROM cad_parcels_geom WHERE cad_num = :c
) g
LIMIT 1
"""),
{"c": cad_num},
)
.mappings()
.first()
)
if not coord_row:
raise HTTPException(
status_code=404,
detail=f"Геометрия для {cad_num} не найдена.",
)
lat = float(coord_row["lat"])
lon = float(coord_row["lon"])
# OpenRouteService isochrones API — POST с JSON body
url = f"{_ORS_BASE}/{mode}"
body: dict[str, Any] = {
"locations": [[lon, lat]],
"range": [t * 60 for t in times_min], # ORS ожидает секунды
"range_type": "time",
"units": "m",
}
headers = {
"Authorization": settings.openrouteservice_api_key,
"Content-Type": "application/json",
"Accept": "application/geo+json",
}
try:
with httpx.Client(timeout=10) as client:
resp = client.post(url, json=body, headers=headers)
resp.raise_for_status()
geojson = resp.json()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 429:
raise HTTPException(
status_code=429,
detail="OpenRouteService daily limit (2000 req) exceeded.",
) from exc
logger.error("ORS HTTP error for %s: %s%s", cad_num, exc.response.status_code, exc)
raise HTTPException(
status_code=500,
detail=f"ORS error {exc.response.status_code}: {exc.response.text[:200]}",
) from exc
except Exception as exc:
logger.error("ORS request failed for %s: %s", cad_num, exc)
raise HTTPException(
status_code=500,
detail=f"Isochrones fetch failed: {exc}",
) from exc
return {
"cad_num": cad_num,
"lat": lat,
"lon": lon,
"mode": mode,
"times_min": times_min,
"geojson": geojson,
"source": "openrouteservice.org",
"note": "Free tier 2000 req/day. Замена на self-hosted OSRM — в #27.",
}

View file

@ -92,5 +92,10 @@ class Settings(BaseSettings):
# OBJECTIVE_ANTON_SQLITE_PATH=C:/Users/user/source/repos/gendesign/sf_anton_snapshot.db
objective_anton_sqlite_path: str = "/data/anton-sqlite/analysis.db"
# OpenRouteService API (https://openrouteservice.org/dev/#/signup).
# Free tier: 2000 запросов/день. Используется в /parcels/{cad}/isochrones.
# Если не задан — endpoint вернёт 503 с инструкцией по регистрации.
openrouteservice_api_key: str = ""
settings = Settings()