diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index a5cab861..7a06d461 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -367,6 +367,23 @@ def list_logs( # ── Objective ETL (Антоновский SQLite → PG) ───────────────────────────────── +@router.post("/noise-sync") +def trigger_noise_sync( + x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, +) -> dict[str, Any]: + """Manual trigger для синхронизации OSM шумовых источников ЕКБ из Overpass API. + + Обычно запускается еженедельно через beat (понед 3:30 МСК). + Этот endpoint — для ad-hoc запуска (например после первого деплоя или + после добавления миграции 84_osm_noise_sources_ekb). + """ + _check_token(x_admin_token) + from app.workers.tasks.noise_sync import sync_osm_noise_sources_ekb + + result = sync_osm_noise_sources_ekb.apply_async() + return {"task_id": result.id, "queued_at": "now"} + + @router.post("/poi-sync") def trigger_poi_sync( x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index fc1bc919..268cf5ec 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1,7 +1,9 @@ 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 @@ -13,6 +15,99 @@ 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 + + # Веса POI-категорий для scoring (Максим: трамвай = минус) _POI_WEIGHTS: dict[str, float] = { "school": 1.5, @@ -224,6 +319,81 @@ def analyze_parcel( .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) + return { "cad_num": cad_num, "source": source, @@ -233,4 +403,12 @@ def analyze_parcel( "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, } diff --git a/backend/app/services/site_finder/noise_loader.py b/backend/app/services/site_finder/noise_loader.py new file mode 100644 index 00000000..8bb10eb8 --- /dev/null +++ b/backend/app/services/site_finder/noise_loader.py @@ -0,0 +1,205 @@ +"""Загрузчик OSM источников шума из Overpass API для site-finder. + +Запускается раз в неделю через Celery beat. Хранит дороги (way), ж/д пути, +промзоны и аэродромы в таблице osm_noise_sources_ekb. +""" + +import asyncio +import json +import logging + +import httpx +from sqlalchemy import text + +from app.core.db import SessionLocal + +logger = logging.getLogger(__name__) + +OVERPASS_URL = "https://overpass-api.de/api/interpreter" +EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east) + +# Overpass блокирует дефолтный User-Agent (406) — явный UA с контактом. +_HEADERS = { + "User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)", + "Accept": "application/json", +} + +# Описание запросов: (overpass_key, overpass_value, source_type, road_class_from_value) +_NOISE_QUERIES: list[tuple[str, str, str, str | None]] = [ + ("highway", "motorway", "highway", "motorway"), + ("highway", "trunk", "highway", "trunk"), + ("highway", "primary", "highway", "primary"), + ("highway", "secondary", "highway", "secondary"), + ("highway", "tertiary", "highway", "tertiary"), + ("highway", "residential", "highway", "residential"), + ("railway", "rail", "railway", None), + ("landuse", "industrial", "industrial", None), + ("aeroway", "aerodrome", "aerodrome", None), +] + + +def _build_overpass_query(key: str, value: str, el_type: str) -> str: + """Запрос Overpass для одного типа элемента. + + Для way используем `out geom` — получаем полные координаты узлов (нужно + для построения LineString геометрии). + Для node — `out body`. + """ + south, west, north, east = EKB_BBOX + bbox = f"({south},{west},{north},{east})" + if el_type == "way": + return f"[out:json][timeout:30];" f'way["{key}"="{value}"]{bbox};' f"out geom;" + else: + return f"[out:json][timeout:30];" f'node["{key}"="{value}"]{bbox};' f"out body;" + + +async def fetch_overpass_noise() -> list[dict]: + """Запрашивает Overpass API для всех шумовых источников ЕКБ. + + Возвращает enriched list dict'ов с дополнительными полями: + _source_type, _road_class — для использования при UPSERT. + + Между запросами sleep 1с (Overpass usage policy). + """ + all_elements: list[dict] = [] + async with httpx.AsyncClient(timeout=60, headers=_HEADERS) as client: + for key, value, source_type, road_class in _NOISE_QUERIES: + # aerodrome — node; остальное — way + el_type = "node" if key == "aeroway" else "way" + query = _build_overpass_query(key, value, el_type) + try: + r = await client.post(OVERPASS_URL, data={"data": query}) + r.raise_for_status() + elements: list[dict] = r.json().get("elements", []) + logger.info( + "Overpass noise: %s=%s (%s) → %d elements", + key, + value, + source_type, + len(elements), + ) + for el in elements: + el["_source_type"] = source_type + el["_road_class"] = road_class + all_elements.extend(elements) + except Exception as e: + logger.warning("Overpass noise failed for %s=%s: %s", key, value, e) + await asyncio.sleep(1.0) + logger.info("Overpass noise: total %d elements", len(all_elements)) + return all_elements + + +def _way_to_linestring_wkt(nodes: list[dict]) -> str | None: + """Построить WKT LineString из списка узлов Overpass (out geom). + + Каждый узел — {'lat': ..., 'lon': ...}. Минимум 2 точки для валидной линии. + """ + if len(nodes) < 2: + return None + parts = " ".join(f"{n['lon']} {n['lat']}" for n in nodes if "lon" in n and "lat" in n) + if not parts or parts.count(" ") < 1: + return None + return f"LINESTRING({parts})" + + +def sync_noise_sources_to_db() -> dict[str, int]: + """UPSERT шумовых источников из Overpass в osm_noise_sources_ekb. + + UPSERT по UNIQUE(osm_type, osm_id). + Геометрия: + - way с nodes → LineString WKT через ST_GeomFromText(..., 4326) + - node → ST_SetSRID(ST_MakePoint(lon, lat), 4326) + Записывает breadcrumb в nspd_geo_log (stage='noise_sync'). + """ + elements = asyncio.run(fetch_overpass_noise()) + + inserted = 0 + updated = 0 + skipped = 0 + fetched = len(elements) + + db = SessionLocal() + try: + for el in elements: + osm_id: int = el["id"] + osm_type: str = el["type"] # 'way' | 'node' + source_type: str = el.get("_source_type", "unknown") + road_class: str | None = el.get("_road_class") + tags: dict = el.get("tags") or {} + name: str | None = tags.get("name") + + # Геометрия зависит от типа элемента + geom_sql: str + geom_params: dict + if osm_type == "way": + nodes: list[dict] = el.get("geometry") or [] + wkt = _way_to_linestring_wkt(nodes) + if wkt is None: + skipped += 1 + continue + geom_sql = "ST_GeomFromText(:wkt, 4326)" + geom_params = {"wkt": wkt} + elif osm_type == "node": + lat = el.get("lat") + lon = el.get("lon") + if lat is None or lon is None: + skipped += 1 + continue + geom_sql = "ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)" + geom_params = {"lat": lat, "lon": lon} + else: + # relation — skip (сложная геометрия, не нужна сейчас) + skipped += 1 + continue + + params: dict = { + "osm_id": osm_id, + "osm_type": osm_type, + "source_type": source_type, + "road_class": road_class, + "name": name, + "tags": json.dumps(tags, ensure_ascii=False), + **geom_params, + } + + result = db.execute( + text(f""" + INSERT INTO osm_noise_sources_ekb + (osm_type, osm_id, source_type, road_class, name, geom, tags, fetched_at) + VALUES ( + :osm_type, :osm_id, :source_type, :road_class, :name, + {geom_sql}, + CAST(:tags AS jsonb), NOW() + ) + ON CONFLICT (osm_type, osm_id) DO UPDATE + SET source_type = EXCLUDED.source_type, + road_class = EXCLUDED.road_class, + name = EXCLUDED.name, + geom = EXCLUDED.geom, + tags = EXCLUDED.tags, + fetched_at = NOW() + RETURNING (xmax = 0) AS is_insert + """), + params, + ).scalar() + + if result: + inserted += 1 + else: + updated += 1 + + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + result_dict = { + "fetched": fetched, + "inserted": inserted, + "updated": updated, + "skipped": skipped, + } + logger.info("noise_sync done: %s", result_dict) + return result_dict diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 9abc0c88..d624a52a 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -54,6 +54,7 @@ celery_app = Celery( "app.workers.tasks.objective_etl", "app.workers.tasks.nspd_geo", "app.workers.tasks.poi_sync", + "app.workers.tasks.noise_sync", ], ) celery_app.conf.timezone = "Europe/Moscow" @@ -220,6 +221,14 @@ def _build_beat_schedule() -> dict: "options": {"queue": "celery"}, } + # OSM noise sources sync — еженедельно в понедельник в 03:30 МСК + # (через 30 мин после poi-sync, чтобы не нагружать Overpass одновременно) + schedule["noise-sync-weekly"] = { + "task": "tasks.noise_sync.sync_osm_noise_sources_ekb", + "schedule": _parse_cron("30 3 * * mon"), + "options": {"queue": "celery"}, + } + return schedule diff --git a/backend/app/workers/tasks/noise_sync.py b/backend/app/workers/tasks/noise_sync.py new file mode 100644 index 00000000..63357cfb --- /dev/null +++ b/backend/app/workers/tasks/noise_sync.py @@ -0,0 +1,51 @@ +"""Celery task: еженедельная синхронизация OSM источников шума для site-finder.""" + +import logging + +from sqlalchemy import text + +from app.workers.celery_app import celery_app + +logger = logging.getLogger(__name__) + + +def _log_breadcrumb(level: str, message: str) -> None: + """Записать строку в nspd_geo_log (job_id=NULL, stage='noise_sync').""" + try: + from app.core.db import SessionLocal + + db = SessionLocal() + try: + db.execute( + text( + "INSERT INTO nspd_geo_log (job_id, level, stage, message) " + "VALUES (NULL, :lvl, 'noise_sync', :msg)" + ), + {"lvl": level, "msg": message[:500]}, + ) + db.commit() + finally: + db.close() + except Exception as e: + logger.warning("noise_sync breadcrumb failed: %s", e) + + +@celery_app.task(name="tasks.noise_sync.sync_osm_noise_sources_ekb", queue="celery") +def sync_osm_noise_sources_ekb() -> dict: + """Еженедельная синхронизация OSM шумовых источников из Overpass в osm_noise_sources_ekb. + + Запускается через beat (понедельник 03:30 МСК — через 30 мин после poi-sync). + Persistent breadcrumbs в nspd_geo_log для диагностики без SSH. + """ + _log_breadcrumb("info", "task started") + try: + from app.services.site_finder.noise_loader import sync_noise_sources_to_db + + result = sync_noise_sources_to_db() + logger.info("noise_sync done: %s", result) + _log_breadcrumb("info", f"done: {result}") + return result + except Exception as e: + logger.exception("noise_sync failed: %s", e) + _log_breadcrumb("error", f"{type(e).__name__}: {e}") + raise diff --git a/frontend/src/components/site-finder/ScoreCard.tsx b/frontend/src/components/site-finder/ScoreCard.tsx index bc7f6ca8..eede065b 100644 --- a/frontend/src/components/site-finder/ScoreCard.tsx +++ b/frontend/src/components/site-finder/ScoreCard.tsx @@ -1,6 +1,12 @@ "use client"; -import type { ParcelAnalysis } from "@/types/site-finder"; +import { useState } from "react"; +import type { + ParcelAnalysis, + ParcelAnalysisNoise, + ParcelAnalysisAirQuality, + ParcelAnalysisWind, +} from "@/types/site-finder"; interface Props { data: ParcelAnalysis; @@ -20,6 +26,13 @@ const CATEGORY_LABELS: Record = { metro_stop: "Метро", }; +const SOURCE_TYPE_LABELS: Record = { + highway: "Магистраль", + railway: "Ж/д", + industrial: "Производство", + aerodrome: "Аэродром", +}; + function scoreColor(score: number): string { if (score > 5) return "#16a34a"; if (score >= 2) return "#d97706"; @@ -31,6 +44,289 @@ function avgDist(items: Array<{ distance_m: number }>): number { return Math.round(items.reduce((s, i) => s + i.distance_m, 0) / items.length); } +function noiseBg(score: number): string { + if (score >= 0.7) return "#dcfce7"; + if (score >= 0.4) return "#fef3c7"; + return "#fecaca"; +} + +function pm25Color(v: number): string { + if (v < 10) return "#16a34a"; + if (v < 25) return "#d97706"; + if (v < 50) return "#ea580c"; + return "#dc2626"; +} + +function pm10Color(v: number): string { + if (v < 20) return "#16a34a"; + if (v < 50) return "#d97706"; + if (v < 100) return "#ea580c"; + return "#dc2626"; +} + +function no2Color(v: number): string { + if (v < 40) return "#16a34a"; + if (v < 100) return "#d97706"; + return "#dc2626"; +} + +function formatTs(iso: string): string { + try { + return new Date(iso).toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +// ── Noise block ─────────────────────────────────────────────────────────────── + +function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) { + const [open, setOpen] = useState(false); + const top3 = noise.sources.slice(0, 3); + + return ( +
+
Шум
+
+ ~{Math.round(noise.estimated_db)} dB +
+
{noise.level}
+ + {top3.length > 0 && ( +
+ + {open && ( +
+ {top3.map((s, i) => ( +
+ {SOURCE_TYPE_LABELS[s.source_type] ?? s.source_type} + {s.name ? ` «${s.name}»` : ""} — {Math.round(s.distance_m)} м + ({Math.round(s.estimated_db)} dB) +
+ ))} +
+ )} +
+ )} +
+ ); +} + +// ── Air Quality block ───────────────────────────────────────────────────────── + +function AirQualityBlock({ aq }: { aq: ParcelAnalysisAirQuality | null }) { + if (!aq) { + return ( +
+
+ Воздух +
+
Данные недоступны
+
+ ); + } + + return ( +
+
+ Воздух +
+
+ + + +
+
+ {aq.source} · {formatTs(aq.ts)} +
+
+ ); +} + +function AqBadge({ + label, + value, + unit, + color, +}: { + label: string; + value: number; + unit: string; + color: string; +}) { + return ( +
+
{label}
+
+ {value.toFixed(1)} +
+
{unit}
+
+ ); +} + +// ── Wind block ──────────────────────────────────────────────────────────────── + +function WindArrow({ deg }: { deg: number }) { + // Arrow points "up" by default (north). Rotate to show wind coming FROM that direction. + return ( + + {/* compass ring */} + + {/* arrow group rotated to dominant_direction_deg */} + + {/* shaft */} + + {/* arrowhead */} + + {/* tail notch */} + + + + ); +} + +function WindBlock({ wind }: { wind: ParcelAnalysisWind | null }) { + if (!wind) { + return ( +
+
+ Ветер +
+
Данные недоступны
+
+ ); + } + + return ( +
+
+ Ветер +
+ +
+ {wind.dominant_direction_label} + {wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""} +
+
+ за {wind.forecast_days} дн. +
+
+ ); +} + +// ── ScoreCard ───────────────────────────────────────────────────────────────── + export function ScoreCard({ data }: Props) { const tramPois = data.score_breakdown["tram_stop"] ?? []; const nearestTram = @@ -38,6 +334,11 @@ export function ScoreCard({ data }: Props) { ? Math.round(Math.min(...tramPois.map((p) => p.distance_m))) : null; + const hasEnvironmental = + data.noise !== undefined || + data.air_quality !== undefined || + data.wind !== undefined; + return (
)}
+ + {/* Environmental factors */} + {hasEnvironmental && ( +
+
+ Внешние факторы +
+
+ {data.noise !== undefined ? ( + data.noise !== null ? ( + + ) : ( +
+
+ Шум +
+
+ Данные недоступны +
+
+ ) + ) : null} + + + + +
+
+ )} ); } diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 4ecf1e96..916b7bc9 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -1,5 +1,36 @@ import type { Geometry } from "geojson"; +export interface ParcelAnalysisNoiseSource { + source_type: "highway" | "railway" | "industrial" | "aerodrome"; + road_class: string | null; + name: string | null; + distance_m: number; + estimated_db: number; +} + +export interface ParcelAnalysisNoise { + score: number; + estimated_db: number; + level: "тихо" | "умеренный" | "шумно"; + sources: ParcelAnalysisNoiseSource[]; +} + +export interface ParcelAnalysisAirQuality { + pm2_5: number; + pm10: number; + no2: number; + ts: string; + source: string; +} + +export interface ParcelAnalysisWind { + dominant_direction_deg: number; + dominant_direction_label: string; + max_speed_m_s: number | null; + forecast_days: number; + source: string; +} + export interface ParcelAnalysisCompetitor { obj_id: number; comm_name: string | null; @@ -33,6 +64,9 @@ export interface ParcelAnalysis { score_breakdown: Record; poi_count: number; competitors: ParcelAnalysisCompetitor[]; + noise: ParcelAnalysisNoise | null; + air_quality: ParcelAnalysisAirQuality | null; + wind: ParcelAnalysisWind | null; } export type PoiCategory =