diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index cd7ae0e3..015f5608 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -70,11 +70,10 @@ def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None: return None -def _fetch_wind_sync(lat: float, lon: float) -> dict | None: - """Синхронный запрос к Open-Meteo Forecast API для данных о ветре. +def _fetch_weather_sync(lat: float, lon: float) -> dict | None: + """Open-Meteo Forecast API — 7-day weather + climate context. - Возвращает доминирующее направление ветра (circular mean по 7 дням) и - максимальную скорость. None если API недоступен. + Free, no API key, JSON by coordinates. Покрывает РФ полностью. """ try: with httpx.Client(timeout=5) as c: @@ -83,28 +82,58 @@ def _fetch_wind_sync(lat: float, lon: float) -> dict | None: params={ "latitude": lat, "longitude": lon, - "daily": "winddirection_10m_dominant,windspeed_10m_max", + "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", {}) - dirs = daily.get("winddirection_10m_dominant") or [] - speeds = daily.get("windspeed_10m_max") or [] - if not dirs: + 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 dirs) - y = sum(math.sin(math.radians(d)) for d in dirs) - dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 + 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 { - "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), + "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: + except Exception as e: + logger.warning("weather fetch failed: %s", e) return None @@ -403,8 +432,8 @@ def analyze_parcel( # 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) + # 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности) + weather = _fetch_weather_sync(centroid_lat, centroid_lon) # 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев market_trend: dict[str, Any] | None = None @@ -477,6 +506,28 @@ def analyze_parcel( logger.warning("market_trend query failed for %s: %s", cad_num, e) market_trend = None + # 10b) 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, + } + return { "cad_num": cad_num, "source": source, @@ -499,6 +550,8 @@ def analyze_parcel( "sources": nearby_noise_sources[:10], }, "air_quality": air_q, - "wind": wind_data, + "weather": weather, + "wind": (weather or {}).get("wind") if weather else None, # backward compat + "geology": geology, "market_trend": market_trend, } diff --git a/frontend/src/components/site-finder/GeologyBlock.tsx b/frontend/src/components/site-finder/GeologyBlock.tsx new file mode 100644 index 00000000..432d6ea8 --- /dev/null +++ b/frontend/src/components/site-finder/GeologyBlock.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { ParcelAnalysisGeology } from "@/types/site-finder"; + +interface Props { + geology: ParcelAnalysisGeology; +} + +export function GeologyBlock({ geology }: Props) { + return ( +