import json import logging import math from typing import Annotated, Any import httpx from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import text from sqlalchemy.orm import Session 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_wind_sync(lat: float, lon: float) -> dict | None: """Синхронный запрос к Open-Meteo Forecast API для данных о ветре. Возвращает доминирующее направление ветра (circular mean по 7 дням) и максимальную скорость. None если API недоступен. """ try: with httpx.Client(timeout=5) as c: r = c.get( "https://api.open-meteo.com/v1/forecast", params={ "latitude": lat, "longitude": lon, "daily": "winddirection_10m_dominant,windspeed_10m_max", "forecast_days": 7, }, ) r.raise_for_status() daily = r.json().get("daily", {}) dirs = daily.get("winddirection_10m_dominant") or [] speeds = daily.get("windspeed_10m_max") or [] if not dirs: return None # Circular mean направления ветра (vector sum) — избегает jump 359→1 x = sum(math.cos(math.radians(d)) for d in dirs) y = sum(math.sin(math.radians(d)) for d in dirs) dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 return { "dominant_direction_deg": round(dominant), "dominant_direction_label": _wind_label(dominant), "max_speed_m_s": round(max(speeds), 1) if speeds else None, "forecast_days": len(dirs), "source": "open-meteo", } except Exception: return None # Эмпирические пороги 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 _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, # негативный вес — шум / вибрация } @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 # 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) Wind — Open-Meteo (best-effort, null при недоступности) wind_data = _fetch_wind_sync(centroid_lat, centroid_lon) # 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 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, 2), "score_label": _score_label(score), "score_max_reference": SCORE_MAX_REFERENCE, "score_explanation": ( "Сумма close-distance POI (школы/сады/парки +, трамваи -). " ">40 = редко, типичный город. центр 15-30." ), "score_breakdown": by_category, "poi_count": len(poi_rows), "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, "wind": wind_data, "market_trend": market_trend, }