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 "отлично" # Веса 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 _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 (по flats_total) - top_objects: первые 10 ближайших / крупнейших ЖК Используется для UI pipeline-bar и severity badge. """ if not rows: return { "objects_count": 0, "flats_total": 0, "by_class": {}, "by_quarter": [], "severity": "none", "top_objects": [], "note": "Нет ЖК в pipeline 24мес в радиусе 5км — низкая будущая конкуренция", } 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 — порог 500 / 3000): if flats_total < 500: severity = "low" elif flats_total < 3000: 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 top_objects = sorted( [dict(r) for r in rows], key=lambda r: r.get("flat_count") or 0, reverse=True, )[:10] # Serialize date for JSON for obj in top_objects: if obj.get("ready_dt"): obj["ready_dt"] = obj["ready_dt"].isoformat() if obj.get("distance_m"): obj["distance_m"] = round(float(obj["distance_m"])) 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": 5, "horizon_months": 24, "note": ( "Будущая конкуренция за покупателя: planned_commissioning от Росреестра " "часто оптимистичен (сдвиги по факту). Pressure-балл — относительный." ), } 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 (изыскания)." ), } @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() ) # 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес # в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class # + по кварталам сдачи. 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, 5000 ) AND ready_dt >= CURRENT_DATE AND ready_dt < CURRENT_DATE + INTERVAL '24 months' ORDER BY ready_dt ASC """), {"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 # 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, "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), "market_trend": market_trend, "zoning": zoning, "success_recommendation": success_recommendation, "isochrones_available": bool(settings.openrouteservice_api_key), } _ORS_BASE = "https://api.openrouteservice.org/v2/isochrones" _ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"}) @router.get("/{cad_num}/isochrones") def get_isochrones( cad_num: str, db: Annotated[Session, Depends(get_db)], mode: str = "foot-walking", times_min: Annotated[list[int], Query()] = [10, 15], # noqa: B006 ) -> dict[str, Any]: """Изохроны доступности от центроида участка через OpenRouteService. Modes: foot-walking | cycling-regular | driving-car. times_min — список минут (1-60), например ?times_min=10×_min=15. Возвращает GeoJSON FeatureCollection для рендера на карте. """ if not settings.openrouteservice_api_key: raise HTTPException( status_code=503, detail=( "OPENROUTESERVICE_API_KEY не задан в env. " "Получи free key на https://openrouteservice.org/dev/#/signup " "и пропиши в backend/.env.runtime" ), ) if mode not in _ORS_VALID_MODES: raise HTTPException( status_code=422, detail=f"Недопустимый mode '{mode}'. Допустимые: {sorted(_ORS_VALID_MODES)}", ) invalid_times = [t for t in times_min if not (1 <= t <= 60)] if invalid_times: raise HTTPException( status_code=422, detail=f"times_min значения вне диапазона 1-60: {invalid_times}", ) # Получить координаты центроида из доступных геометрий участка coord_row = ( db.execute( text(""" SELECT ST_X(ST_Centroid(g.geom)) AS lon, ST_Y(ST_Centroid(g.geom)) AS lat FROM ( SELECT geom FROM cad_quarters_geom WHERE cad_number = :c UNION ALL SELECT geom FROM cad_buildings WHERE cad_num = :c UNION ALL SELECT geom FROM cad_parcels_geom WHERE cad_num = :c ) g LIMIT 1 """), {"c": cad_num}, ) .mappings() .first() ) if not coord_row: raise HTTPException( status_code=404, detail=f"Геометрия для {cad_num} не найдена.", ) lat = float(coord_row["lat"]) lon = float(coord_row["lon"]) # OpenRouteService isochrones API — POST с JSON body url = f"{_ORS_BASE}/{mode}" body: dict[str, Any] = { "locations": [[lon, lat]], "range": [t * 60 for t in times_min], # ORS ожидает секунды "range_type": "time", "units": "m", } headers = { "Authorization": settings.openrouteservice_api_key, "Content-Type": "application/json", "Accept": "application/geo+json", } try: with httpx.Client(timeout=10) as client: resp = client.post(url, json=body, headers=headers) resp.raise_for_status() geojson = resp.json() except httpx.HTTPStatusError as exc: if exc.response.status_code == 429: raise HTTPException( status_code=429, detail="OpenRouteService daily limit (2000 req) exceeded.", ) from exc logger.error("ORS HTTP error for %s: %s — %s", cad_num, exc.response.status_code, exc) raise HTTPException( status_code=500, detail=f"ORS error {exc.response.status_code}: {exc.response.text[:200]}", ) from exc except Exception as exc: logger.error("ORS request failed for %s: %s", cad_num, exc) raise HTTPException( status_code=500, detail=f"Isochrones fetch failed: {exc}", ) from exc return { "cad_num": cad_num, "lat": lat, "lon": lon, "mode": mode, "times_min": times_min, "geojson": geojson, "source": "openrouteservice.org", "note": "Free tier 2000 req/day. Замена на self-hosted OSRM — в #27.", }