feat(site-finder): /parcels/{cad}/isochrones via OpenRouteService
This commit is contained in:
parent
6ce634a9b9
commit
923f250926
2 changed files with 127 additions and 1 deletions
|
|
@ -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×_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.",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue