diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 015f5608..5175b8d3 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -70,6 +70,82 @@ def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None: 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. @@ -165,6 +241,71 @@ _POI_WEIGHTS: dict[str, float] = { } +# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет). +# Добавляй регионы по мере расширения географии продукта. +GEOTECH_BY_REGION: dict[int, dict[str, Any]] = { + 66: { # Свердловская обл. + "seismic_intensity_balls": 5, + "seismic_label": "минимальная сейсмика (≤5 баллов)", + "seismic_description": "Обычное строительство без специальных мер.", + "permafrost": False, + }, + 77: { # Москва + "seismic_intensity_balls": 4, + "seismic_label": "практически нет сейсмики", + "seismic_description": "Обычное строительство.", + "permafrost": False, + }, +} + + +def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]: + """Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость. + + industrial_within_500m — proxy для возможного загрязнения почв (без + реальных шейпов зон загрязнения). Точная геология/гидрогеология требует + инженерных изысканий (bore-holes). + """ + region_data: dict[str, Any] = GEOTECH_BY_REGION.get( + region_code, + { + "seismic_intensity_balls": None, + "seismic_label": "нет данных в встроенной таблице", + "seismic_description": "ОСР-2016: уточняйте по карте.", + "permafrost": False, + }, + ) + + industrial_close: int = ( + db.execute( + text(""" + SELECT COUNT(*) AS n + FROM osm_noise_sources_ekb + WHERE source_type = 'industrial' + AND ST_DWithin( + geom::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + 500 + ) + """), + {"wkt": geom_wkt}, + ).scalar() + or 0 + ) + + return { + **region_data, + "industrial_within_500m": int(industrial_close), + "industrial_contamination_flag": int(industrial_close) > 0, + "note": ( + "Сейсмика — ОСР-2016 карта B (среднее повторяемое за 500 лет). " + "Для строительства обычно достаточно ≤7 баллов без спецмер. " + "Industrial proximity — proxy для возможного загрязнения почв. " + "Точная геология/гидрогеология — требует bore-holes (изыскания)." + ), + } + + @router.post("/search", response_model=ParcelSearchResponse) async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse: """Search parcels by filters + scoring. @@ -435,6 +576,58 @@ def analyze_parcel( # 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 + # 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев market_trend: dict[str, Any] | None = None try: @@ -551,7 +744,10 @@ def analyze_parcel( }, "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, + "geotech_risk": _geotech_risk(66, db, geom_wkt), "market_trend": market_trend, } diff --git a/backend/app/services/site_finder/noise_loader.py b/backend/app/services/site_finder/noise_loader.py index 29a982b9..d96eb028 100644 --- a/backend/app/services/site_finder/noise_loader.py +++ b/backend/app/services/site_finder/noise_loader.py @@ -35,6 +35,11 @@ _NOISE_QUERIES: list[tuple[str, str, str, str | None]] = [ ("railway", "rail", "railway", None), ("landuse", "industrial", "industrial", None), ("aeroway", "aerodrome", "aerodrome", None), + # Гидрология — реки, ручьи, каналы (way-линии) и водоёмы (way-полигоны) + ("waterway", "river", "water", "river"), + ("waterway", "stream", "water", "stream"), + ("waterway", "canal", "water", "canal"), + ("natural", "water", "water", "lake_or_pond"), ] @@ -104,6 +109,21 @@ def _way_to_linestring_wkt(nodes: list[dict]) -> str | None: return f"LINESTRING({', '.join(points)})" +def _way_to_polygon_wkt(nodes: list[dict]) -> str | None: + """Построить WKT Polygon из замкнутого way Overpass (out geom). + + Используется для natural=water (озёра, пруды). Если кольцо не замкнуто + автоматически — добавляем первую точку в конец. + """ + points = [f"{n['lon']} {n['lat']}" for n in nodes if "lon" in n and "lat" in n] + if len(points) < 3: + return None + # Замкнуть кольцо если нужно + if points[0] != points[-1]: + points.append(points[0]) + return f"POLYGON(({', '.join(points)}))" + + def sync_noise_sources_to_db() -> dict[str, int]: """UPSERT шумовых источников из Overpass в osm_noise_sources_ekb. @@ -135,7 +155,14 @@ def sync_noise_sources_to_db() -> dict[str, int]: geom_params: dict if osm_type == "way": nodes: list[dict] = el.get("geometry") or [] - wkt = _way_to_linestring_wkt(nodes) + # natural=water → замкнутый полигон; waterway → линия + if source_type == "water" and road_class == "lake_or_pond": + wkt = _way_to_polygon_wkt(nodes) + if wkt is None: + # Fallback: попробовать как линию (частично загруженный bbox-обрезок) + wkt = _way_to_linestring_wkt(nodes) + else: + wkt = _way_to_linestring_wkt(nodes) if wkt is None: skipped += 1 continue diff --git a/frontend/src/components/site-finder/GeotechRiskBlock.tsx b/frontend/src/components/site-finder/GeotechRiskBlock.tsx new file mode 100644 index 00000000..83030a54 --- /dev/null +++ b/frontend/src/components/site-finder/GeotechRiskBlock.tsx @@ -0,0 +1,131 @@ +"use client"; + +import type { GeotechRisk } from "@/types/site-finder"; + +interface Props { + risk: GeotechRisk; +} + +function seismicBadgeStyle(balls: number): { bg: string; color: string } { + if (balls <= 5) return { bg: "#dcfce7", color: "#15803d" }; + if (balls <= 7) return { bg: "#fef3c7", color: "#92400e" }; + return { bg: "#fecaca", color: "#b91c1c" }; +} + +export function GeotechRiskBlock({ risk }: Props) { + return ( +