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
|
from typing import Annotated, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.schemas.parcel import ParcelDetail, ParcelSearchRequest, ParcelSearchResponse
|
from app.schemas.parcel import ParcelDetail, ParcelSearchRequest, ParcelSearchResponse
|
||||||
|
|
||||||
|
|
@ -805,4 +806,124 @@ def analyze_parcel(
|
||||||
"utilities": utilities,
|
"utilities": utilities,
|
||||||
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
||||||
"market_trend": market_trend,
|
"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=C:/Users/user/source/repos/gendesign/sf_anton_snapshot.db
|
||||||
objective_anton_sqlite_path: str = "/data/anton-sqlite/analysis.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()
|
settings = Settings()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue