import datetime as _dt import json import logging import math import time from typing import Annotated, Any import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Response from shapely import wkt as _shp_wkt from shapely.geometry import Polygon 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 from app.services.site_finder.cadastre_fetch import ( cad_exists_in_db, find_or_enqueue_fetch, ) from app.services.site_finder.cadastre_fetch import ( fetch_status as _fetch_status, ) from app.services.site_finder.quarter_dump_lookup import ( get_quarter_dump_data, make_empty_result, ) 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, # негативный вес — шум / вибрация } # Человеко-читаемые имена категорий для verbal breakdown (X1). _POI_CATEGORY_RU: dict[str, str] = { "school": "Школа", "kindergarten": "Детсад", "pharmacy": "Аптека", "hospital": "Больница", "shop_mall": "ТЦ", "shop_supermarket": "Супермаркет", "shop_small": "Магазин", "park": "Парк", "bus_stop": "Автобус", "metro_stop": "Метро", "tram_stop": "Трамвай", } # Группировка POI по тематическим эшелонам — для stacked-bar % contribution # (X1 score breakdown). Расширяй по мере добавления новых категорий. _POI_GROUP: dict[str, str] = { "school": "Социалка", "kindergarten": "Социалка", "pharmacy": "Социалка", "hospital": "Социалка", "shop_mall": "Торговля", "shop_supermarket": "Торговля", "shop_small": "Торговля", "park": "Парки", "bus_stop": "Транспорт", "metro_stop": "Транспорт", "tram_stop": "Шум/трамвай", } def _verbal_for_poi( cat: str, name: str | None, distance_m: float, contribution: float, ) -> str: """Сгенерировать verbal explain для одного POI-вклада. Пример: "Школа №125 в 400м — +0.90 баллов". Для отрицательного вклада (трамваи): "Трамвай Ленина в 80м — −0.46 баллов". """ label = _POI_CATEGORY_RU.get(cat, cat) safe_name = (name or "").strip() name_part = f" «{safe_name}»" if safe_name and safe_name != "—" else "" sign = "+" if contribution >= 0 else "−" return f"{label}{name_part} в {round(distance_m)}м — {sign}{abs(contribution):.2f} баллов" # Сейсмика по ОСР-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, }, } # D4 (#36) — pipeline 24mo constants. Размещены в одном месте для тюнинга # и аудита; severity пороги матчатся с acceptance #36. PIPELINE_RADIUS_M = 5000 PIPELINE_HORIZON_MONTHS = 24 PIPELINE_SEVERITY_MEDIUM_THRESHOLD = 500 # flats_total < это → low PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high PIPELINE_TOP_OBJECTS_LIMIT = 10 def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]: """D4 (#36) — собрать pipeline_24mo aggregate из rows domrf_kn_objects. Метрики: - objects_count, flats_total - by_class: {economy: int, comfort: int, business: int, unknown: int} - by_quarter: {"2026-Q1": {objects: N, flats: M}, ...} - severity: low / medium / high (см. PIPELINE_SEVERITY_* пороги) - top_objects: PIPELINE_TOP_OBJECTS_LIMIT крупнейших ЖК по flat_count NB: `obj_class` в production часто NULL (см. `fixes/Bug_Kn_API_Obj_Class_Always_Null_OPEN`) — by_class dominated by "unknown" пока не закрыт D6/#38. Используется для UI pipeline-bar и severity badge. """ if not rows: return { "objects_count": 0, "flats_total": 0, "by_class": {}, "by_quarter": [], "severity": "none", "top_objects": [], "note": ( f"Нет ЖК в pipeline {PIPELINE_HORIZON_MONTHS}мес в радиусе " f"{PIPELINE_RADIUS_M // 1000}км — низкая будущая конкуренция" ), } by_class: dict[str, int] = {} by_quarter: dict[str, dict[str, int]] = {} flats_total = 0 for r in rows: cls = (r["obj_class"] or "unknown").lower().strip() or "unknown" flats = int(r["flat_count"]) if r["flat_count"] else 0 flats_total += flats by_class[cls] = by_class.get(cls, 0) + flats ready = r["ready_dt"] if ready: q = (ready.month - 1) // 3 + 1 key = f"{ready.year}-Q{q}" slot = by_quarter.setdefault(key, {"objects": 0, "flats": 0}) slot["objects"] += 1 slot["flats"] += flats # Severity (#36 acceptance) if flats_total < PIPELINE_SEVERITY_MEDIUM_THRESHOLD: severity = "low" elif flats_total < PIPELINE_SEVERITY_HIGH_THRESHOLD: severity = "medium" else: severity = "high" severity_label = { "low": "низкая", "medium": "средняя", "high": "высокая", }[severity] # Sort quarters chronologically quarters_sorted = [{"quarter": k, **v} for k, v in sorted(by_quarter.items())] # Top objects — по flat_count desc. # Explicit field selection вместо `dict(r)` — иначе CTE `SELECT *` протекает # внутренние колонки (latitude/longitude/snapshot_date/region_cd/dev_id и т.д.) # в API response. Не security issue, но schema-leak. top_rows = sorted(rows, key=lambda r: r.get("flat_count") or 0, reverse=True)[ :PIPELINE_TOP_OBJECTS_LIMIT ] top_objects: list[dict[str, Any]] = [] for r in top_rows: ready_dt = r.get("ready_dt") distance_m = r.get("distance_m") top_objects.append( { "obj_id": r["obj_id"], "comm_name": r.get("comm_name"), "dev_name": r.get("dev_name"), "obj_class": r.get("obj_class"), "flat_count": r.get("flat_count"), # ISO date string для JSON; distance_m — explicit None guard # (centroid-on-building даёт 0.0 — falsy float; raw Decimal иначе # упадёт в JSON serialization). "ready_dt": ready_dt.isoformat() if ready_dt else None, "distance_m": round(float(distance_m)) if distance_m is not None else None, } ) return { "objects_count": len(rows), "flats_total": flats_total, "by_class": by_class, "by_quarter": quarters_sorted, "severity": severity, "severity_label": severity_label, "top_objects": top_objects, "radius_km": PIPELINE_RADIUS_M // 1000, "horizon_months": PIPELINE_HORIZON_MONTHS, "note": ( "Будущая конкуренция за покупателя: planned_commissioning от Росреестра " "часто оптимистичен (сдвиги по факту). Pressure-балл — относительный." ), } # P1 (#45) — constants for polygon suitability (строительные нормы Свердл/общие # для ЖК; будут править — храним в одном месте) _GEOM_MIN_AREA_HA = 0.2 # ниже → area_subscore = 0 (физический минимум) _GEOM_AREA_COMFORT_HA = 0.3 # рекомендуемая комфортная площадь МКД (recommendation) _GEOM_AREA_SCORE_FULL_HA = 0.5 # ≥ → area_subscore = 1.0 (premium) _GEOM_ASPECT_PENALTY_THRESHOLD = 5.0 # выше → вытянутый _GEOM_ASPECT_PENALTY = 0.3 _GEOM_CONVEX_PENALTY_THRESHOLD = 0.65 # ниже → изрезанный _GEOM_CONVEX_PENALTY = 0.3 # Строительный минимум — physical possibility (под penalty) _GEOM_MIN_WIDTH_PHYSICAL_M = 30 _GEOM_NARROW_PENALTY = 0.5 # Комфорт МКД — recommendation level (помещается типовой корпус 12-16 эт) _GEOM_MIN_WIDTH_COMFORT_M = 40 _GEOM_LABEL_MICRO_HA = 0.05 # ниже → label "микро" (комбинируется с penalties) _GEOM_LABEL_GOOD = 0.7 _GEOM_LABEL_MEDIUM = 0.4 def _polygon_suitability(geom_wkt: str) -> dict[str, Any]: """P1 (#45) — physical suitability участка по метрикам shape. Метрики: - area_ha — площадь в гектарах (locally-projected metres via cos(lat)) - perimeter_m — периметр - aspect_ratio — длина / ширина минимального ограничивающего прямоугольника - convex_hull_ratio — площадь / площадь выпуклой оболочки (1.0 = выпуклый, <0.7 изрезанный) - min_inscribed_rect_dim_m — длина короткой стороны MABR Suitability score 0..1 — composite (пороги — см. _GEOM_* константы): - area_subscore: <_GEOM_MIN_AREA_HA → 0.0, ≥_GEOM_AREA_SCORE_FULL_HA → 1.0, linear - −_GEOM_ASPECT_PENALTY если aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD - −_GEOM_CONVEX_PENALTY если convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD - −_GEOM_NARROW_PENALTY если short_side < _GEOM_MIN_WIDTH_PHYSICAL_M UI label: микро / подходящий / сложная форма / слабо подходит. Label "микро" комбинируется с penalties — "микро · узкий" — чтобы пользователь увидел обе проблемы сразу. """ try: # Парсим WGS84 polygon (shapely imports теперь module-level) poly = _shp_wkt.loads(geom_wkt) if poly.is_empty or poly.geom_type not in ("Polygon", "MultiPolygon"): return {"data_available": False, "note": "Геометрия не Polygon/MultiPolygon"} # Берём наибольший компонент для MultiPolygon if poly.geom_type == "MultiPolygon": poly = max(poly.geoms, key=lambda g: g.area) assert isinstance(poly, Polygon) # Equirectangular-projection в метры через centroid-anchor. # На широте ~57° деформация <1% в радиусе 50км (parcel-scale OK). centroid = poly.centroid lat_rad = math.radians(centroid.y) m_per_deg_lon = 111_320.0 * math.cos(lat_rad) m_per_deg_lat = 110_540.0 ext = list(poly.exterior.coords) ext_m = [ ( (x - centroid.x) * m_per_deg_lon, (y - centroid.y) * m_per_deg_lat, ) for x, y in ext ] poly_m = Polygon(ext_m) area_m2 = poly_m.area area_ha = area_m2 / 10_000.0 perimeter_m = poly_m.length # Convex hull ratio hull = poly_m.convex_hull convex_hull_ratio = area_m2 / hull.area if hull.area > 0 else 1.0 # MABR (minimum area bounding rectangle) → aspect_ratio + short side try: mabr = poly_m.minimum_rotated_rectangle mabr_coords = list(mabr.exterior.coords) # 4 уникальные точки в MABR (closed ring → 5 points) → две стороны side_lens: list[float] = [] for i in range(4): p1 = mabr_coords[i] p2 = mabr_coords[i + 1] side_lens.append(math.hypot(p2[0] - p1[0], p2[1] - p1[1])) short_side = min(side_lens) long_side = max(side_lens) aspect_ratio = long_side / short_side if short_side > 0 else 1.0 except Exception as mabr_err: logger.debug("MABR computation failed, falling back to sqrt(area): %s", mabr_err) short_side = math.sqrt(area_m2) aspect_ratio = 1.0 # Suitability score composite if area_ha >= _GEOM_AREA_SCORE_FULL_HA: area_subscore = 1.0 elif area_ha <= _GEOM_MIN_AREA_HA: area_subscore = 0.0 else: # linear: _GEOM_MIN_AREA_HA → 0, _GEOM_AREA_SCORE_FULL_HA → 1.0 area_subscore = (area_ha - _GEOM_MIN_AREA_HA) / ( _GEOM_AREA_SCORE_FULL_HA - _GEOM_MIN_AREA_HA ) suitability = area_subscore penalties: list[str] = [] if aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD: suitability -= _GEOM_ASPECT_PENALTY penalties.append(f"вытянутый (aspect>{_GEOM_ASPECT_PENALTY_THRESHOLD:.0f})") if convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD: suitability -= _GEOM_CONVEX_PENALTY penalties.append(f"изрезанный (convex<{_GEOM_CONVEX_PENALTY_THRESHOLD})") if short_side < _GEOM_MIN_WIDTH_PHYSICAL_M: suitability -= _GEOM_NARROW_PENALTY penalties.append(f"узкий (короткая сторона {short_side:.0f}м)") suitability = max(0.0, min(1.0, suitability)) # Label — combine "микро" с penalties чтобы UI видел всё is_micro = area_ha < _GEOM_LABEL_MICRO_HA if suitability >= _GEOM_LABEL_GOOD and not is_micro: label = "подходящий" elif is_micro: # combine с penalties: "микро" + первая penalty (для краткости) if penalties: label = f"микро, {penalties[0].split(' (')[0]}" else: label = "микро" elif suitability >= _GEOM_LABEL_MEDIUM: label = "сложная форма" else: label = "слабо подходит" return { "data_available": True, "area_ha": round(area_ha, 3), "area_m2": round(area_m2), "perimeter_m": round(perimeter_m), "aspect_ratio": round(aspect_ratio, 2), "convex_hull_ratio": round(convex_hull_ratio, 2), "min_inscribed_rect_dim_m": round(short_side), "suitability_score": round(suitability, 2), "label": label, "penalties": penalties, "recommendation": ( f"Строительный минимум короткой стороны — {_GEOM_MIN_WIDTH_PHYSICAL_M}м, " f"комфорт типового МКД 12-16 этажей — от {_GEOM_MIN_WIDTH_COMFORT_M}м " f"и площадь от {_GEOM_AREA_COMFORT_HA} га." ), "note": ( "Оценка по форме участка (Shapely). Учитывает площадь, " "вытянутость, изрезанность, минимальную ширину MABR." ), } except Exception as e: logger.warning("polygon suitability failed: %s", e) return { "data_available": False, "note": f"Не удалось проанализировать геометрию: {e}", } # P2 (#46) cost-per-m² sanity filter — кадастровая стоимость иногда # содержит 0/None или экстремальные значения (миллиарды). Пороги выбраны # эмпирически для ЕКБ. _COST_PER_M2_MIN = 1000 # ₽/м² — ниже скорее всего ошибка ввода _COST_PER_M2_MAX = 500_000 # ₽/м² — выше скорее всего outlier def _parse_floors(raw: str | None) -> int | None: """cad_buildings.floors хранится TEXT (могут быть диапазоны '1-2', '5-7'). Возвращаем верхнюю границу (более консервативный сосед-высотка). NB: `isdigit()` намеренно фильтрует malformed parts типа "5а-7"; для multi-range "1-2-3" возвращается max(1,2,3)=3 (acceptable degradation). """ if not raw: return None raw = raw.strip() # range like "5-7" → 7 if "-" in raw: parts = raw.split("-") try: return max(int(p.strip()) for p in parts if p.strip().isdigit()) except ValueError: return None # single int try: return int(raw) except ValueError: return None def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str, Any]: """P2 (#46) — cad_buildings соседи в 100м + overlap check. Возвращает aggregate (avg/max floors, median cost/m², count) + плоский список соседей для UI + флаг has_existing_buildings (overlap >50 м²). Использует GIST на cad_buildings.geom (уже создан в schema). """ try: neighbor_rows = ( db.execute( text(""" SELECT cad_num, building_name, floors, year_built, cost_value, area, readable_address, ST_Distance( b.geom::geography, ST_GeomFromText(:wkt, 4326)::geography ) AS distance_m FROM cad_buildings b WHERE ST_DWithin( b.geom::geography, ST_GeomFromText(:wkt, 4326)::geography, 100 ) AND b.cad_num != :our_cad ORDER BY distance_m ASC LIMIT 30 """), {"wkt": geom_wkt, "our_cad": our_cad_num}, ) .mappings() .all() ) except Exception as e: logger.warning("neighbors query failed: %s", e) return {"data_available": False, "note": f"neighbors query failed: {e}"} # Aggregate floors + cost. Дефенсивный try/except: если cost_value/area # придёт как non-numeric (e.g. "N/A"), float() бросит ValueError и без # этого guard весь endpoint вернёт 500. try: floors_parsed: list[int] = [] costs_per_m2: list[float] = [] for r in neighbor_rows: f = _parse_floors(r.get("floors")) if f is not None and f > 0: floors_parsed.append(f) if r.get("cost_value") and r.get("area") and float(r["area"]) > 0: cost_per_m2 = float(r["cost_value"]) / float(r["area"]) if _COST_PER_M2_MIN < cost_per_m2 < _COST_PER_M2_MAX: costs_per_m2.append(cost_per_m2) avg_floors = round(sum(floors_parsed) / len(floors_parsed), 1) if floors_parsed else None max_floors = max(floors_parsed) if floors_parsed else None median_cost = round(sorted(costs_per_m2)[len(costs_per_m2) // 2]) if costs_per_m2 else None except (ValueError, TypeError) as e: logger.warning("neighbors aggregation failed: %s", e) return { "data_available": False, "note": f"neighbors aggregation failed: {e}", } # Overlap check — что-то построено непосредственно на нашем участке. # Если хоть один building пересекается с площадью >50 м² — hard warn. try: overlap_row = ( db.execute( text(""" SELECT cad_num, building_name, floors, readable_address, ST_Area( ST_Intersection( ST_Transform(b.geom, 32641), ST_Transform(ST_GeomFromText(:wkt, 4326), 32641) ) ) AS overlap_m2 FROM cad_buildings b WHERE ST_Intersects(b.geom, ST_GeomFromText(:wkt, 4326)) AND b.cad_num != :our_cad ORDER BY overlap_m2 DESC NULLS LAST LIMIT 5 """), {"wkt": geom_wkt, "our_cad": our_cad_num}, ) .mappings() .all() ) except Exception as e: logger.warning("overlap check failed: %s", e) overlap_row = [] overlap_buildings = [ { "cad_num": o["cad_num"], "building_name": o.get("building_name"), "floors": o.get("floors"), "readable_address": o.get("readable_address"), "overlap_m2": round(float(o["overlap_m2"])) if o.get("overlap_m2") else None, } for o in overlap_row if o.get("overlap_m2") and float(o["overlap_m2"]) > 50 ] has_existing = len(overlap_buildings) > 0 return { "data_available": True, "radius_m": 100, "count_buildings_100m": len(neighbor_rows), "avg_floors_100m": avg_floors, "max_floors_100m": max_floors, "median_cost_per_m2_100m": median_cost, "neighbors": [ { "cad_num": r["cad_num"], "building_name": r.get("building_name"), "floors": r.get("floors"), "floors_parsed": _parse_floors(r.get("floors")), "year_built": r.get("year_built"), "area_m2": round(float(r["area"])) if r.get("area") else None, "cost_per_m2": ( round(float(r["cost_value"]) / float(r["area"])) if r.get("cost_value") and r.get("area") and float(r["area"]) > 0 else None ), "distance_m": round(float(r["distance_m"])), "readable_address": r.get("readable_address"), } for r in neighbor_rows[:20] ], "has_existing_buildings": has_existing, "overlap_buildings": overlap_buildings, "note": ( "Cad_buildings 100м radius. Floors хранится как TEXT (диапазоны типа '5-7') — " "agg использует верхнюю границу. Cost/m² — кадастровая стоимость, не рыночная." ), } 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 ненадёжен"). """ 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. # Guard `int(... or 0)` — recent_deals_count иногда приходит как non-numeric # из external/legacy paths; без guard int() крашнет 500. n_recent_raw = (market_trend or {}).get("recent_deals_count") try: n_recent = int(n_recent_raw) if n_recent_raw is not None else 0 except (ValueError, TypeError): n_recent = 0 if n_recent > 0: # порог 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, } # #93 — on-demand cadastre fetch tuning constants. # _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback. # # Tradeoff: sync `def analyze_parcel` блокирует один FastAPI threadpool slot # на это время. Default threadpool в Starlette/FastAPI — 40 slots (anyio # default). При concurrent burst >40 «миссинг cad» запросов будем saturate # threadpool — последующие запросы (включая healthcheck) ждут free slot. # # 15s выбран как баланс: НСПД ~5-15s avg для quarter, ~10-20s для parcel — # fast path сработает в ~70% случаев. Остальные 30% получают 202 + # polling (без блокировки threadpool). _INLINE_FETCH_WAIT_S = 15 _INLINE_FETCH_POLL_INTERVAL_S = 2 @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.get("/{cad_num}/fetch-status") def get_fetch_status( cad_num: str, db: Annotated[Session, Depends(get_db)], ) -> dict[str, Any]: """Polling endpoint для on-demand cadastre fetch (см. issue #93). Frontend polling каждые 2с после 202 Accepted из /analyze. Returns: { "status": "ready" | "fetching" | "not_in_nspd" | "failed" | "invalid_format", "job_id": int | None, "error_msg": str | None, "eta_seconds": int | None, } Frontend поведение: - "ready" → автоматически re-trigger POST /analyze - "fetching" → continue polling - "not_in_nspd" → показать пользователю «cad не найден в НСПД» - "failed" → retry button + retry-after message - "invalid_format" → подсказка формата """ return _fetch_status(db, cad_num) @router.post("/{cad_num}/analyze") def analyze_parcel( cad_num: str, db: Annotated[Session, Depends(get_db)], response: Response, profile_id: Annotated[ int | None, Query(ge=1, description="Переопределить веса POI через конкретный weight profile"), ] = None, profile_user_id: Annotated[ str | None, Query(description="user_id для fallback на default-профиль пользователя"), ] = None, ) -> dict[str, Any]: """Анализ участка: близость к социалке + district context + конкуренты. Порядок поиска геометрии: cad_quarters_geom → cad_buildings → cad_parcels_geom. Issue #93 — Graceful fallback при отсутствии geometry: - Не возвращаем 404 сразу. Вместо: enqueue NSPD on-demand fetch, ждём inline до _INLINE_FETCH_WAIT_S (~15с). Если за это время геометрия появилась в БД — продолжаем analyze (fast path). - Иначе → 202 Accepted + {status, job_id, eta_seconds} для polling через GET /fetch-status. - Дедупликация (через `find_active_on_demand_job`): параллельные запросы на тот же cad → один Celery job, оба клиента ждут. """ # 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: # #93 — graceful fallback: enqueue NSPD fetch, await inline до 15s # (см. _INLINE_FETCH_WAIT_S — снижено с 25 для threadpool safety). status, job_id, error_msg = find_or_enqueue_fetch(db, cad_num) if status == "invalid_format": raise HTTPException(status_code=400, detail=error_msg) if status == "not_in_nspd": raise HTTPException(status_code=404, detail=error_msg) if status == "failed": # 503 — НСПД временно недоступен (rate-limit / WAF) response.headers["Retry-After"] = "60" raise HTTPException(status_code=503, detail=error_msg) # status == "fetching" → inline await fast path deadline = time.monotonic() + _INLINE_FETCH_WAIT_S while time.monotonic() < deadline: time.sleep(_INLINE_FETCH_POLL_INTERVAL_S) if cad_exists_in_db(db, cad_num): # Re-fetch row для analyze 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), b.geom, 'cad_building' FROM cad_buildings b WHERE b.cad_num = :c UNION ALL SELECT ST_AsGeoJSON(p.geom), p.geom, 'cad_parcel' FROM cad_parcels_geom p WHERE p.cad_num = :c LIMIT 1 """), {"c": cad_num}, ) .mappings() .first() ) if row: break if not row: # Timeout — frontend будет poll'ить /fetch-status. response.status_code = 202 return { "status": "fetching", "cad_num": cad_num, "job_id": job_id, "eta_seconds": 15, "message": ("Геометрия загружается из НСПД. Обычно занимает 15-30 секунд."), } 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() ) # 3b) Resolve effective POI weights (profile → user default → system) from app.services.site_finder.weight_profiles import resolve_weights as _resolve_weights _effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) _weights_source = ( "profile" if profile_id is not None else ("user_default" if profile_user_id is not None else "system") ) # 4) Scoring: weighted sum с distance decay score = 0.0 by_category: dict[str, list[dict[str, Any]]] = {} # X1 (#47): per-POI breakdown с verbal explain для UI factors_detailed: list[dict[str, Any]] = [] for idx, p in enumerate(poi_rows): cat: str = p["category"] w = _effective_weights.get(cat, _POI_WEIGHTS.get(cat, 0.0)) # distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м distance_m = float(p["distance_m"]) decay = max(0.0, 1.0 - distance_m / 1000.0) contribution = w * decay score += contribution by_category.setdefault(cat, []).append( { "name": p["name"], "distance_m": round(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 ), } ) # Skip факторы с нулевым вкладом (POI дальше 1км) — UI шуму не нужен. if abs(contribution) < 0.01: continue factors_detailed.append( { # Include idx чтобы избежать React key collision: два POI одной # категории на одинаково округлённом расстоянии иначе дали бы # дубль (например, two аптеки 450м в плотном районе). "factor": f"{cat}_{round(distance_m)}m_{idx}", "category": cat, "category_ru": _POI_CATEGORY_RU.get(cat, cat), "group": _POI_GROUP.get(cat, "Прочее"), "value": round(distance_m, 1), "weight": w, "contribution": round(contribution, 2), "verbal": _verbal_for_poi(cat, p["name"], distance_m, contribution), "lat": float(p["lat"]) if p["lat"] is not None else None, "lon": float(p["lon"]) if p["lon"] is not None 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() ) # 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес # в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class # + по кварталам сдачи. Константы — см. PIPELINE_* выше. # NB: full seq scan на ~3000 строк OK; при росте — нужен GIST/index на # (latitude, longitude) — отдельный issue для database-expert. pipeline_rows = ( db.execute( text(""" WITH latest_obj AS ( SELECT DISTINCT ON (obj_id) * FROM domrf_kn_objects WHERE latitude IS NOT NULL AND ready_dt IS NOT NULL ORDER BY obj_id, snapshot_date DESC NULLS LAST ) SELECT obj_id, comm_name, dev_name, obj_class, flat_count, ready_dt, 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, :radius_m ) AND ready_dt >= CURRENT_DATE AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval) ORDER BY ready_dt ASC """), { "wkt": geom_wkt, "radius_m": PIPELINE_RADIUS_M, "horizon_months": str(PIPELINE_HORIZON_MONTHS), }, ) .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 # X1 (#47): centrality как отдельный synthetic factor в breakdown. # NB: для centrality decay не применяется (bonus IS the value), поэтому # weight=1.0 семантически — "no decay multiplier"; contribution = center_bonus. if center_bonus > 0: factors_detailed.append( { "factor": f"center_bonus_{round(dist_to_center_km)}km", "category": "centrality", "category_ru": "Центральность", "group": "Локация", "value": round(dist_to_center_km, 2), "weight": 1.0, "contribution": round(center_bonus, 2), "verbal": ( f"Близость к центру ЕКБ ({dist_to_center_km:.1f}км) — " f"+{center_bonus:.2f} баллов" ), "lat": None, "lon": None, } ) # 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 # 9e) NSPD quarter dump — ПЗЗ зона + ЗОУИТ + инженерные сооружения из кеша try: nspd_dump_data = get_quarter_dump_data(db, cad_num, geom_wkt) except Exception as e: logger.warning("nspd quarter dump lookup failed for %s: %s", cad_num, e) # Independent dict per request — never mutate module singleton. nspd_dump_data = make_empty_result() # 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 # X1 (#47): расчёт contribution_pct + top-3 / by-group для UI. # Базис для процентов — сумма абсолютных значений всех факторов; это даёт # стабильное соотношение независимо от знака и не делится на 0. abs_total = sum(abs(f["contribution"]) for f in factors_detailed) or 1.0 for f in factors_detailed: f["contribution_pct"] = round(100.0 * abs(f["contribution"]) / abs_total, 1) factors_sorted = sorted(factors_detailed, key=lambda x: x["contribution"], reverse=True) # Convention: оба top-list'а отсортированы "dominant first": # positives → most-positive first (factors_sorted desc → [:3]) # negatives → most-negative first (sort negatives asc → [:3]) score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3] negatives_only = [f for f in factors_sorted if f["contribution"] < 0] score_top_3_negatives = sorted(negatives_only, key=lambda x: x["contribution"])[:3] # By-group totals — для stacked-bar в UI. count это int, contribution* — float. group_totals: dict[str, dict[str, float | int]] = {} for f in factors_detailed: g = group_totals.setdefault( f["group"], {"contribution": 0.0, "count": 0, "contribution_pct": 0.0} ) g["contribution"] += f["contribution"] g["count"] += 1 group_abs_total = sum(abs(g["contribution"]) for g in group_totals.values()) or 1.0 for g_val in group_totals.values(): g_val["contribution"] = round(g_val["contribution"], 2) g_val["contribution_pct"] = round(100.0 * abs(g_val["contribution"]) / group_abs_total, 1) score_by_group = [ {"group": k, **v} for k, v in sorted(group_totals.items(), key=lambda kv: -abs(kv[1]["contribution"])) ] # 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, ) # D4 (#36): aggregate pipeline_24mo pipeline_24mo = _aggregate_pipeline(pipeline_rows) 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, # X1 (#47): per-factor контрибуции с verbal explain + top-3 / by-group. "score_breakdown_detailed": factors_sorted, "score_top_3_positives": score_top_3_positives, "score_top_3_negatives": score_top_3_negatives, "score_by_group": score_by_group, "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], # D4 (#36): 24-month pipeline competition "pipeline_24mo": pipeline_24mo, "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), # P1 (#45) — physical suitability участка "geometry_suitability": _polygon_suitability(geom_wkt), # P2 (#46) — соседи-здания + overlap check "neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num), "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"], # Sprint 1.1 item #4 — NSPD quarter dump fields # nspd_zoning: ПЗЗ зона из territorial_zones кеша (G1) # nspd_zouit_overlaps: ЗОУИТ пересечения (G3) # nspd_engineering_nearby: инженерные сооружения в 200м (I3) # nspd_dump: freshness metadata — available, stale, harvest_triggered "nspd_zoning": nspd_dump_data["nspd_zoning"], "nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"], "nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"], "nspd_dump": nspd_dump_data["nspd_dump"], # #114: кастомные веса POI — source + applied dict для прозрачности. "weights_profile": { "source": _weights_source, "profile_id": profile_id, "user_id": profile_user_id, "weights_applied": _effective_weights, }, } _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.", }