feat(site-finder): v3.2 — noise + air quality + wind analytics
This commit is contained in:
parent
a2e8a49e6d
commit
1e9d32ee3c
7 changed files with 853 additions and 1 deletions
|
|
@ -367,6 +367,23 @@ def list_logs(
|
||||||
# ── Objective ETL (Антоновский SQLite → PG) ─────────────────────────────────
|
# ── 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")
|
@router.post("/poi-sync")
|
||||||
def trigger_poi_sync(
|
def trigger_poi_sync(
|
||||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -13,6 +15,99 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
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-категорий для scoring (Максим: трамвай = минус)
|
||||||
_POI_WEIGHTS: dict[str, float] = {
|
_POI_WEIGHTS: dict[str, float] = {
|
||||||
"school": 1.5,
|
"school": 1.5,
|
||||||
|
|
@ -224,6 +319,81 @@ def analyze_parcel(
|
||||||
.all()
|
.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 {
|
return {
|
||||||
"cad_num": cad_num,
|
"cad_num": cad_num,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
|
@ -233,4 +403,12 @@ def analyze_parcel(
|
||||||
"score_breakdown": by_category,
|
"score_breakdown": by_category,
|
||||||
"poi_count": len(poi_rows),
|
"poi_count": len(poi_rows),
|
||||||
"competitors": [dict(c) for c in competitor_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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
205
backend/app/services/site_finder/noise_loader.py
Normal file
205
backend/app/services/site_finder/noise_loader.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -54,6 +54,7 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.objective_etl",
|
"app.workers.tasks.objective_etl",
|
||||||
"app.workers.tasks.nspd_geo",
|
"app.workers.tasks.nspd_geo",
|
||||||
"app.workers.tasks.poi_sync",
|
"app.workers.tasks.poi_sync",
|
||||||
|
"app.workers.tasks.noise_sync",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
celery_app.conf.timezone = "Europe/Moscow"
|
celery_app.conf.timezone = "Europe/Moscow"
|
||||||
|
|
@ -220,6 +221,14 @@ def _build_beat_schedule() -> dict:
|
||||||
"options": {"queue": "celery"},
|
"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
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
51
backend/app/workers/tasks/noise_sync.py
Normal file
51
backend/app/workers/tasks/noise_sync.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
"use client";
|
"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 {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
|
|
@ -20,6 +26,13 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||||
metro_stop: "Метро",
|
metro_stop: "Метро",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
highway: "Магистраль",
|
||||||
|
railway: "Ж/д",
|
||||||
|
industrial: "Производство",
|
||||||
|
aerodrome: "Аэродром",
|
||||||
|
};
|
||||||
|
|
||||||
function scoreColor(score: number): string {
|
function scoreColor(score: number): string {
|
||||||
if (score > 5) return "#16a34a";
|
if (score > 5) return "#16a34a";
|
||||||
if (score >= 2) return "#d97706";
|
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);
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: noiseBg(noise.score),
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>Шум</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "#111827",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
~{Math.round(noise.estimated_db)} dB
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#374151" }}>{noise.level}</div>
|
||||||
|
|
||||||
|
{top3.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((p) => !p)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#6b7280",
|
||||||
|
cursor: "pointer",
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{open ? "Скрыть источники" : `Источники (${noise.sources.length})`}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{top3.map((s, i) => (
|
||||||
|
<div key={i} style={{ fontSize: 12, color: "#374151" }}>
|
||||||
|
{SOURCE_TYPE_LABELS[s.source_type] ?? s.source_type}
|
||||||
|
{s.name ? ` «${s.name}»` : ""} — {Math.round(s.distance_m)} м
|
||||||
|
({Math.round(s.estimated_db)} dB)
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Air Quality block ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AirQualityBlock({ aq }: { aq: ParcelAnalysisAirQuality | null }) {
|
||||||
|
if (!aq) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Воздух
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#9ca3af" }}>Данные недоступны</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f0fdf4",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Воздух
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<AqBadge
|
||||||
|
label="PM2.5"
|
||||||
|
value={aq.pm2_5}
|
||||||
|
unit="мкг/м³"
|
||||||
|
color={pm25Color(aq.pm2_5)}
|
||||||
|
/>
|
||||||
|
<AqBadge
|
||||||
|
label="PM10"
|
||||||
|
value={aq.pm10}
|
||||||
|
unit="мкг/м³"
|
||||||
|
color={pm10Color(aq.pm10)}
|
||||||
|
/>
|
||||||
|
<AqBadge
|
||||||
|
label="NO₂"
|
||||||
|
value={aq.no2}
|
||||||
|
unit="мкг/м³"
|
||||||
|
color={no2Color(aq.no2)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
||||||
|
{aq.source} · {formatTs(aq.ts)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AqBadge({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "6px 10px",
|
||||||
|
background: `${color}18`,
|
||||||
|
border: `1px solid ${color}55`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
minWidth: 62,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 700, color, lineHeight: 1.2 }}>
|
||||||
|
{value.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: "#9ca3af" }}>{unit}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wind block ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WindArrow({ deg }: { deg: number }) {
|
||||||
|
// Arrow points "up" by default (north). Rotate to show wind coming FROM that direction.
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="52"
|
||||||
|
height="52"
|
||||||
|
viewBox="0 0 52 52"
|
||||||
|
style={{ display: "block" }}
|
||||||
|
aria-label={`Направление ветра ${deg}°`}
|
||||||
|
>
|
||||||
|
{/* compass ring */}
|
||||||
|
<circle cx="26" cy="26" r="24" fill="#e5e7eb" />
|
||||||
|
{/* arrow group rotated to dominant_direction_deg */}
|
||||||
|
<g transform={`rotate(${deg}, 26, 26)`}>
|
||||||
|
{/* shaft */}
|
||||||
|
<rect x="24.5" y="10" width="3" height="24" rx="1.5" fill="#374151" />
|
||||||
|
{/* arrowhead */}
|
||||||
|
<polygon points="26,6 21,16 31,16" fill="#374151" />
|
||||||
|
{/* tail notch */}
|
||||||
|
<polygon points="26,34 22,44 30,44" fill="#9ca3af" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WindBlock({ wind }: { wind: ParcelAnalysisWind | null }) {
|
||||||
|
if (!wind) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Ветер
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#9ca3af" }}>Данные недоступны</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#eff6ff",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Ветер
|
||||||
|
</div>
|
||||||
|
<WindArrow deg={wind.dominant_direction_deg} />
|
||||||
|
<div style={{ fontSize: 13, color: "#374151" }}>
|
||||||
|
{wind.dominant_direction_label}
|
||||||
|
{wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
||||||
|
за {wind.forecast_days} дн.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ScoreCard ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ScoreCard({ data }: Props) {
|
export function ScoreCard({ data }: Props) {
|
||||||
const tramPois = data.score_breakdown["tram_stop"] ?? [];
|
const tramPois = data.score_breakdown["tram_stop"] ?? [];
|
||||||
const nearestTram =
|
const nearestTram =
|
||||||
|
|
@ -38,6 +334,11 @@ export function ScoreCard({ data }: Props) {
|
||||||
? Math.round(Math.min(...tramPois.map((p) => p.distance_m)))
|
? Math.round(Math.min(...tramPois.map((p) => p.distance_m)))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const hasEnvironmental =
|
||||||
|
data.noise !== undefined ||
|
||||||
|
data.air_quality !== undefined ||
|
||||||
|
data.wind !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -159,6 +460,63 @@ export function ScoreCard({ data }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Environmental factors */}
|
||||||
|
{hasEnvironmental && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 24px 16px",
|
||||||
|
borderTop: "1px solid #e5e7eb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Внешние факторы
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(3, 1fr)",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.noise !== undefined ? (
|
||||||
|
data.noise !== null ? (
|
||||||
|
<NoiseBlock noise={data.noise} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}
|
||||||
|
>
|
||||||
|
Шум
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#9ca3af", marginTop: 8 }}>
|
||||||
|
Данные недоступны
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AirQualityBlock aq={data.air_quality ?? null} />
|
||||||
|
|
||||||
|
<WindBlock wind={data.wind ?? null} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,36 @@
|
||||||
import type { Geometry } from "geojson";
|
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 {
|
export interface ParcelAnalysisCompetitor {
|
||||||
obj_id: number;
|
obj_id: number;
|
||||||
comm_name: string | null;
|
comm_name: string | null;
|
||||||
|
|
@ -33,6 +64,9 @@ export interface ParcelAnalysis {
|
||||||
score_breakdown: Record<string, ParcelAnalysisPoi[]>;
|
score_breakdown: Record<string, ParcelAnalysisPoi[]>;
|
||||||
poi_count: number;
|
poi_count: number;
|
||||||
competitors: ParcelAnalysisCompetitor[];
|
competitors: ParcelAnalysisCompetitor[];
|
||||||
|
noise: ParcelAnalysisNoise | null;
|
||||||
|
air_quality: ParcelAnalysisAirQuality | null;
|
||||||
|
wind: ParcelAnalysisWind | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PoiCategory =
|
export type PoiCategory =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue