Backend (parcels.py): - _compute_confidence() composite score 0..1 from 7 subscores: poi_freshness, geom_source (parcel vs quarter), district, market_trend (rosreestr_deals depth), competitors, environment (noise/air/weather availability), zoning (placeholder до G1). - confidence_label: high (>0.75) / medium (0.4-0.75) / low (<0.4) - confidence_caveats: list of конкретных проблем для UI - confidence_breakdown: per-subscore 0..1 для прозрачности Это stub-версия (полная — после G1/G2/D1/D2). Использует только текущие сигналы. Frontend: - Новый ConfidenceBadge.tsx — color-coded (green/yellow/red) badge с % - Caveats для low — показываются сразу; для medium/high — под toggle - Toggle "Подробнее" → breakdown per-subscore + полный список caveats - Размещён в начале OverviewTab (выше "Район") - TS типы расширены: confidence, confidence_label, confidence_breakdown, confidence_caveats Closes #48. Co-authored-by: lekss361 <claudestars@proton.me>
1200 lines
48 KiB
Python
1200 lines
48 KiB
Python
import json
|
||
import logging
|
||
import math
|
||
from typing import Annotated, Any
|
||
|
||
import httpx
|
||
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
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
# Базовые уровни шума по типу источника (дБ на 10м) — источник: WHO Environmental Noise Guidelines
|
||
NOISE_L_BASE: dict[str, float] = {
|
||
"highway:motorway": 75.0,
|
||
"highway:trunk": 75.0,
|
||
"highway:primary": 70.0,
|
||
"highway:secondary": 65.0,
|
||
"highway:tertiary": 60.0,
|
||
"highway:residential": 55.0,
|
||
"railway": 72.0,
|
||
"industrial": 65.0,
|
||
"aerodrome": 70.0,
|
||
}
|
||
|
||
|
||
def _wind_label(deg: float) -> str:
|
||
"""Перевести угол направления ветра (0-360) в 8-позиционную розу на русском."""
|
||
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
|
||
idx = round(deg / 45) % 8
|
||
return rose[idx]
|
||
|
||
|
||
def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None:
|
||
"""Синхронный запрос к Open-Meteo Air Quality API.
|
||
|
||
Возвращает данные текущего часа (первый элемент hourly). None если API
|
||
недоступен или вернул неожиданный формат.
|
||
"""
|
||
try:
|
||
with httpx.Client(timeout=5) as c:
|
||
r = c.get(
|
||
"https://air-quality-api.open-meteo.com/v1/air-quality",
|
||
params={
|
||
"latitude": lat,
|
||
"longitude": lon,
|
||
"hourly": "pm2_5,pm10,nitrogen_dioxide",
|
||
"forecast_days": 1,
|
||
},
|
||
)
|
||
r.raise_for_status()
|
||
data = r.json()
|
||
hourly = data.get("hourly", {})
|
||
if not hourly.get("time"):
|
||
return None
|
||
return {
|
||
"pm2_5": hourly["pm2_5"][0] if hourly.get("pm2_5") else None,
|
||
"pm10": hourly["pm10"][0] if hourly.get("pm10") else None,
|
||
"no2": hourly["nitrogen_dioxide"][0] if hourly.get("nitrogen_dioxide") else None,
|
||
"ts": hourly["time"][0],
|
||
"source": "open-meteo",
|
||
}
|
||
except Exception as e:
|
||
logger.warning("air quality fetch failed: %s", e)
|
||
return None
|
||
|
||
|
||
def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None:
|
||
"""Open-Meteo Climate API — 30-летние нормали по сезонам.
|
||
|
||
Использует модель MRI-AGCM3.2-S (японская, точная для всего мира).
|
||
Группирует 1995-2024 по четырём сезонам. Медленнее прогноза — timeout 15s.
|
||
"""
|
||
try:
|
||
with httpx.Client(timeout=15) as c:
|
||
r = c.get(
|
||
"https://climate-api.open-meteo.com/v1/climate",
|
||
params={
|
||
"latitude": lat,
|
||
"longitude": lon,
|
||
"start_date": "1995-01-01",
|
||
"end_date": "2024-12-31",
|
||
"models": "MRI_AGCM3_2_S",
|
||
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
|
||
},
|
||
)
|
||
r.raise_for_status()
|
||
data = r.json()
|
||
daily = data.get("daily", {})
|
||
times = daily.get("time") or []
|
||
t_max = daily.get("temperature_2m_max") or []
|
||
t_min = daily.get("temperature_2m_min") or []
|
||
precip = daily.get("precipitation_sum") or []
|
||
if not times:
|
||
return None
|
||
|
||
seasons_months = {
|
||
"winter": [12, 1, 2],
|
||
"spring": [3, 4, 5],
|
||
"summer": [6, 7, 8],
|
||
"autumn": [9, 10, 11],
|
||
}
|
||
buckets: dict[str, dict[str, list[float]]] = {
|
||
k: {"t_max": [], "t_min": [], "precip": []} for k in seasons_months
|
||
}
|
||
for i, t in enumerate(times):
|
||
month = int(t[5:7]) # 'YYYY-MM-DD'
|
||
for season, months in seasons_months.items():
|
||
if month in months:
|
||
if i < len(t_max) and t_max[i] is not None:
|
||
buckets[season]["t_max"].append(t_max[i])
|
||
if i < len(t_min) and t_min[i] is not None:
|
||
buckets[season]["t_min"].append(t_min[i])
|
||
if i < len(precip) and precip[i] is not None:
|
||
buckets[season]["precip"].append(precip[i])
|
||
break
|
||
|
||
seasons: dict[str, Any] = {}
|
||
for season, vals in buckets.items():
|
||
if not vals["t_max"]:
|
||
seasons[season] = None
|
||
continue
|
||
seasons[season] = {
|
||
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
|
||
"avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1),
|
||
"max_t_c": round(max(vals["t_max"]), 1),
|
||
"min_t_c": round(min(vals["t_min"]), 1),
|
||
"avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
|
||
"total_precip_mm": round(sum(vals["precip"]), 0),
|
||
"days_observed": len(vals["t_max"]),
|
||
}
|
||
return {
|
||
"seasons": seasons,
|
||
"period": "1995-2024 (30 лет)",
|
||
"model": "MRI-AGCM3-2-S",
|
||
"source": "open-meteo-climate",
|
||
"note": ("Климатические нормали. Текущая погода — отдельный API."),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("seasonal weather fetch failed: %s", e)
|
||
return None
|
||
|
||
|
||
def _fetch_weather_sync(lat: float, lon: float) -> dict | None:
|
||
"""Open-Meteo Forecast API — 7-day weather + climate context.
|
||
|
||
Free, no API key, JSON by coordinates. Покрывает РФ полностью.
|
||
"""
|
||
try:
|
||
with httpx.Client(timeout=5) as c:
|
||
r = c.get(
|
||
"https://api.open-meteo.com/v1/forecast",
|
||
params={
|
||
"latitude": lat,
|
||
"longitude": lon,
|
||
"daily": (
|
||
"temperature_2m_max,temperature_2m_min,"
|
||
"precipitation_sum,uv_index_max,"
|
||
"winddirection_10m_dominant,windspeed_10m_max"
|
||
),
|
||
"timezone": "Europe/Moscow",
|
||
"forecast_days": 7,
|
||
},
|
||
)
|
||
r.raise_for_status()
|
||
daily = r.json().get("daily", {})
|
||
if not daily.get("time"):
|
||
return None
|
||
|
||
t_max = daily.get("temperature_2m_max") or []
|
||
t_min = daily.get("temperature_2m_min") or []
|
||
precip = daily.get("precipitation_sum") or []
|
||
uv = daily.get("uv_index_max") or []
|
||
wind_d = daily.get("winddirection_10m_dominant") or []
|
||
wind_s = daily.get("windspeed_10m_max") or []
|
||
|
||
# Circular mean направления ветра (vector sum) — избегает jump 359→1
|
||
x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None)
|
||
y = sum(math.sin(math.radians(d)) for d in wind_d if d is not None)
|
||
dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0
|
||
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
|
||
wind_label = rose[round(dominant / 45) % 8]
|
||
|
||
return {
|
||
"forecast_days": len(daily.get("time", [])),
|
||
"temperature": {
|
||
"min_c": round(min(t_min), 1) if t_min else None,
|
||
"max_c": round(max(t_max), 1) if t_max else None,
|
||
"avg_max_c": round(sum(t_max) / len(t_max), 1) if t_max else None,
|
||
"avg_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
|
||
},
|
||
"precipitation_total_mm": round(sum(precip), 1) if precip else 0,
|
||
"precipitation_days": sum(1 for p in precip if p and p > 0.5),
|
||
"uv_index_max": round(max(uv), 1) if uv else None,
|
||
"wind": {
|
||
"dominant_direction_deg": round(dominant),
|
||
"dominant_direction_label": wind_label,
|
||
"max_speed_m_s": round(max(wind_s), 1) if wind_s else None,
|
||
},
|
||
"source": "open-meteo",
|
||
"note": (
|
||
"7-day forecast. Для исторических норм и B2B-данных — "
|
||
"Yandex Business / Gismeteo (платно)."
|
||
),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("weather fetch failed: %s", e)
|
||
return None
|
||
|
||
|
||
# Координаты центра ЕКБ — Площадь 1905 года
|
||
EKB_CENTER_LAT: float = 56.838011
|
||
EKB_CENTER_LON: float = 60.597474
|
||
|
||
# Эмпирические пороги score для ЕКБ: средний диапазон 15-30, max редко >40.
|
||
SCORE_THRESHOLDS: dict[str, float] = {"плохо": 5.0, "средне": 15.0, "хорошо": 25.0, "отлично": 40.0}
|
||
SCORE_MAX_REFERENCE: float = 40.0
|
||
|
||
|
||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||
"""Расстояние по формуле гаверсинуса между двумя точками (км)."""
|
||
earth_r = 6371.0
|
||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||
dphi = math.radians(lat2 - lat1)
|
||
dlam = math.radians(lon2 - lon1)
|
||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||
return 2 * earth_r * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||
|
||
|
||
def _score_label(s: float) -> str:
|
||
"""Текстовая интерпретация POI-score по эмпирическим порогам ЕКБ."""
|
||
if s < SCORE_THRESHOLDS["средне"]:
|
||
return "плохо" if s < SCORE_THRESHOLDS["плохо"] else "средне"
|
||
return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично"
|
||
|
||
|
||
def _confidence_label(c: float) -> str:
|
||
"""Текстовая интерпретация confidence (0..1).
|
||
|
||
Пороги:
|
||
high — c > 0.75 (плотные актуальные данные)
|
||
medium — 0.4-0.75
|
||
low — c < 0.4 (caveats обязательны)
|
||
"""
|
||
if c >= 0.75:
|
||
return "high"
|
||
if c >= 0.4:
|
||
return "medium"
|
||
return "low"
|
||
|
||
|
||
# Веса POI-категорий для scoring (Максим: трамвай = минус)
|
||
_POI_WEIGHTS: dict[str, float] = {
|
||
"school": 1.5,
|
||
"kindergarten": 1.5,
|
||
"pharmacy": 0.8,
|
||
"hospital": 0.6,
|
||
"shop_mall": 1.2,
|
||
"shop_supermarket": 1.0,
|
||
"shop_small": 0.5,
|
||
"park": 1.8,
|
||
"bus_stop": 0.3,
|
||
"metro_stop": 1.5,
|
||
"tram_stop": -0.5, # негативный вес — шум / вибрация
|
||
}
|
||
|
||
|
||
# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
|
||
# Добавляй регионы по мере расширения географии продукта.
|
||
GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
|
||
66: { # Свердловская обл.
|
||
"seismic_intensity_balls": 5,
|
||
"seismic_label": "минимальная сейсмика (≤5 баллов)",
|
||
"seismic_description": "Обычное строительство без специальных мер.",
|
||
"permafrost": False,
|
||
},
|
||
77: { # Москва
|
||
"seismic_intensity_balls": 4,
|
||
"seismic_label": "практически нет сейсмики",
|
||
"seismic_description": "Обычное строительство.",
|
||
"permafrost": False,
|
||
},
|
||
}
|
||
|
||
|
||
def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]:
|
||
"""Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.
|
||
|
||
industrial_within_500m — proxy для возможного загрязнения почв (без
|
||
реальных шейпов зон загрязнения). Точная геология/гидрогеология требует
|
||
инженерных изысканий (bore-holes).
|
||
"""
|
||
region_data: dict[str, Any] = GEOTECH_BY_REGION.get(
|
||
region_code,
|
||
{
|
||
"seismic_intensity_balls": None,
|
||
"seismic_label": "нет данных в встроенной таблице",
|
||
"seismic_description": "ОСР-2016: уточняйте по карте.",
|
||
"permafrost": False,
|
||
},
|
||
)
|
||
|
||
industrial_close: int = (
|
||
db.execute(
|
||
text("""
|
||
SELECT COUNT(*) AS n
|
||
FROM osm_noise_sources_ekb
|
||
WHERE source_type = 'industrial'
|
||
AND ST_DWithin(
|
||
geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
500
|
||
)
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
).scalar()
|
||
or 0
|
||
)
|
||
|
||
return {
|
||
**region_data,
|
||
"industrial_within_500m": int(industrial_close),
|
||
"industrial_contamination_flag": int(industrial_close) > 0,
|
||
"note": (
|
||
"Сейсмика — ОСР-2016 карта B (среднее повторяемое за 500 лет). "
|
||
"Для строительства обычно достаточно ≤7 баллов без спецмер. "
|
||
"Industrial proximity — proxy для возможного загрязнения почв. "
|
||
"Точная геология/гидрогеология — требует bore-holes (изыскания)."
|
||
),
|
||
}
|
||
|
||
|
||
def _compute_confidence(
|
||
*,
|
||
source: str,
|
||
poi_rows: list[dict[str, Any]],
|
||
district_row: dict[str, Any] | None,
|
||
competitor_rows: list[dict[str, Any]],
|
||
noise_sources_count: int,
|
||
air_q: dict[str, Any] | None,
|
||
weather: dict[str, Any] | None,
|
||
market_trend: dict[str, Any] | None,
|
||
zoning: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
"""X2 (#48) — composite confidence score 0..1 + caveats.
|
||
|
||
Stub-версия (до реализации G1/G2/D1/D2): использует сигналы которые уже
|
||
доступны на main. Композитный балл = avg of subscore'ов; caveats — list
|
||
конкретных проблем для UI ("Нет данных N, score K ненадёжен").
|
||
"""
|
||
import datetime as _dt
|
||
|
||
caveats: list[str] = []
|
||
subscores: dict[str, float] = {}
|
||
|
||
# 1) POI freshness — % POI с last_osm_edit_date в последние 2 года.
|
||
# Для участков с малым числом POI (<5) — снижаем confidence как coverage.
|
||
poi_total = len(poi_rows)
|
||
if poi_total == 0:
|
||
subscores["poi_freshness"] = 0.0
|
||
caveats.append("OSM POI не найдены в радиусе 1км — скоринг неприменим")
|
||
else:
|
||
cutoff = _dt.date.today() - _dt.timedelta(days=730)
|
||
fresh = sum(
|
||
1 for p in poi_rows if p.get("last_osm_edit_date") and p["last_osm_edit_date"] >= cutoff
|
||
)
|
||
ratio = fresh / poi_total
|
||
# coverage penalty: <5 POI слабая статистика
|
||
coverage_factor = min(1.0, poi_total / 10.0)
|
||
subscores["poi_freshness"] = round(ratio * coverage_factor, 2)
|
||
if poi_total < 5:
|
||
caveats.append(f"Мало OSM POI в радиусе 1км ({poi_total}) — социалка-фактор ненадёжен")
|
||
elif ratio < 0.5:
|
||
caveats.append("Большая часть POI (>50%) старше 2 лет — данные OSM требуют обновления")
|
||
|
||
# 2) Geometry source confidence — участок > квартал
|
||
subscores["geom_source"] = 0.9 if source == "cad_building" else 0.6
|
||
if source == "cad_quarter":
|
||
caveats.append(
|
||
"Геометрия quartal-level (нет parcel shape) — окружение усреднено по кварталу"
|
||
)
|
||
|
||
# 3) District context — известен ли район
|
||
subscores["district"] = 1.0 if district_row else 0.3
|
||
if not district_row:
|
||
caveats.append("Район не определён (вне границ ЕКБ?) — медианные цены недоступны")
|
||
|
||
# 4) Market trend — есть ли rosreestr_deals
|
||
if market_trend and market_trend.get("recent_deals_count"):
|
||
n_recent = int(market_trend["recent_deals_count"])
|
||
# порог 5 сделок за 6 мес — достаточно для тренда
|
||
subscores["market_trend"] = min(1.0, n_recent / 10.0)
|
||
if n_recent < 5:
|
||
caveats.append(f"Мало ДДУ за 6 мес ({n_recent}) — тренд рынка статистически слабый")
|
||
else:
|
||
subscores["market_trend"] = 0.0
|
||
caveats.append("Нет ДДУ в 3км — тренд рынка недоступен")
|
||
|
||
# 5) Competitors coverage
|
||
n_competitors = len(competitor_rows)
|
||
subscores["competitors"] = min(1.0, n_competitors / 5.0)
|
||
if n_competitors == 0:
|
||
caveats.append("Нет конкурентов-ЖК в 3км — низкая урбанизация / окраина")
|
||
|
||
# 6) Environmental data freshness
|
||
env_ok = sum([bool(noise_sources_count > 0), bool(air_q), bool(weather)])
|
||
subscores["environment"] = env_ok / 3.0
|
||
if noise_sources_count == 0:
|
||
caveats.append("Шумовая карта не загружена — noise score = stub")
|
||
if not air_q:
|
||
caveats.append("Air Quality API недоступен — exposure unknown")
|
||
|
||
# 7) ПЗЗ coverage — placeholder до G1
|
||
has_zoning = bool(zoning.get("data_available")) if zoning else False
|
||
subscores["zoning"] = 1.0 if has_zoning else 0.2
|
||
if not has_zoning:
|
||
caveats.append(
|
||
"ПЗЗ zone_code не известен — нельзя оценить разрешённое использование (G1 pending)"
|
||
)
|
||
|
||
composite = sum(subscores.values()) / len(subscores)
|
||
composite = round(max(0.0, min(1.0, composite)), 2)
|
||
|
||
return {
|
||
"value": composite,
|
||
"label": _confidence_label(composite),
|
||
"breakdown": subscores,
|
||
"caveats": caveats,
|
||
}
|
||
|
||
|
||
@router.post("/search", response_model=ParcelSearchResponse)
|
||
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
|
||
"""Search parcels by filters + scoring.
|
||
|
||
TODO Stage 2b: PostGIS query + scorer service.
|
||
"""
|
||
return ParcelSearchResponse(items=[], total=0)
|
||
|
||
|
||
@router.get("/{parcel_id}", response_model=ParcelDetail)
|
||
async def get_parcel(parcel_id: str) -> ParcelDetail:
|
||
"""TODO Stage 2b: fetch parcel by id from DB."""
|
||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||
|
||
|
||
@router.post("/{cad_num}/analyze")
|
||
def analyze_parcel(
|
||
cad_num: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> dict[str, Any]:
|
||
"""Анализ участка: близость к социалке + district context + конкуренты.
|
||
|
||
Порядок поиска геометрии: cad_quarters_geom → cad_buildings.
|
||
"""
|
||
# 1) Получить геометрию участка — GeoJSON строка через ST_AsGeoJSON
|
||
row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT ST_AsGeoJSON(g.geom) AS geom_geojson,
|
||
g.geom AS geom_wkb,
|
||
'cad_quarter' AS source
|
||
FROM cad_quarters_geom g
|
||
WHERE g.cad_number = :c
|
||
UNION ALL
|
||
SELECT ST_AsGeoJSON(b.geom) AS geom_geojson,
|
||
b.geom AS geom_wkb,
|
||
'cad_building' AS source
|
||
FROM cad_buildings b
|
||
WHERE b.cad_num = :c
|
||
UNION ALL
|
||
SELECT ST_AsGeoJSON(p.geom) AS geom_geojson,
|
||
p.geom AS geom_wkb,
|
||
'cad_parcel' AS source
|
||
FROM cad_parcels_geom p
|
||
WHERE p.cad_num = :c
|
||
LIMIT 1
|
||
"""),
|
||
{"c": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
|
||
if not row:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"Геометрия для {cad_num} не найдена. Загрузи через NSPD geo.",
|
||
)
|
||
|
||
geom_geojson: str = row["geom_geojson"]
|
||
source: str = row["source"]
|
||
|
||
# Используем ST_AsText для передачи геометрии в последующие запросы.
|
||
# Все PostGIS-запросы принимают текстовый WKT через ST_GeomFromText.
|
||
geom_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT ST_AsText(g.geom) AS wkt
|
||
FROM (
|
||
SELECT g.geom FROM cad_quarters_geom g WHERE g.cad_number = :c
|
||
UNION ALL
|
||
SELECT b.geom FROM cad_buildings b WHERE b.cad_num = :c
|
||
UNION ALL
|
||
SELECT p.geom FROM cad_parcels_geom p WHERE p.cad_num = :c
|
||
) g
|
||
LIMIT 1
|
||
"""),
|
||
{"c": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
|
||
geom_wkt: str = geom_row["wkt"] # type: ignore[index]
|
||
|
||
# 2) District context — ближайший район ЕКБ
|
||
district_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT district_name,
|
||
median_price_per_m2,
|
||
ST_Distance(
|
||
d.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS dist_to_center
|
||
FROM ekb_districts d
|
||
WHERE ST_DWithin(
|
||
d.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
5000
|
||
)
|
||
ORDER BY dist_to_center ASC
|
||
LIMIT 1
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
|
||
# 3) POI в радиусе 1 км — список с distance_m
|
||
poi_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT category,
|
||
name,
|
||
lat,
|
||
lon,
|
||
ST_Distance(
|
||
p.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m,
|
||
last_osm_edit_date
|
||
FROM osm_poi_ekb p
|
||
WHERE ST_DWithin(
|
||
p.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
1000
|
||
)
|
||
ORDER BY distance_m ASC
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
# 4) Scoring: weighted sum с distance decay
|
||
score = 0.0
|
||
by_category: dict[str, list[dict[str, Any]]] = {}
|
||
for p in poi_rows:
|
||
cat: str = p["category"]
|
||
w = _POI_WEIGHTS.get(cat, 0.0)
|
||
# distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м
|
||
decay = max(0.0, 1.0 - float(p["distance_m"]) / 1000.0)
|
||
score += w * decay
|
||
by_category.setdefault(cat, []).append(
|
||
{
|
||
"name": p["name"],
|
||
"distance_m": round(float(p["distance_m"])),
|
||
"lat": float(p["lat"]) if p["lat"] is not None else None,
|
||
"lon": float(p["lon"]) if p["lon"] is not None else None,
|
||
"last_edit": (
|
||
p["last_osm_edit_date"].isoformat() if p["last_osm_edit_date"] else None
|
||
),
|
||
}
|
||
)
|
||
|
||
# 5) Конкуренты в радиусе 3 км из DOM.РФ.
|
||
# NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по
|
||
# latest snapshot, иначе дубликаты ЖК в выдаче.
|
||
competitor_rows = (
|
||
db.execute(
|
||
text("""
|
||
WITH latest_obj AS (
|
||
SELECT DISTINCT ON (obj_id) *
|
||
FROM domrf_kn_objects
|
||
WHERE latitude IS NOT NULL
|
||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||
)
|
||
SELECT obj_id,
|
||
comm_name,
|
||
dev_name,
|
||
obj_class,
|
||
flat_count,
|
||
district_name,
|
||
ST_Distance(
|
||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM latest_obj o
|
||
WHERE ST_DWithin(
|
||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
3000
|
||
)
|
||
ORDER BY o.flat_count DESC NULLS LAST
|
||
LIMIT 20
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
# 6) Centroid координаты для внешних API (air quality / wind)
|
||
centroid_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT ST_X(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lon,
|
||
ST_Y(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lat
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
centroid_lat: float = float(centroid_row["lat"]) if centroid_row else 56.838
|
||
centroid_lon: float = float(centroid_row["lon"]) if centroid_row else 60.605
|
||
|
||
# 6b) Distance to EKB center + center bonus
|
||
dist_to_center_km = _haversine_km(centroid_lat, centroid_lon, EKB_CENTER_LAT, EKB_CENTER_LON)
|
||
if dist_to_center_km < 5:
|
||
center_bonus = 3.0
|
||
elif dist_to_center_km < 10:
|
||
center_bonus = 1.5
|
||
elif dist_to_center_km < 15:
|
||
center_bonus = 0.5
|
||
else:
|
||
center_bonus = 0.0
|
||
|
||
# 7) Noise score — шумовые источники в радиусе 2 км
|
||
noise_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT source_type, road_class, name,
|
||
ST_Distance(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM osm_noise_sources_ekb n
|
||
WHERE ST_DWithin(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
2000
|
||
)
|
||
ORDER BY distance_m ASC
|
||
LIMIT 30
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
noise_db_max = 0.0
|
||
nearby_noise_sources: list[dict[str, Any]] = []
|
||
for nr in noise_rows:
|
||
src = nr["source_type"]
|
||
key = f"{src}:{nr['road_class']}" if src == "highway" and nr["road_class"] else src
|
||
base_db = NOISE_L_BASE.get(key, 50.0)
|
||
d = max(float(nr["distance_m"]), 10.0)
|
||
noise_db = base_db - 20.0 * math.log10(d / 10.0)
|
||
noise_db_max = max(noise_db_max, noise_db)
|
||
if noise_db >= 50.0: # WHO порог дискомфорта
|
||
nearby_noise_sources.append(
|
||
{
|
||
"source_type": src,
|
||
"road_class": nr["road_class"],
|
||
"name": nr["name"],
|
||
"distance_m": round(d),
|
||
"estimated_db": round(noise_db, 1),
|
||
}
|
||
)
|
||
|
||
# noise_score: 0..1, чем тише тем лучше. 45 dB=1.0 (тихо), 75 dB=0.0 (шумно).
|
||
noise_score = max(0.0, min(1.0, (75.0 - noise_db_max) / 30.0))
|
||
|
||
if noise_db_max < 50.0:
|
||
noise_level = "тихо"
|
||
elif noise_db_max < 65.0:
|
||
noise_level = "умеренный"
|
||
else:
|
||
noise_level = "шумно"
|
||
|
||
# 8) Air quality — Open-Meteo (best-effort, null при недоступности)
|
||
air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon)
|
||
|
||
# 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
|
||
weather = _fetch_weather_sync(centroid_lat, centroid_lon)
|
||
|
||
# 9b) Seasonal weather — 30-летние нормали Climate API
|
||
seasonal_weather = _fetch_seasonal_weather_sync(centroid_lat, centroid_lon)
|
||
|
||
# 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
|
||
hydrology: dict[str, Any] | None = None
|
||
try:
|
||
hydro_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT source_type, road_class, name,
|
||
ST_Distance(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM osm_noise_sources_ekb n
|
||
WHERE source_type = 'water'
|
||
AND ST_DWithin(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
2000
|
||
)
|
||
ORDER BY distance_m ASC
|
||
LIMIT 10
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
hydrology = {
|
||
"nearest": [
|
||
{
|
||
"subtype": r["road_class"],
|
||
"name": r["name"],
|
||
"distance_m": round(float(r["distance_m"])),
|
||
}
|
||
for r in hydro_rows[:5]
|
||
],
|
||
"flood_risk_flag": any(
|
||
float(r["distance_m"]) < 200 and r["road_class"] in ("river", "canal")
|
||
for r in hydro_rows
|
||
),
|
||
"note": (
|
||
"Пойма реки (<200м) — повышенный риск подтопления. Точные данные о "
|
||
"зонах затопления — в Росреестре (ЗОУИТ типа 33: 'Зона затопления, "
|
||
"подтопления') через ФГИС ТП."
|
||
),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("hydrology query failed for %s: %s", cad_num, e)
|
||
hydrology = None
|
||
|
||
# 9d) Utilities — power lines + pipelines из OSM (магистральные сети)
|
||
utilities: dict[str, Any] | None = None
|
||
try:
|
||
util_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT road_class, name,
|
||
ST_Distance(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM osm_noise_sources_ekb n
|
||
WHERE source_type = 'utility'
|
||
AND ST_DWithin(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
2000
|
||
)
|
||
ORDER BY distance_m ASC
|
||
LIMIT 10
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
# Группировка по типу для compactness
|
||
by_subtype: dict[str, dict[str, Any]] = {}
|
||
for r in util_rows:
|
||
sub = r["road_class"] or "other"
|
||
if sub not in by_subtype:
|
||
by_subtype[sub] = {
|
||
"subtype": sub,
|
||
"nearest_m": round(float(r["distance_m"])),
|
||
"name": r["name"],
|
||
"count_within_2km": 0,
|
||
}
|
||
by_subtype[sub]["count_within_2km"] += 1
|
||
utilities = {
|
||
"summary": list(by_subtype.values()),
|
||
"power_line_охранная_зона_flag": any(
|
||
float(r["distance_m"]) < 25 and r["road_class"] == "power_line" for r in util_rows
|
||
),
|
||
"note": (
|
||
"Охранная зона ЛЭП ≥35 кВ — 15-40м по обе стороны (СП 36.13330). "
|
||
"В зоне ОЗ нельзя строить капитальные объекты. "
|
||
"Точная классификация напряжения / магистральности — ЗОУИТ "
|
||
"5 (ОЗ ЛЭП) и 9 (ОЗ газопровода) через ФГИС ТП."
|
||
),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("utilities query failed for %s: %s", cad_num, e)
|
||
utilities = None
|
||
|
||
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
|
||
market_trend: dict[str, Any] | None = None
|
||
try:
|
||
trend_row = (
|
||
db.execute(
|
||
text("""
|
||
WITH district_deals AS (
|
||
SELECT d.period_start_date AS deal_date,
|
||
d.price_per_sqm AS price_per_m2
|
||
FROM rosreestr_deals d
|
||
WHERE d.region_code = 66
|
||
AND d.doc_type = 'ДДУ'
|
||
AND d.realestate_type_code = '002001003000'
|
||
AND d.price_per_sqm BETWEEN 30000 AND 500000
|
||
AND d.period_start_date > NOW() - INTERVAL '12 months'
|
||
AND ST_DWithin(
|
||
(SELECT ST_Centroid(geom)
|
||
FROM cad_quarters_geom
|
||
WHERE cad_number = d.quarter_cad_number)::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
3000
|
||
)
|
||
)
|
||
SELECT
|
||
AVG(price_per_m2)
|
||
FILTER (WHERE deal_date > NOW() - INTERVAL '6 months')
|
||
AS recent_avg,
|
||
AVG(price_per_m2)
|
||
FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months'
|
||
AND NOW() - INTERVAL '6 months')
|
||
AS prior_avg,
|
||
COUNT(*)
|
||
FILTER (WHERE deal_date > NOW() - INTERVAL '6 months')
|
||
AS recent_n,
|
||
COUNT(*)
|
||
FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months'
|
||
AND NOW() - INTERVAL '6 months')
|
||
AS prior_n
|
||
FROM district_deals
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]:
|
||
recent_p = float(trend_row["recent_avg"])
|
||
prior_p = float(trend_row["prior_avg"])
|
||
# 6-месячное изменение; ×2 даёт годовой эквивалент
|
||
delta_6m_pct = round((recent_p - prior_p) / prior_p * 100, 1)
|
||
if delta_6m_pct > 8:
|
||
perspective_label = "Сильный рост — рынок растёт быстрее инфляции"
|
||
elif delta_6m_pct > 0:
|
||
perspective_label = "Умеренный рост — стабильный спрос"
|
||
elif delta_6m_pct > -5:
|
||
perspective_label = "Стагнация — рынок остыл"
|
||
else:
|
||
perspective_label = "Падение — риск переоценки"
|
||
market_trend = {
|
||
"recent_avg_price_per_m2": round(recent_p),
|
||
"prior_avg_price_per_m2": round(prior_p),
|
||
"delta_6m_pct": delta_6m_pct,
|
||
"recent_deals_count": int(trend_row["recent_n"]),
|
||
"prior_deals_count": int(trend_row["prior_n"]),
|
||
"label": perspective_label,
|
||
"radius_km": 3,
|
||
}
|
||
except Exception as e:
|
||
logger.warning("market_trend query failed for %s: %s", cad_num, e)
|
||
market_trend = None
|
||
|
||
# 10b) Zoning — территориальная зона ПЗЗ.
|
||
# NB: Росреестр PKK6 API закрыт в 2024 → редирект на NSPD (anti-bot WAF).
|
||
# Open-data shapefile ПЗЗ ЕКБ публично не выкладывается (data.midural.ru
|
||
# содержит только metadata). Реальный per-parcel zoning требует либо
|
||
# ручного импорта shapefile из ГИС ЕКБ (с авторизацией) либо платный
|
||
# API (egrn.reestr.net). Здесь — fallback на pzz_zones_ekb (если есть)
|
||
# + deep-links на публичные геопорталы для drill-down.
|
||
zoning: dict[str, Any] = {
|
||
"zone_code": None,
|
||
"zone_name": None,
|
||
"description": None,
|
||
"data_available": False,
|
||
"note": (
|
||
"Автоматический per-parcel zoning недоступен: Росреестр PKK6 API "
|
||
"закрыт (2024), NSPD блокирует bot-доступ. Используй внешние "
|
||
"геопорталы для определения зоны вручную."
|
||
),
|
||
"links": {
|
||
"nspd_portal": f"https://nspd.gov.ru/map?lat={centroid_lat}&lng={centroid_lon}&z=17",
|
||
"ekb_geoportal": "https://xn--80acgfbsl1azdqr.xn--p1ai/",
|
||
"midural_data": "https://data.midural.ru/",
|
||
},
|
||
"lat": centroid_lat,
|
||
"lon": centroid_lon,
|
||
}
|
||
try:
|
||
zoning_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT zone_code, zone_name, description, rosreestr_id
|
||
FROM pzz_zones_ekb
|
||
WHERE ST_Within(
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326)),
|
||
geom
|
||
)
|
||
LIMIT 1
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if zoning_row:
|
||
zoning.update(
|
||
{
|
||
"zone_code": zoning_row["zone_code"],
|
||
"zone_name": zoning_row["zone_name"],
|
||
"description": zoning_row["description"],
|
||
"rosreestr_id": zoning_row["rosreestr_id"],
|
||
"data_available": True,
|
||
"source": "rosreestr-pkk6-cached",
|
||
}
|
||
)
|
||
except Exception as e:
|
||
logger.warning("zoning query failed for %s: %s", cad_num, e)
|
||
|
||
# 10c) Success recommendation — топ квартирография по district из v_bucket_success_score
|
||
success_recommendation: dict[str, Any] | None = None
|
||
if district_row:
|
||
try:
|
||
success_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT bucket, success_score, n_deals, avg_price_per_m2, avg_area_m2,
|
||
velocity_z, price_z, area_z
|
||
FROM v_bucket_success_score
|
||
WHERE district_name = :dn
|
||
ORDER BY success_score DESC
|
||
LIMIT 5
|
||
"""),
|
||
{"dn": district_row["district_name"]},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
if success_rows:
|
||
success_recommendation = {
|
||
"district": district_row["district_name"],
|
||
"ranking": [
|
||
{
|
||
"bucket": r["bucket"],
|
||
"success_score": round(float(r["success_score"]), 2),
|
||
"n_deals": int(r["n_deals"]),
|
||
"avg_price_per_m2": (
|
||
int(r["avg_price_per_m2"]) if r["avg_price_per_m2"] else None
|
||
),
|
||
"avg_area_m2": (
|
||
round(float(r["avg_area_m2"]), 1) if r["avg_area_m2"] else None
|
||
),
|
||
}
|
||
for r in success_rows
|
||
],
|
||
"top_bucket": dict(success_rows[0]) if success_rows else None,
|
||
"note": (
|
||
"Топ комнатность по 'успешности' = z-scores: velocity×0.5 + price×0.3 "
|
||
"- area×0.2. Min 30 сделок в группе за 24 мес. "
|
||
"Используй для квартирографии проекта."
|
||
),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("success_recommendation query failed for %s: %s", cad_num, e)
|
||
success_recommendation = None
|
||
|
||
# 10d) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS
|
||
karpinsky_url = (
|
||
f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
|
||
f"?lat={centroid_lat:.6f}&lon={centroid_lon:.6f}&zoom=12"
|
||
)
|
||
efgi_url = "https://efgi.ru/"
|
||
geology: dict[str, Any] = {
|
||
"data_available": False,
|
||
"note": (
|
||
"Подробная геология (типы пород, грунты, мощность ОС) "
|
||
"доступна только через ВСЕГЕИ-200/1000 шейпы — требуется "
|
||
"ручной импорт в PostGIS (multi-day задача). Для drill-down "
|
||
"используй внешние ссылки ниже."
|
||
),
|
||
"links": {
|
||
"karpinsky_webgis": karpinsky_url,
|
||
"efgi_federal_registry": efgi_url,
|
||
},
|
||
"lat": centroid_lat,
|
||
"lon": centroid_lon,
|
||
}
|
||
|
||
score_final = score + center_bonus
|
||
|
||
# X2 (#48): composite confidence + caveats
|
||
confidence_info = _compute_confidence(
|
||
source=source,
|
||
poi_rows=[dict(p) for p in poi_rows],
|
||
district_row=dict(district_row) if district_row else None,
|
||
competitor_rows=[dict(c) for c in competitor_rows],
|
||
noise_sources_count=len(noise_rows),
|
||
air_q=air_q,
|
||
weather=weather,
|
||
market_trend=market_trend,
|
||
zoning=zoning,
|
||
)
|
||
|
||
return {
|
||
"cad_num": cad_num,
|
||
"source": source,
|
||
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
|
||
"district": dict(district_row) if district_row else None,
|
||
"score": round(score_final, 2),
|
||
"score_without_center": round(score, 2),
|
||
"score_label": _score_label(score_final),
|
||
"score_max_reference": SCORE_MAX_REFERENCE,
|
||
"score_explanation": (
|
||
"Сумма close-distance POI (школы/сады/парки +, трамваи -) + center_bonus. "
|
||
">40 = редко, типичный город. центр 15-30."
|
||
),
|
||
"score_breakdown": by_category,
|
||
"poi_count": len(poi_rows),
|
||
"location": {
|
||
"distance_to_center_km": round(dist_to_center_km, 2),
|
||
"center_bonus": center_bonus,
|
||
"ekb_center": {"lat": EKB_CENTER_LAT, "lon": EKB_CENTER_LON},
|
||
"note": "Бонус к score: <5км +3.0, 5-10км +1.5, 10-15км +0.5, >15км 0",
|
||
},
|
||
"competitors": [dict(c) for c in competitor_rows],
|
||
"noise": {
|
||
"score": round(noise_score, 2),
|
||
"estimated_db": round(noise_db_max, 1),
|
||
"level": noise_level,
|
||
"sources": nearby_noise_sources[:10],
|
||
},
|
||
"air_quality": air_q,
|
||
"weather": weather,
|
||
"seasonal_weather": seasonal_weather,
|
||
"wind": (weather or {}).get("wind") if weather else None, # backward compat
|
||
"geology": geology,
|
||
"hydrology": hydrology,
|
||
"utilities": utilities,
|
||
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
||
"market_trend": market_trend,
|
||
"zoning": zoning,
|
||
"success_recommendation": success_recommendation,
|
||
"isochrones_available": bool(settings.openrouteservice_api_key),
|
||
# X2 (#48) — confidence indicator
|
||
"confidence": confidence_info["value"],
|
||
"confidence_label": confidence_info["label"],
|
||
"confidence_breakdown": confidence_info["breakdown"],
|
||
"confidence_caveats": confidence_info["caveats"],
|
||
}
|
||
|
||
|
||
_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.",
|
||
}
|