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 ( +
+
+ Геотехнический риск +
+ + {/* Seismics */} +
+ {risk.seismic_intensity_balls !== null ? ( + <> +
+ {risk.seismic_intensity_balls} +
+
+
+ {risk.seismic_label} +
+ {risk.seismic_description && ( +
+ {risk.seismic_description} +
+ )} +
+ + ) : ( +
+ {risk.seismic_label} +
+ )} +
+ + {/* Permafrost */} + {risk.permafrost && ( +
+ 🟡 + Вечная мерзлота — особые фундаменты +
+ )} + + {/* Industrial contamination */} + {risk.industrial_contamination_flag && ( +
+ + + {risk.industrial_within_500m} промзон(ы) в <500м (риск + загрязнения почв) + +
+ )} + + {/* Note */} + {risk.note && ( +
{risk.note}
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/HydrologyBlock.tsx b/frontend/src/components/site-finder/HydrologyBlock.tsx new file mode 100644 index 00000000..ed32f9d4 --- /dev/null +++ b/frontend/src/components/site-finder/HydrologyBlock.tsx @@ -0,0 +1,109 @@ +"use client"; + +import type { Hydrology, HydrologyNearest } from "@/types/site-finder"; + +interface Props { + hydrology: Hydrology; +} + +const SUBTYPE_ICON: Record = { + river: "🏞", + stream: "💦", + lake_or_pond: "🪷", + canal: "🚣", +}; + +function subtypeLabel(subtype: HydrologyNearest["subtype"]): string { + switch (subtype) { + case "river": + return "река"; + case "stream": + return "ручей"; + case "lake_or_pond": + return "озеро/пруд"; + case "canal": + return "канал"; + default: + return "водоём"; + } +} + +export function HydrologyBlock({ hydrology }: Props) { + const top5 = hydrology.nearest.slice(0, 5); + + return ( +
+
+ Гидрология +
+ + {/* Flood risk banner */} + {hydrology.flood_risk_flag && ( +
+ + Возможная пойма — река/канал в <200м +
+ )} + + {/* Nearest water bodies */} + {top5.length === 0 ? ( +
+ Поблизости водных объектов нет +
+ ) : ( +
+ {top5.map((item, i) => { + const icon = SUBTYPE_ICON[item.subtype ?? ""] ?? "💧"; + const label = subtypeLabel(item.subtype); + const name = item.name ?? "б/н"; + return ( +
+ {icon} + + {name} {label} — {Math.round(item.distance_m)} м + +
+ ); + })} +
+ )} + + {/* Note */} + {hydrology.note && ( +
{hydrology.note}
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/ScoreCard.tsx b/frontend/src/components/site-finder/ScoreCard.tsx index a501a46b..111b41bf 100644 --- a/frontend/src/components/site-finder/ScoreCard.tsx +++ b/frontend/src/components/site-finder/ScoreCard.tsx @@ -10,6 +10,9 @@ import type { } from "@/types/site-finder"; import { MarketTrendBlock } from "./MarketTrendBlock"; import { GeologyBlock } from "./GeologyBlock"; +import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock"; +import { HydrologyBlock } from "./HydrologyBlock"; +import { GeotechRiskBlock } from "./GeotechRiskBlock"; interface Props { data: ParcelAnalysis; @@ -714,6 +717,42 @@ export function ScoreCard({ data }: Props) { )} + {/* Seasonal weather */} + {"seasonal_weather" in data && ( +
+ +
+ )} + + {/* Hydrology */} + {data.hydrology !== undefined && ( +
+ +
+ )} + + {/* Geotech risk */} + {data.geotech_risk !== undefined && ( +
+ +
+ )} + {/* Geology */} {data.geology !== undefined && (
= [ + { key: "winter", label: "Зима", bg: "#dbeafe", color: "#1e40af" }, + { key: "spring", label: "Весна", bg: "#dcfce7", color: "#15803d" }, + { key: "summer", label: "Лето", bg: "#fef3c7", color: "#92400e" }, + { key: "autumn", label: "Осень", bg: "#fed7aa", color: "#c2410c" }, +]; + +function SeasonCard({ + label, + bg, + color, + stats, +}: { + label: string; + bg: string; + color: string; + stats: SeasonStats; +}) { + return ( +
+
{label}
+
+ {stats.avg_t_min_c}…{stats.avg_t_max_c}°C +
+
+ (макс ↓{stats.min_t_c} / ↑{stats.max_t_c}°C) +
+
+ {stats.total_precip_mm} мм осадков +
+
+ ); +} + +export function SeasonalWeatherBlock({ seasonal }: Props) { + if (!seasonal) { + return ( +
+
+ Климат (нормали) +
+
+ Климат-данные недоступны +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ Климат (нормали) +
+
{seasonal.period}
+
+ + {/* Season cards */} +
+ {SEASON_CONFIG.map(({ key, label, bg, color }) => { + const stats = seasonal.seasons[key]; + if (!stats) { + return ( +
+
+ {label} +
+
+ Нет данных +
+
+ ); + } + return ( + + ); + })} +
+ + {/* Footer */} +
+ {seasonal.source} · {seasonal.model} +
+
+ ); +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 46f45e69..1c7eec75 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -82,6 +82,51 @@ export interface ParcelAnalysisGeology { lon: number; } +export interface SeasonStats { + avg_t_max_c: number; + avg_t_min_c: number; + max_t_c: number; + min_t_c: number; + avg_precip_per_day_mm: number; + total_precip_mm: number; + days_observed: number; +} + +export interface SeasonalWeather { + seasons: { + winter: SeasonStats | null; + spring: SeasonStats | null; + summer: SeasonStats | null; + autumn: SeasonStats | null; + }; + period: string; + model: string; + source: string; + note: string; +} + +export interface HydrologyNearest { + subtype: "river" | "stream" | "lake_or_pond" | "canal" | null; + name: string | null; + distance_m: number; +} + +export interface Hydrology { + nearest: HydrologyNearest[]; + flood_risk_flag: boolean; + note: string; +} + +export interface GeotechRisk { + seismic_intensity_balls: number | null; + seismic_label: string; + seismic_description: string; + permafrost: boolean; + industrial_within_500m: number; + industrial_contamination_flag: boolean; + note: string; +} + export interface MarketTrend { recent_avg_price_per_m2: number; prior_avg_price_per_m2: number; @@ -109,6 +154,9 @@ export interface ParcelAnalysis { air_quality: ParcelAnalysisAirQuality | null; wind: ParcelAnalysisWind | null; weather?: ParcelAnalysisWeather | null; + seasonal_weather?: SeasonalWeather | null; + hydrology?: Hydrology; + geotech_risk?: GeotechRisk; geology?: ParcelAnalysisGeology; }