gendesign/backend/app/services/site_finder/noise_loader.py
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

307 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Загрузчик 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),
# Гидрология — реки, ручьи, каналы (way-линии) и водоёмы (way-полигоны)
("waterway", "river", "water", "river"),
("waterway", "stream", "water", "stream"),
("waterway", "canal", "water", "canal"),
("natural", "water", "water", "lake_or_pond"),
# Инженерные сети — магистральные линии (для proximity-проверки доступности
# подключения / распознавания ЛЭП-охранных зон через ЗОУИТ типа 5/9)
("power", "line", "utility", "power_line"),
("power", "minor_line", "utility", "power_line"),
("man_made", "pipeline", "utility", "pipeline"),
# Магистральные трубопроводы по substance (way) — газ / вода / канализация (#44).
# OSM tag `pipeline` (не `man_made=pipeline`): pipeline=substance.
("pipeline", "gas", "utility", "gas_pipeline"),
("pipeline", "water", "utility", "water_main"),
("pipeline", "sewerage", "utility", "sewerage"),
# Сети ВКХ (водоснабжение / канализация) — узлы, не магистрали
("man_made", "water_works", "utility", "water_works"),
("man_made", "wastewater_plant", "utility", "wastewater_plant"),
("man_made", "water_tower", "utility", "water_tower"),
("man_made", "storage_tank", "utility", "storage_tank"),
# Электроподстанции / трансформаторы — точки подключения 10/35/110 кВ (#44)
("power", "substation", "utility", "substation"),
("power", "transformer", "utility", "transformer"),
# ЦТП (центральный тепловой пункт) — точка подключения теплосети (#44)
("man_made", "heat_substation", "utility", "heat_substation"),
]
# Инж.-сетевые точки подключения (#44), которые в OSM маппят и как node, и как
# area (closed way): подстанции / трансформаторы / ЦТП / водозабор и т.п.
# Для них спрашиваем nwr (node + way) с `out geom`, чтобы поймать оба варианта
# маппинга и получить координаты узлов для way-площадок.
_UTILITY_POINT_VALUES: frozenset[str] = frozenset(
{
"substation",
"transformer",
"heat_substation",
"water_works",
"wastewater_plant",
"water_tower",
"storage_tank",
}
)
def _build_overpass_query(key: str, value: str, el_type: str) -> str:
"""Запрос Overpass для одного типа элемента.
Для way / nwr используем `out geom` — получаем полные координаты узлов
(нужно для построения LineString / Polygon геометрии way-площадок).
Для node — `out body`.
"""
south, west, north, east = EKB_BBOX
bbox = f"({south},{west},{north},{east})"
if el_type == "nwr":
# node + way: точки подключения бывают и точкой, и площадкой
return f"[out:json][timeout:30];" f'nwr["{key}"="{value}"]{bbox};' f"out geom;"
if el_type == "way":
return f"[out:json][timeout:30];" f'way["{key}"="{value}"]{bbox};' f"out geom;"
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 — nwr (node+way): в OSM аэродромы/аэропорты (Кольцово,
# IATA SVX) почти всегда замаплены как way-полигон, а не одиночный
# node. Запрос только по node возвращал ~0 элементов → авиашум молча
# терялся в скоринге (#1350). nwr ловит оба варианта; way-полигон
# граница уходит в _way_to_linestring_wkt и корректно участвует в
# ST_Distance-проверках. Точки подключения инж.сетей — тоже nwr;
# остальное (дороги/ж-д/трубы/ЛЭП-линии) — way.
if key == "aeroway":
el_type = "nwr"
elif value in _UTILITY_POINT_VALUES:
el_type = "nwr"
else:
el_type = "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
# WKT LINESTRING требует запятую между парами точек: "lon1 lat1, lon2 lat2".
# Раньше joined через пробел → "lon1 lat1 lon2 lat2" → invalid geometry.
points = [f"{n['lon']} {n['lat']}" for n in nodes if "lon" in n and "lat" in n]
if len(points) < 2:
return None
return f"LINESTRING({', '.join(points)})"
def _way_to_polygon_wkt(nodes: list[dict]) -> str | None:
"""Построить WKT Polygon из замкнутого way Overpass (out geom).
Используется для natural=water (озёра, пруды). Если кольцо не замкнуто
автоматически — добавляем первую точку в конец.
PostGIS отвергает linear ring < 4 точек. Замкнутый way из 3 точек
[A, B, A] (points[0]==points[-1]) даёт POLYGON((A,B,A)) — "geometry
requires more points". Возвращаем None, чтобы caller сделал fallback
на LineString или пропустил элемент.
"""
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])
# Итоговое кольцо должно содержать ≥4 точек (PostGIS требование).
if len(points) < 4:
return None
return f"POLYGON(({', '.join(points)}))"
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 []
# 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
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,
}
try:
with db.begin_nested(): # SAVEPOINT — откат только этой записи
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
except Exception as e:
# Дефектный way (например, 3-точечное замкнутое кольцо POLYGON((A,B,A))
# отвергается PostGIS) не должен валить весь weekly-sync. SAVEPOINT
# откатывает только эту строку, продолжаем с остальными.
logger.warning(
"noise_sync insert failed for %s/%s (source_type=%s): %s",
osm_type,
osm_id,
source_type,
e,
)
skipped += 1
db.commit()
except Exception as e:
db.rollback()
logger.exception("noise_sync: unexpected error, outer tx rolled back: %s", e)
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