This commit is contained in:
parent
4fe20a9cbd
commit
3753079dee
10 changed files with 1094 additions and 0 deletions
|
|
@ -35,6 +35,7 @@ from app.schemas.parcel import (
|
||||||
ParcelSearchResponse,
|
ParcelSearchResponse,
|
||||||
RedLine,
|
RedLine,
|
||||||
RiskZone,
|
RiskZone,
|
||||||
|
UtilityInfrastructureResponse,
|
||||||
)
|
)
|
||||||
from app.services.analysis_runs.repository import (
|
from app.services.analysis_runs.repository import (
|
||||||
ANALYZE_SCHEMA_VERSION,
|
ANALYZE_SCHEMA_VERSION,
|
||||||
|
|
@ -82,6 +83,9 @@ from app.services.site_finder.quarter_dump_lookup import (
|
||||||
)
|
)
|
||||||
from app.services.site_finder.riasurt_lookup import parcel_riasurt_gate
|
from app.services.site_finder.riasurt_lookup import parcel_riasurt_gate
|
||||||
from app.services.site_finder.saturation import compute_district_saturation
|
from app.services.site_finder.saturation import compute_district_saturation
|
||||||
|
from app.services.site_finder.utility_infrastructure_loader import (
|
||||||
|
get_nearby_utility_infrastructure,
|
||||||
|
)
|
||||||
from app.services.site_finder.velocity import compute_velocity
|
from app.services.site_finder.velocity import compute_velocity
|
||||||
from app.services.site_finder.weight_profiles import (
|
from app.services.site_finder.weight_profiles import (
|
||||||
_SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
|
_SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
|
||||||
|
|
@ -3435,6 +3439,38 @@ def get_parcel_connection_points(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{cad_num}/utility-infrastructure",
|
||||||
|
response_model=UtilityInfrastructureResponse,
|
||||||
|
summary="Инженерные сети из OSM вблизи участка (#1746)",
|
||||||
|
)
|
||||||
|
def get_parcel_utility_infrastructure(
|
||||||
|
cad_num: str,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
radius_m: Annotated[int, Query(ge=50, le=2000)] = 500,
|
||||||
|
) -> UtilityInfrastructureResponse:
|
||||||
|
"""Инженерные сети (ЛЭП/подстанции/трубопроводы/ЦТП/вышки) из OSM вблизи участка.
|
||||||
|
|
||||||
|
No-B2B open-data источник: osm_utility_infrastructure_ekb (наполняется weekly-sync
|
||||||
|
из Overpass). Возвращает фичи в радиусе от центроида участка (ST_DWithin) +
|
||||||
|
summary с ближайшими расстояниями по видам сети.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
radius_m: радиус поиска, 50–2000 м (default 500).
|
||||||
|
|
||||||
|
Если участок не найден в БД (нет geom) → 404.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = get_nearby_utility_infrastructure(db, cad_num, radius_m)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return UtilityInfrastructureResponse(
|
||||||
|
features=data["features"],
|
||||||
|
summary=data["summary"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_ORS_BASE = "https://api.openrouteservice.org/v2/isochrones"
|
_ORS_BASE = "https://api.openrouteservice.org/v2/isochrones"
|
||||||
_ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"})
|
_ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,34 @@ class ConnectionPointsResponse(BaseModel):
|
||||||
dump_fetched_at: str | None
|
dump_fetched_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
# ── OSM engineering-networks schemas (#1746) ─────────────────────────────────
|
||||||
|
# No-B2B open-data слой инженерных сетей из OSM (Overpass): power/water/gas/heat/
|
||||||
|
# communication/sewage. Источник — osm_utility_infrastructure_ekb.
|
||||||
|
|
||||||
|
|
||||||
|
class UtilityInfrastructureFeature(BaseModel):
|
||||||
|
osm_id: int
|
||||||
|
osm_type: str
|
||||||
|
# 'power' | 'water' | 'gas' | 'heat' | 'communication' | 'sewage'
|
||||||
|
infrastructure_kind: str
|
||||||
|
name: str | None
|
||||||
|
source_tag: str | None
|
||||||
|
distance_m: float
|
||||||
|
geometry_geojson: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class UtilityInfrastructureSummary(BaseModel):
|
||||||
|
total_features: int
|
||||||
|
nearest_distance_m: float | None
|
||||||
|
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
|
||||||
|
nearest_by_kind: dict[str, float | None]
|
||||||
|
|
||||||
|
|
||||||
|
class UtilityInfrastructureResponse(BaseModel):
|
||||||
|
features: list[UtilityInfrastructureFeature]
|
||||||
|
summary: UtilityInfrastructureSummary
|
||||||
|
|
||||||
|
|
||||||
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
|
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,390 @@
|
||||||
|
"""Загрузчик OSM инженерных сетей («инженерные сети») из Overpass API (#1746).
|
||||||
|
|
||||||
|
No-B2B open-data путь: тянет ЛЭП / подстанции / трубопроводы (газ/вода) / ЦТП /
|
||||||
|
вышки связи через Overpass для bbox ЕКБ и UPSERT-ит в
|
||||||
|
``osm_utility_infrastructure_ekb``. Живой тест дал 3081+ utility-элементов ЕКБ.
|
||||||
|
|
||||||
|
Структура зеркалит ``noise_loader.py``: httpx-клиент с явным UA + таймаутом,
|
||||||
|
rate-limit ~1 req/s (Overpass usage policy), геометрия way → LineString /
|
||||||
|
node → Point, per-element SAVEPOINT при UPSERT (битый элемент не валит батч).
|
||||||
|
|
||||||
|
Запускается раз в неделю через Celery beat. Геометрия включает И линии (ЛЭП,
|
||||||
|
трубопроводы — way), И точки (опоры, подстанции, узлы ВКХ, ЦТП, вышки — node).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.site_finder.noise_loader import (
|
||||||
|
EKB_BBOX,
|
||||||
|
_way_to_linestring_wkt,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||||
|
|
||||||
|
# Overpass блокирует дефолтный User-Agent (406) — явный UA с контактом (как noise_loader).
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Описание запросов: (overpass_key, overpass_value, el_type, infrastructure_kind).
|
||||||
|
# el_type: 'way' (out geom) | 'node' (out body) | 'nwr' (out geom, node+way).
|
||||||
|
# substance: для pipeline-веток газ/вода дополнительно фильтруем по substance,
|
||||||
|
# чтобы не смешивать водопровод с газопроводом — см. _SUBSTANCE_FILTER.
|
||||||
|
# Виды сети (infrastructure_kind):
|
||||||
|
# power | water | gas | heat | communication | sewage
|
||||||
|
_UTILITY_QUERIES: list[tuple[str, str, str, str]] = [
|
||||||
|
# power — магистральные ЛЭП (линии) + опоры (точки) + подстанции (точка/площадка)
|
||||||
|
("power", "line", "way", "power"),
|
||||||
|
("power", "minor_line", "way", "power"),
|
||||||
|
("power", "tower", "node", "power"),
|
||||||
|
("power", "substation", "nwr", "power"),
|
||||||
|
# water — водозаборы/узлы ВКХ (точка/площадка) + магистраль pipeline substance=water
|
||||||
|
("man_made", "water_works", "nwr", "water"),
|
||||||
|
("man_made", "water_tower", "nwr", "water"),
|
||||||
|
("pipeline", "water", "way", "water"),
|
||||||
|
# gas — магистральные газопроводы (way), pipeline substance=gas
|
||||||
|
("pipeline", "gas", "way", "gas"),
|
||||||
|
("man_made", "pipeline", "way", "gas"), # substance=gas фильтруется ниже
|
||||||
|
# heat — ЦТП / теплопункты (sparse — это нормально)
|
||||||
|
("man_made", "heat_substation", "nwr", "heat"),
|
||||||
|
# communication — вышки связи (точка/площадка) tower:type=communication
|
||||||
|
("man_made", "tower", "nwr", "communication"),
|
||||||
|
# sewage — магистральная канализация (pipeline substance=sewerage) + очистные
|
||||||
|
("pipeline", "sewerage", "way", "sewage"),
|
||||||
|
("man_made", "wastewater_plant", "nwr", "sewage"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Для generic man_made=pipeline нужно различать вещество по тегу `substance`
|
||||||
|
# (или legacy `type`): иначе под газ попадут все трубы. Ключ — (key, value),
|
||||||
|
# значение — ожидаемый substance. Элементы без совпадения substance пропускаем.
|
||||||
|
_SUBSTANCE_FILTER: dict[tuple[str, str], frozenset[str]] = {
|
||||||
|
("man_made", "pipeline"): frozenset({"gas", "natural_gas", "cng"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
# tower:type=communication — для man_made=tower оставляем только вышки связи
|
||||||
|
# (иначе под communication попадут смотровые/водонапорные башни).
|
||||||
|
_TOWER_TYPE_REQUIRED: dict[tuple[str, str], frozenset[str]] = {
|
||||||
|
("man_made", "tower"): frozenset({"communication", "communications"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_overpass_query(key: str, value: str, el_type: str) -> str:
|
||||||
|
"""Запрос Overpass для одного типа инж.-сетевого элемента.
|
||||||
|
|
||||||
|
way / nwr → ``out geom`` (нужны координаты узлов для LineString / way-площадок);
|
||||||
|
node → ``out body``. Зеркалит noise_loader._build_overpass_query.
|
||||||
|
"""
|
||||||
|
south, west, north, east = EKB_BBOX
|
||||||
|
bbox = f"({south},{west},{north},{east})"
|
||||||
|
if el_type == "nwr":
|
||||||
|
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;"
|
||||||
|
|
||||||
|
|
||||||
|
def _passes_tag_filters(key: str, value: str, tags: dict) -> bool:
|
||||||
|
"""Доп-фильтр по тегам элемента (substance / tower:type).
|
||||||
|
|
||||||
|
Для generic ``man_made=pipeline`` оставляем только газовые трубы (substance),
|
||||||
|
для ``man_made=tower`` — только вышки связи (tower:type). Остальные ключи
|
||||||
|
проходят без фильтра.
|
||||||
|
"""
|
||||||
|
substance_ok = _SUBSTANCE_FILTER.get((key, value))
|
||||||
|
if substance_ok is not None:
|
||||||
|
substance = (tags.get("substance") or tags.get("type") or "").lower()
|
||||||
|
if substance not in substance_ok:
|
||||||
|
return False
|
||||||
|
tower_ok = _TOWER_TYPE_REQUIRED.get((key, value))
|
||||||
|
if tower_ok is not None:
|
||||||
|
tower_type = (tags.get("tower:type") or "").lower()
|
||||||
|
if tower_type not in tower_ok:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_overpass_utility() -> list[dict]:
|
||||||
|
"""Запрашивает Overpass API для всех инж.-сетевых типов ЕКБ.
|
||||||
|
|
||||||
|
Возвращает enriched list dict'ов с доп-полями:
|
||||||
|
``_infrastructure_kind``, ``_source_tag`` — для UPSERT.
|
||||||
|
Между запросами sleep 1с (Overpass usage policy), как в noise_loader.
|
||||||
|
"""
|
||||||
|
all_elements: list[dict] = []
|
||||||
|
async with httpx.AsyncClient(timeout=60, headers=_HEADERS) as client:
|
||||||
|
for key, value, el_type, kind in _UTILITY_QUERIES:
|
||||||
|
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 utility: %s=%s (%s) → %d elements",
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
kind,
|
||||||
|
len(elements),
|
||||||
|
)
|
||||||
|
for el in elements:
|
||||||
|
el["_infrastructure_kind"] = kind
|
||||||
|
el["_source_tag"] = f"{key}={value}"
|
||||||
|
el["_query_key"] = key
|
||||||
|
el["_query_value"] = value
|
||||||
|
all_elements.extend(elements)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Overpass utility failed for %s=%s: %s", key, value, e)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
logger.info("Overpass utility: total %d elements", len(all_elements))
|
||||||
|
return all_elements
|
||||||
|
|
||||||
|
|
||||||
|
def sync_utility_infrastructure_to_db() -> dict[str, int]:
|
||||||
|
"""UPSERT инж.сетей из Overpass в osm_utility_infrastructure_ekb.
|
||||||
|
|
||||||
|
UPSERT по UNIQUE(osm_type, osm_id). Геометрия:
|
||||||
|
- way с nodes → LineString WKT через ST_GeomFromText(..., 4326)
|
||||||
|
- node → ST_SetSRID(ST_MakePoint(lon, lat), 4326)
|
||||||
|
Per-element SAVEPOINT — битый элемент не валит весь weekly-sync.
|
||||||
|
|
||||||
|
Returns: счётчики fetched/inserted/updated/skipped + per-kind counts.
|
||||||
|
"""
|
||||||
|
elements = asyncio.run(fetch_overpass_utility())
|
||||||
|
return _upsert_elements(elements)
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_elements(elements: list[dict]) -> dict[str, int]:
|
||||||
|
"""UPSERT уже полученных Overpass-элементов в таблицу (выделено для тестов).
|
||||||
|
|
||||||
|
Принимает enriched-элементы (с ``_infrastructure_kind`` / ``_source_tag``),
|
||||||
|
строит геометрию и UPSERT-ит под per-element SAVEPOINT. Возвращает счётчики.
|
||||||
|
"""
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
fetched = len(elements)
|
||||||
|
by_kind: dict[str, int] = {}
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for el in elements:
|
||||||
|
key: str = el.get("_query_key", "")
|
||||||
|
value: str = el.get("_query_value", "")
|
||||||
|
tags: dict = el.get("tags") or {}
|
||||||
|
|
||||||
|
# substance / tower:type фильтры (generic man_made=pipeline / tower).
|
||||||
|
if not _passes_tag_filters(key, value, tags):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
osm_id: int = el["id"]
|
||||||
|
osm_type: str = el["type"] # 'way' | 'node'
|
||||||
|
kind: str = el.get("_infrastructure_kind", "power")
|
||||||
|
source_tag: str | None = el.get("_source_tag")
|
||||||
|
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:
|
||||||
|
# way-площадка (закрытый контур) тоже даёт LineString из geometry —
|
||||||
|
# если узлов <2, пропускаем (битый/частичный bbox-обрезок).
|
||||||
|
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,
|
||||||
|
"infrastructure_kind": kind,
|
||||||
|
"name": name,
|
||||||
|
"source_tag": source_tag,
|
||||||
|
"tags": json.dumps(tags, ensure_ascii=False),
|
||||||
|
**geom_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.begin_nested(): # SAVEPOINT — откат только этой записи
|
||||||
|
result = db.execute(
|
||||||
|
text(f"""
|
||||||
|
INSERT INTO osm_utility_infrastructure_ekb
|
||||||
|
(osm_type, osm_id, infrastructure_kind, name, geom,
|
||||||
|
source_tag, tags, fetched_at)
|
||||||
|
VALUES (
|
||||||
|
:osm_type, :osm_id, :infrastructure_kind, :name,
|
||||||
|
{geom_sql},
|
||||||
|
:source_tag, CAST(:tags AS jsonb), NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (osm_type, osm_id) DO UPDATE
|
||||||
|
SET infrastructure_kind = EXCLUDED.infrastructure_kind,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
geom = EXCLUDED.geom,
|
||||||
|
source_tag = EXCLUDED.source_tag,
|
||||||
|
tags = EXCLUDED.tags,
|
||||||
|
fetched_at = NOW()
|
||||||
|
RETURNING (xmax = 0) AS is_insert
|
||||||
|
"""),
|
||||||
|
params,
|
||||||
|
).scalar()
|
||||||
|
if result:
|
||||||
|
inserted += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
by_kind[kind] = by_kind.get(kind, 0) + 1
|
||||||
|
except Exception as e:
|
||||||
|
# Дефектный элемент (битая геометрия и т.п.) не должен валить
|
||||||
|
# весь weekly-sync. SAVEPOINT откатывает только эту строку.
|
||||||
|
logger.warning(
|
||||||
|
"utility_sync upsert failed for %s/%s (kind=%s): %s",
|
||||||
|
osm_type,
|
||||||
|
osm_id,
|
||||||
|
kind,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("utility_sync: unexpected error, outer tx rolled back: %s", e)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
result_dict: dict[str, int] = {
|
||||||
|
"fetched": fetched,
|
||||||
|
"inserted": inserted,
|
||||||
|
"updated": updated,
|
||||||
|
"skipped": skipped,
|
||||||
|
}
|
||||||
|
# per-kind counts (power/water/gas/heat/communication/sewage) — для отчёта populate.
|
||||||
|
for k, c in sorted(by_kind.items()):
|
||||||
|
result_dict[f"kind_{k}"] = c
|
||||||
|
logger.info("utility_sync done: %s", result_dict)
|
||||||
|
return result_dict
|
||||||
|
|
||||||
|
|
||||||
|
async def load_utility_infrastructure(db: Session | None = None) -> dict[str, int]:
|
||||||
|
"""Async-обёртка для прямого вызова из async-контекста (#1746 spec).
|
||||||
|
|
||||||
|
Тянет Overpass и UPSERT-ит в таблицу. ``db`` принимается для сигнатурной
|
||||||
|
совместимости со спекой, но UPSERT идёт через собственную SessionLocal
|
||||||
|
(как noise/poi sync) — Overpass-fetch async, запись в БД sync. Возвращает
|
||||||
|
те же per-kind counts, что ``sync_utility_infrastructure_to_db``.
|
||||||
|
"""
|
||||||
|
elements = await fetch_overpass_utility()
|
||||||
|
return _upsert_elements(elements)
|
||||||
|
|
||||||
|
|
||||||
|
def get_nearby_utility_infrastructure(
|
||||||
|
db: Session,
|
||||||
|
cad_num: str,
|
||||||
|
radius_m: int = 500,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> dict:
|
||||||
|
"""Инженерные сети из OSM в radius_m от центроида участка (#1746 endpoint).
|
||||||
|
|
||||||
|
Источник: ``osm_utility_infrastructure_ekb`` (наполняется weekly-sync из
|
||||||
|
Overpass). Расстояние считается от ЦЕНТРОИДА участка через ST_DWithin /
|
||||||
|
ST_Distance в geography. Возвращает feature-list + summary с ближайшими
|
||||||
|
расстояниями по видам сети.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: SQLAlchemy session.
|
||||||
|
cad_num: кадастровый номер участка (e.g. '66:41:0204016:10').
|
||||||
|
radius_m: радиус поиска, м (caller валидирует диапазон).
|
||||||
|
limit: максимум фич в ответе (ближайшие).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"features": [...], "summary": {...}}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: участок не найден в БД (нет geom).
|
||||||
|
"""
|
||||||
|
# Импорт здесь, чтобы избежать циклического импорта на module-load
|
||||||
|
# (quarter_dump_lookup → noise_loader → ... ).
|
||||||
|
from app.services.site_finder.quarter_dump_lookup import _get_parcel_wkt
|
||||||
|
|
||||||
|
parcel_wkt = _get_parcel_wkt(db, cad_num)
|
||||||
|
if parcel_wkt is None:
|
||||||
|
raise ValueError(f"Участок {cad_num!r} не найден в БД")
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT osm_id, osm_type, infrastructure_kind, name, source_tag,
|
||||||
|
ST_Distance(
|
||||||
|
u.geom::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||||
|
) AS distance_m,
|
||||||
|
ST_AsGeoJSON(u.geom) AS geojson
|
||||||
|
FROM osm_utility_infrastructure_ekb u
|
||||||
|
WHERE ST_DWithin(
|
||||||
|
u.geom::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
|
CAST(:radius_m AS float)
|
||||||
|
)
|
||||||
|
ORDER BY distance_m ASC
|
||||||
|
LIMIT :lim
|
||||||
|
"""),
|
||||||
|
{"wkt": parcel_wkt, "radius_m": radius_m, "lim": limit},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
features: list[dict] = []
|
||||||
|
nearest_by_kind: dict[str, float | None] = {}
|
||||||
|
nearest_overall: float | None = None
|
||||||
|
for r in rows:
|
||||||
|
dist = round(float(r["distance_m"]), 1)
|
||||||
|
kind = r["infrastructure_kind"]
|
||||||
|
# rows отсортированы по distance ASC → первое попадание вида = ближайшее.
|
||||||
|
if kind not in nearest_by_kind:
|
||||||
|
nearest_by_kind[kind] = dist
|
||||||
|
if nearest_overall is None:
|
||||||
|
nearest_overall = dist
|
||||||
|
features.append(
|
||||||
|
{
|
||||||
|
"osm_id": int(r["osm_id"]),
|
||||||
|
"osm_type": r["osm_type"],
|
||||||
|
"infrastructure_kind": kind,
|
||||||
|
"name": r["name"],
|
||||||
|
"source_tag": r["source_tag"],
|
||||||
|
"distance_m": dist,
|
||||||
|
"geometry_geojson": json.loads(r["geojson"]) if r["geojson"] else {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"features": features,
|
||||||
|
"summary": {
|
||||||
|
"total_features": len(features),
|
||||||
|
"nearest_distance_m": nearest_overall,
|
||||||
|
"nearest_by_kind": nearest_by_kind,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -274,6 +274,15 @@ def build_beat_schedule() -> dict:
|
||||||
"options": {"queue": "celery"},
|
"options": {"queue": "celery"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# OSM инженерные сети (#1746) sync — еженедельно в понедельник в 04:00 МСК
|
||||||
|
# (через 30 мин после noise-sync, чтобы не нагружать Overpass одновременно).
|
||||||
|
# No-B2B open-data источник (Overpass): ЛЭП/подстанции/трубопроводы/ЦТП/вышки.
|
||||||
|
schedule["utility-infrastructure-sync-weekly"] = {
|
||||||
|
"task": "tasks.utility_infrastructure_sync.sync_osm_utility_infrastructure_ekb",
|
||||||
|
"schedule": _parse_cron("0 4 * * mon"),
|
||||||
|
"options": {"queue": "celery"},
|
||||||
|
}
|
||||||
|
|
||||||
# #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК.
|
# #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК.
|
||||||
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
|
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
|
||||||
schedule["ekburg-permits-monthly"] = {
|
schedule["ekburg-permits-monthly"] = {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.nspd_sync",
|
"app.workers.tasks.nspd_sync",
|
||||||
"app.workers.tasks.poi_sync",
|
"app.workers.tasks.poi_sync",
|
||||||
"app.workers.tasks.noise_sync",
|
"app.workers.tasks.noise_sync",
|
||||||
|
"app.workers.tasks.utility_infrastructure_sync",
|
||||||
"app.workers.tasks.pzz_sync",
|
"app.workers.tasks.pzz_sync",
|
||||||
"app.workers.tasks.scrape_cadastre",
|
"app.workers.tasks.scrape_cadastre",
|
||||||
"app.workers.tasks.ekburg_permits_sync",
|
"app.workers.tasks.ekburg_permits_sync",
|
||||||
|
|
|
||||||
59
backend/app/workers/tasks/utility_infrastructure_sync.py
Normal file
59
backend/app/workers/tasks/utility_infrastructure_sync.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Celery task: еженедельная синхронизация OSM инженерных сетей для site-finder (#1746)."""
|
||||||
|
|
||||||
|
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='utility_sync').
|
||||||
|
|
||||||
|
Persistent breadcrumb для диагностики без SSH (как poi_sync / 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, 'utility_sync', :msg)"
|
||||||
|
),
|
||||||
|
{"lvl": level, "msg": message[:500]},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("utility_sync breadcrumb failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
name="tasks.utility_infrastructure_sync.sync_osm_utility_infrastructure_ekb",
|
||||||
|
queue="celery",
|
||||||
|
)
|
||||||
|
def sync_osm_utility_infrastructure_ekb() -> dict:
|
||||||
|
"""Еженедельная синхронизация OSM инж.сетей из Overpass в osm_utility_infrastructure_ekb.
|
||||||
|
|
||||||
|
Запускается через beat (понедельник 04:00 МСК — через 30 мин после noise-sync).
|
||||||
|
No-B2B open-data источник (Overpass). Persistent breadcrumbs в nspd_geo_log.
|
||||||
|
"""
|
||||||
|
_log_breadcrumb("info", "task started")
|
||||||
|
try:
|
||||||
|
from app.services.site_finder.utility_infrastructure_loader import (
|
||||||
|
sync_utility_infrastructure_to_db,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = sync_utility_infrastructure_to_db()
|
||||||
|
logger.info("utility_sync done: %s", result)
|
||||||
|
_log_breadcrumb("info", f"done: {result}")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("utility_sync failed: %s", e)
|
||||||
|
_log_breadcrumb("error", f"{type(e).__name__}: {e}")
|
||||||
|
raise
|
||||||
136
backend/tests/api/v1/test_parcel_utility_infrastructure.py
Normal file
136
backend/tests/api/v1/test_parcel_utility_infrastructure.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""Тесты для GET /{cad_num}/utility-infrastructure (#1746).
|
||||||
|
|
||||||
|
FastAPI TestClient + mock сервиса get_nearby_utility_infrastructure — без Postgres.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
_VALID_CAD = "66:41:0204016:10"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_feature(
|
||||||
|
osm_id: int = 1,
|
||||||
|
kind: str = "power",
|
||||||
|
distance_m: float = 120.5,
|
||||||
|
osm_type: str = "way",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"osm_id": osm_id,
|
||||||
|
"osm_type": osm_type,
|
||||||
|
"infrastructure_kind": kind,
|
||||||
|
"name": "ЛЭП-110",
|
||||||
|
"source_tag": "power=line",
|
||||||
|
"distance_m": distance_m,
|
||||||
|
"geometry_geojson": {"type": "LineString", "coordinates": [[60.6, 56.8], [60.61, 56.81]]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_response(
|
||||||
|
features: list[dict[str, Any]] | None = None,
|
||||||
|
nearest: float | None = 120.5,
|
||||||
|
nearest_by_kind: dict[str, float | None] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if features is None:
|
||||||
|
features = [_make_feature()]
|
||||||
|
if nearest_by_kind is None:
|
||||||
|
nearest_by_kind = {"power": 120.5}
|
||||||
|
return {
|
||||||
|
"features": features,
|
||||||
|
"summary": {
|
||||||
|
"total_features": len(features),
|
||||||
|
"nearest_distance_m": nearest,
|
||||||
|
"nearest_by_kind": nearest_by_kind,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_utility_infra_returns_features() -> None:
|
||||||
|
"""Нормальный ответ: фичи + summary с ближайшими по видам сети."""
|
||||||
|
full = _make_response(
|
||||||
|
features=[
|
||||||
|
_make_feature(1, "power", 80.0, "way"),
|
||||||
|
_make_feature(2, "water", 150.0, "node"),
|
||||||
|
],
|
||||||
|
nearest=80.0,
|
||||||
|
nearest_by_kind={"power": 80.0, "water": 150.0},
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.parcels.get_nearby_utility_infrastructure",
|
||||||
|
return_value=full,
|
||||||
|
):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/utility-infrastructure")
|
||||||
|
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["summary"]["total_features"] == 2
|
||||||
|
assert body["summary"]["nearest_distance_m"] == pytest.approx(80.0)
|
||||||
|
assert body["summary"]["nearest_by_kind"]["power"] == pytest.approx(80.0)
|
||||||
|
assert body["summary"]["nearest_by_kind"]["water"] == pytest.approx(150.0)
|
||||||
|
assert len(body["features"]) == 2
|
||||||
|
assert body["features"][0]["infrastructure_kind"] == "power"
|
||||||
|
assert body["features"][0]["geometry_geojson"]["type"] == "LineString"
|
||||||
|
|
||||||
|
|
||||||
|
def test_utility_infra_empty() -> None:
|
||||||
|
"""Нет сетей в радиусе → пустой список, total=0, nearest=null."""
|
||||||
|
empty = _make_response(features=[], nearest=None, nearest_by_kind={})
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.parcels.get_nearby_utility_infrastructure",
|
||||||
|
return_value=empty,
|
||||||
|
):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/utility-infrastructure")
|
||||||
|
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["features"] == []
|
||||||
|
assert body["summary"]["total_features"] == 0
|
||||||
|
assert body["summary"]["nearest_distance_m"] is None
|
||||||
|
assert body["summary"]["nearest_by_kind"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_utility_infra_parcel_not_found_404() -> None:
|
||||||
|
"""Участок не найден (ValueError из сервиса) → 404."""
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.parcels.get_nearby_utility_infrastructure",
|
||||||
|
side_effect=ValueError("Участок '66:41:9999999:1' не найден в БД"),
|
||||||
|
):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.get("/api/v1/parcels/66:41:9999999:1/utility-infrastructure")
|
||||||
|
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert "не найден" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_utility_infra_passes_radius() -> None:
|
||||||
|
"""radius_m из query передаётся в сервис."""
|
||||||
|
with patch("app.api.v1.parcels.get_nearby_utility_infrastructure") as mock_svc:
|
||||||
|
mock_svc.return_value = _make_response()
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/utility-infrastructure?radius_m=1000")
|
||||||
|
assert r.status_code == 200
|
||||||
|
call = mock_svc.call_args
|
||||||
|
assert call[0][2] == 1000 or call[1].get("radius_m") == 1000
|
||||||
|
|
||||||
|
|
||||||
|
def test_utility_infra_radius_out_of_range_422() -> None:
|
||||||
|
"""radius_m=10 (< min 50) → 422."""
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/utility-infrastructure?radius_m=10")
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_utility_infra_radius_too_large_422() -> None:
|
||||||
|
"""radius_m=5000 (> max 2000) → 422."""
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/utility-infrastructure?radius_m=5000")
|
||||||
|
assert r.status_code == 422
|
||||||
284
backend/tests/test_utility_infrastructure_loader.py
Normal file
284
backend/tests/test_utility_infrastructure_loader.py
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
"""Unit tests для utility_infrastructure_loader — OSM инженерные сети (#1746).
|
||||||
|
|
||||||
|
Mock-based / pure — без реального Overpass и БД. Покрывает:
|
||||||
|
- _build_overpass_query: node / way / nwr варианты + bbox ЕКБ
|
||||||
|
- _passes_tag_filters: substance-фильтр (man_made=pipeline) + tower:type (man_made=tower)
|
||||||
|
- _upsert_elements: парсинг way→LineString / node→Point, классификация kind,
|
||||||
|
per-element SAVEPOINT (битый элемент не валит батч), per-kind counts
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.site_finder import utility_infrastructure_loader as ul
|
||||||
|
|
||||||
|
# ── _build_overpass_query ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_node() -> None:
|
||||||
|
q = ul._build_overpass_query("power", "tower", "node")
|
||||||
|
assert q.startswith("[out:json][timeout:30];")
|
||||||
|
assert 'node["power"="tower"]' in q
|
||||||
|
assert "out body;" in q
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_way_uses_out_geom() -> None:
|
||||||
|
"""way → out geom (нужны координаты узлов для LineString ЛЭП/трубы)."""
|
||||||
|
q = ul._build_overpass_query("power", "line", "way")
|
||||||
|
assert 'way["power"="line"]' in q
|
||||||
|
assert "out geom;" in q
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_nwr_for_points() -> None:
|
||||||
|
"""nwr ловит и node, и way (точка/площадка подстанции), с out geom."""
|
||||||
|
q = ul._build_overpass_query("power", "substation", "nwr")
|
||||||
|
assert 'nwr["power"="substation"]' in q
|
||||||
|
assert "out geom;" in q
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_bbox_present() -> None:
|
||||||
|
"""Bbox ЕКБ присутствует во всех вариантах запроса."""
|
||||||
|
q = ul._build_overpass_query("pipeline", "gas", "way")
|
||||||
|
assert "(56.7,60.5,56.95,60.75)" in q
|
||||||
|
|
||||||
|
|
||||||
|
# ── _UTILITY_QUERIES coverage ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_required_kinds_present() -> None:
|
||||||
|
"""Все 6 видов сети покрыты запросами."""
|
||||||
|
kinds = {kind for _k, _v, _t, kind in ul._UTILITY_QUERIES}
|
||||||
|
for expected in ("power", "water", "gas", "heat", "communication", "sewage"):
|
||||||
|
assert expected in kinds, f"вид {expected!r} должен быть в _UTILITY_QUERIES"
|
||||||
|
|
||||||
|
|
||||||
|
def test_power_line_is_way_and_tower_is_node() -> None:
|
||||||
|
"""ЛЭП = way (линия), опора = node (точка)."""
|
||||||
|
by_kv = {(k, v): t for k, v, t, _ in ul._UTILITY_QUERIES}
|
||||||
|
assert by_kv[("power", "line")] == "way"
|
||||||
|
assert by_kv[("power", "tower")] == "node"
|
||||||
|
assert by_kv[("power", "substation")] == "nwr"
|
||||||
|
|
||||||
|
|
||||||
|
# ── _passes_tag_filters ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_substance_gas_passes() -> None:
|
||||||
|
assert ul._passes_tag_filters("man_made", "pipeline", {"substance": "gas"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_substance_non_gas_filtered() -> None:
|
||||||
|
"""man_made=pipeline с substance=oil НЕ проходит под gas."""
|
||||||
|
assert not ul._passes_tag_filters("man_made", "pipeline", {"substance": "oil"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_legacy_type_tag_fallback() -> None:
|
||||||
|
"""legacy `type=gas` (без substance) тоже распознаётся."""
|
||||||
|
assert ul._passes_tag_filters("man_made", "pipeline", {"type": "gas"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_tower_communication_passes() -> None:
|
||||||
|
assert ul._passes_tag_filters("man_made", "tower", {"tower:type": "communication"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_tower_non_communication_filtered() -> None:
|
||||||
|
"""Водонапорная башня (tower:type=water) НЕ проходит под communication."""
|
||||||
|
assert not ul._passes_tag_filters("man_made", "tower", {"tower:type": "water"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiltered_key_always_passes() -> None:
|
||||||
|
"""Ключи без substance/tower фильтра проходят как есть."""
|
||||||
|
assert ul._passes_tag_filters("power", "line", {})
|
||||||
|
|
||||||
|
|
||||||
|
# ── _upsert_elements (fake DB) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _Scalar:
|
||||||
|
def __init__(self, value: Any) -> None:
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
def scalar(self) -> Any:
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
"""Мини-фейк SQLAlchemy session: пишет executed, отдаёт is_insert через scalar()."""
|
||||||
|
|
||||||
|
def __init__(self, *, fail_ids: set[int] | None = None) -> None:
|
||||||
|
self.executed: list[tuple[Any, dict[str, Any] | None]] = []
|
||||||
|
self.committed = False
|
||||||
|
self.closed = False
|
||||||
|
self.rolled_back = False
|
||||||
|
self._fail_ids = fail_ids or set()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def begin_nested(self) -> Any:
|
||||||
|
yield
|
||||||
|
|
||||||
|
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
self.executed.append((sql, params))
|
||||||
|
if params is not None and params.get("osm_id") in self._fail_ids:
|
||||||
|
raise RuntimeError("bad geometry")
|
||||||
|
return _Scalar(True) # is_insert=True
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
self.rolled_back = True
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def _way(osm_id: int, kind: str, key: str, value: str, nodes: list[dict]) -> dict:
|
||||||
|
return {
|
||||||
|
"id": osm_id,
|
||||||
|
"type": "way",
|
||||||
|
"geometry": nodes,
|
||||||
|
"tags": {"name": f"way-{osm_id}"},
|
||||||
|
"_infrastructure_kind": kind,
|
||||||
|
"_source_tag": f"{key}={value}",
|
||||||
|
"_query_key": key,
|
||||||
|
"_query_value": value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _node(osm_id: int, kind: str, key: str, value: str, tags: dict | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": osm_id,
|
||||||
|
"type": "node",
|
||||||
|
"lat": 56.83,
|
||||||
|
"lon": 60.6,
|
||||||
|
"tags": tags or {"name": f"node-{osm_id}"},
|
||||||
|
"_infrastructure_kind": kind,
|
||||||
|
"_source_tag": f"{key}={value}",
|
||||||
|
"_query_key": key,
|
||||||
|
"_query_value": value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_TWO_NODES = [{"lat": 56.8, "lon": 60.6}, {"lat": 56.81, "lon": 60.61}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_mixed_power_water_gas(monkeypatch: Any) -> None:
|
||||||
|
"""Power line (way), substation (node), water_works (node), gas pipeline (way)."""
|
||||||
|
fake_db = _FakeDB()
|
||||||
|
monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db)
|
||||||
|
|
||||||
|
elements = [
|
||||||
|
_way(1, "power", "power", "line", _TWO_NODES),
|
||||||
|
_node(2, "power", "power", "substation"),
|
||||||
|
_node(3, "water", "man_made", "water_works"),
|
||||||
|
_way(4, "gas", "pipeline", "gas", _TWO_NODES),
|
||||||
|
]
|
||||||
|
result = ul._upsert_elements(elements)
|
||||||
|
|
||||||
|
assert result["fetched"] == 4
|
||||||
|
assert result["inserted"] == 4
|
||||||
|
assert result["skipped"] == 0
|
||||||
|
assert result["kind_power"] == 2
|
||||||
|
assert result["kind_water"] == 1
|
||||||
|
assert result["kind_gas"] == 1
|
||||||
|
assert fake_db.committed
|
||||||
|
assert fake_db.closed
|
||||||
|
assert len(fake_db.executed) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_way_geometry_is_linestring(monkeypatch: Any) -> None:
|
||||||
|
"""way → WKT LINESTRING передаётся в params (geom-build из noise_loader)."""
|
||||||
|
fake_db = _FakeDB()
|
||||||
|
monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db)
|
||||||
|
|
||||||
|
ul._upsert_elements([_way(1, "power", "power", "line", _TWO_NODES)])
|
||||||
|
_, params = fake_db.executed[0]
|
||||||
|
assert params is not None
|
||||||
|
assert params["wkt"] == "LINESTRING(60.6 56.8, 60.61 56.81)"
|
||||||
|
assert params["infrastructure_kind"] == "power"
|
||||||
|
assert params["source_tag"] == "power=line"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_node_geometry_is_point(monkeypatch: Any) -> None:
|
||||||
|
"""node → lat/lon передаются для ST_MakePoint."""
|
||||||
|
fake_db = _FakeDB()
|
||||||
|
monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db)
|
||||||
|
|
||||||
|
ul._upsert_elements([_node(2, "power", "power", "substation")])
|
||||||
|
_, params = fake_db.executed[0]
|
||||||
|
assert params is not None
|
||||||
|
assert params["lat"] == 56.83
|
||||||
|
assert params["lon"] == 60.6
|
||||||
|
assert "wkt" not in params
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_skips_way_with_too_few_nodes(monkeypatch: Any) -> None:
|
||||||
|
"""way с <2 узлами (битый bbox-обрезок) пропускается, не валит батч."""
|
||||||
|
fake_db = _FakeDB()
|
||||||
|
monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db)
|
||||||
|
|
||||||
|
elements = [
|
||||||
|
_way(1, "power", "power", "line", [{"lat": 56.8, "lon": 60.6}]), # 1 узел
|
||||||
|
_node(2, "power", "power", "substation"),
|
||||||
|
]
|
||||||
|
result = ul._upsert_elements(elements)
|
||||||
|
assert result["skipped"] == 1
|
||||||
|
assert result["inserted"] == 1
|
||||||
|
assert len(fake_db.executed) == 1 # только node дошёл до UPSERT
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_substance_filter_skips_non_gas_pipeline(monkeypatch: Any) -> None:
|
||||||
|
"""man_made=pipeline substance=oil пропускается (не газ)."""
|
||||||
|
fake_db = _FakeDB()
|
||||||
|
monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db)
|
||||||
|
|
||||||
|
oil = _way(1, "gas", "man_made", "pipeline", _TWO_NODES)
|
||||||
|
oil["tags"] = {"substance": "oil"}
|
||||||
|
gas = _way(2, "gas", "man_made", "pipeline", _TWO_NODES)
|
||||||
|
gas["tags"] = {"substance": "gas"}
|
||||||
|
|
||||||
|
result = ul._upsert_elements([oil, gas])
|
||||||
|
assert result["skipped"] == 1
|
||||||
|
assert result["inserted"] == 1
|
||||||
|
assert result["kind_gas"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_savepoint_isolates_bad_element(monkeypatch: Any) -> None:
|
||||||
|
"""Битый элемент (execute бросает) → SAVEPOINT откатывает только его, батч жив."""
|
||||||
|
fake_db = _FakeDB(fail_ids={2})
|
||||||
|
monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db)
|
||||||
|
|
||||||
|
elements = [
|
||||||
|
_node(1, "power", "power", "substation"),
|
||||||
|
_node(2, "power", "power", "substation"), # упадёт
|
||||||
|
_node(3, "water", "man_made", "water_works"),
|
||||||
|
]
|
||||||
|
result = ul._upsert_elements(elements)
|
||||||
|
assert result["inserted"] == 2
|
||||||
|
assert result["skipped"] == 1
|
||||||
|
assert fake_db.committed # outer tx НЕ откатан
|
||||||
|
assert not fake_db.rolled_back
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_tags_serialized_json(monkeypatch: Any) -> None:
|
||||||
|
"""tags сериализуются в JSON-строку (для CAST(:tags AS jsonb))."""
|
||||||
|
fake_db = _FakeDB()
|
||||||
|
monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db)
|
||||||
|
|
||||||
|
ul._upsert_elements([_node(2, "power", "power", "substation", {"name": "ТП-5"})])
|
||||||
|
_, params = fake_db.executed[0]
|
||||||
|
assert params is not None
|
||||||
|
assert json.loads(params["tags"]) == {"name": "ТП-5"}
|
||||||
|
assert params["name"] == "ТП-5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_empty_returns_zero(monkeypatch: Any) -> None:
|
||||||
|
fake_db = _FakeDB()
|
||||||
|
monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db)
|
||||||
|
result = ul._upsert_elements([])
|
||||||
|
assert result["fetched"] == 0
|
||||||
|
assert result["inserted"] == 0
|
||||||
|
assert len(fake_db.executed) == 0
|
||||||
53
data/sql/170_osm_utility_infrastructure_ekb.sql
Normal file
53
data/sql/170_osm_utility_infrastructure_ekb.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
-- 170_osm_utility_infrastructure_ekb.sql
|
||||||
|
-- Context: Таблица инженерных сетей («инженерные сети») Екатеринбурга из OSM (#1746).
|
||||||
|
-- No-B2B open-data путь: Overpass API. Хранит магистральные линии
|
||||||
|
-- (ЛЭП, трубопроводы — way → LineString) И точки/узлы (опоры ЛЭП,
|
||||||
|
-- подстанции, водозаборы, ЦТП, вышки связи — node → Point) для быстрых
|
||||||
|
-- ST_DWithin-запросов «инж.сети в радиусе участка».
|
||||||
|
-- Дополняет osm_noise_sources_ekb (там utility-срез завязан на noise-loader);
|
||||||
|
-- этот слой — самостоятельный, типизированный по infrastructure_kind.
|
||||||
|
-- Source: Overpass API (power/water/gas/heat/communication queries).
|
||||||
|
-- Geometry: mixed (LineString для way / Point для node) -> geometry(Geometry, 4326)
|
||||||
|
-- Dependencies: PostGIS (уже установлен в prod)
|
||||||
|
-- Deploy order: standalone, нет зависимостей от других таблиц
|
||||||
|
-- Idempotent: да (IF NOT EXISTS / IF NOT EXISTS на индексах)
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS osm_utility_infrastructure_ekb (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
osm_id BIGINT NOT NULL,
|
||||||
|
osm_type TEXT NOT NULL, -- 'node' | 'way' | 'relation'
|
||||||
|
-- Нормализованный вид инженерной сети:
|
||||||
|
-- 'power' | 'water' | 'gas' | 'heat' | 'communication' | 'sewage'
|
||||||
|
infrastructure_kind TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
geom GEOMETRY(Geometry, 4326) NOT NULL, -- линии И точки
|
||||||
|
source_tag TEXT, -- OSM key=value, по которому элемент пойман
|
||||||
|
tags JSONB DEFAULT '{}'::jsonb,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (osm_type, osm_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- GIST индекс для ST_DWithin / ST_Intersects запросов (инж.сети в радиусе участка)
|
||||||
|
CREATE INDEX IF NOT EXISTS osm_utility_infrastructure_ekb_geom_idx
|
||||||
|
ON osm_utility_infrastructure_ekb USING GIST (geom);
|
||||||
|
|
||||||
|
-- Индекс по виду сети для фильтрации (power/water/gas/...)
|
||||||
|
CREATE INDEX IF NOT EXISTS osm_utility_infrastructure_ekb_kind_idx
|
||||||
|
ON osm_utility_infrastructure_ekb (infrastructure_kind);
|
||||||
|
|
||||||
|
COMMENT ON TABLE osm_utility_infrastructure_ekb IS
|
||||||
|
'Инженерные сети Екатеринбурга из OSM (power/water/gas/heat/communication/sewage). '
|
||||||
|
'No-B2B open-data источник: Overpass API. Линии (ЛЭП/трубопроводы) и узлы '
|
||||||
|
'(подстанции/водозаборы/ЦТП/вышки связи) для site-finder utility-infrastructure. '
|
||||||
|
'Маппинг OSM-тегов -> infrastructure_kind: '
|
||||||
|
'backend/app/services/site_finder/utility_infrastructure_loader.py';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN osm_utility_infrastructure_ekb.infrastructure_kind IS
|
||||||
|
'Нормализованный вид сети: power | water | gas | heat | communication | sewage.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN osm_utility_infrastructure_ekb.source_tag IS
|
||||||
|
'OSM key=value, по которому элемент пойман (например power=line, pipeline=gas).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -339,6 +339,35 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/parcels/{cad_num}/utility-infrastructure": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Инженерные сети из OSM вблизи участка (#1746)
|
||||||
|
* @description Инженерные сети (ЛЭП/подстанции/трубопроводы/ЦТП/вышки) из OSM вблизи участка.
|
||||||
|
*
|
||||||
|
* No-B2B open-data источник: osm_utility_infrastructure_ekb (наполняется weekly-sync
|
||||||
|
* из Overpass). Возвращает фичи в радиусе от центроида участка (ST_DWithin) +
|
||||||
|
* summary с ближайшими расстояниями по видам сети.
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* radius_m: радиус поиска, 50–2000 м (default 500).
|
||||||
|
*
|
||||||
|
* Если участок не найден в БД (нет geom) → 404.
|
||||||
|
*/
|
||||||
|
get: operations["get_parcel_utility_infrastructure_api_v1_parcels__cad_num__utility_infrastructure_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/parcels/{cad_num}/isochrones": {
|
"/api/v1/parcels/{cad_num}/isochrones": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -4714,6 +4743,42 @@ export interface components {
|
||||||
/** Deny Paths */
|
/** Deny Paths */
|
||||||
deny_paths: string[];
|
deny_paths: string[];
|
||||||
};
|
};
|
||||||
|
/** UtilityInfrastructureFeature */
|
||||||
|
UtilityInfrastructureFeature: {
|
||||||
|
/** Osm Id */
|
||||||
|
osm_id: number;
|
||||||
|
/** Osm Type */
|
||||||
|
osm_type: string;
|
||||||
|
/** Infrastructure Kind */
|
||||||
|
infrastructure_kind: string;
|
||||||
|
/** Name */
|
||||||
|
name: string | null;
|
||||||
|
/** Source Tag */
|
||||||
|
source_tag: string | null;
|
||||||
|
/** Distance M */
|
||||||
|
distance_m: number;
|
||||||
|
/** Geometry Geojson */
|
||||||
|
geometry_geojson: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** UtilityInfrastructureResponse */
|
||||||
|
UtilityInfrastructureResponse: {
|
||||||
|
/** Features */
|
||||||
|
features: components["schemas"]["UtilityInfrastructureFeature"][];
|
||||||
|
summary: components["schemas"]["UtilityInfrastructureSummary"];
|
||||||
|
};
|
||||||
|
/** UtilityInfrastructureSummary */
|
||||||
|
UtilityInfrastructureSummary: {
|
||||||
|
/** Total Features */
|
||||||
|
total_features: number;
|
||||||
|
/** Nearest Distance M */
|
||||||
|
nearest_distance_m: number | null;
|
||||||
|
/** Nearest By Kind */
|
||||||
|
nearest_by_kind: {
|
||||||
|
[key: string]: number | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
/** ValidationError */
|
/** ValidationError */
|
||||||
ValidationError: {
|
ValidationError: {
|
||||||
/** Location */
|
/** Location */
|
||||||
|
|
@ -5206,6 +5271,39 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
get_parcel_utility_infrastructure_api_v1_parcels__cad_num__utility_infrastructure_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
radius_m?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
cad_num: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UtilityInfrastructureResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
get_isochrones_api_v1_parcels__cad_num__isochrones_get: {
|
get_isochrones_api_v1_parcels__cad_num__isochrones_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue