319 lines
14 KiB
Python
319 lines
14 KiB
Python
"""OSM-геокод городских ТП/РП ЕКБ → geom для power_tp_rp_reserves (#2119 B2, финал).
|
||
|
||
Хвост §3 «свободная мощность»: у ~2012 ЕКБ-строк power_tp_rp_reserves (ЕЭСК,
|
||
«ниже 35» кВ) name_norm = чистый номер («10003»), но geom NULL. OSM в bbox ЕКБ
|
||
несёт ~821 substation; часть подписана номером ТП/РП в теге ``ref`` или ``name``
|
||
(«РП-101», «ТП-180», «ТП 1591») — по нему матчим наши строки и заливаем координату.
|
||
|
||
Разведка 2026-07-03: матч по номеру даёт ~119 наших ТП, 0 неоднозначностей в срезе.
|
||
Консервативно: берём число только если это похоже на номер ТП/РП (ТП/РП/RP/TP-токен
|
||
рядом ИЛИ голый ref-число) — «110 кВ» из voltage мимо. Дубль-номера (>1 OSM-фичи на
|
||
один номер) честно скипаем в ambiguous.
|
||
|
||
RUN-ON-PROD-friendly, но источник (Overpass) без гео-блока — можно и локально.
|
||
httpx с явным таймаутом, per-row SAVEPOINT (backend.md), idempotent UPDATE
|
||
(geom IS NULL + geocode_status='osm'). Dry-run по умолчанию; CLI ``--apply``.
|
||
|
||
Overpass endpoint: overpass.kumi.systems (overpass-api.de даёт 406 на наш POST;
|
||
area-запрос роняет соединение — используем ТОЛЬКО bbox).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import logging
|
||
import re
|
||
|
||
import httpx
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import SessionLocal
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Overpass-инстанс: overpass-api.de отдаёт 406 на наш POST, kumi стабилен (разведка).
|
||
OVERPASS_URL = "https://overpass.kumi.systems/api/interpreter"
|
||
|
||
# bbox ЕКБ (south, west, north, east) для Overpass. area-запрос роняет соединение —
|
||
# ТОЛЬКО bbox. Тот же прямоугольник используем и для sanity-гейта координаты.
|
||
_EKB_BBOX = (56.70, 60.40, 56.97, 60.80)
|
||
|
||
_HTTP_TIMEOUT = 60
|
||
|
||
# Overpass QL: node/way/relation с power=substation в bbox, вывод с центроидом (center)
|
||
# и тегами. `out center tags;` — для way/relation отдаёт center-точку, для node — саму.
|
||
_OVERPASS_QL = (
|
||
"[out:json][timeout:{timeout}];"
|
||
"("
|
||
'node["power"="substation"]({s},{w},{n},{e});'
|
||
'way["power"="substation"]({s},{w},{n},{e});'
|
||
'relation["power"="substation"]({s},{w},{n},{e});'
|
||
");"
|
||
"out center tags;"
|
||
)
|
||
|
||
# Токен «ТП/РП» (кириллица и латиница-двойники) + число 2-5 цифр после него.
|
||
# Примеры: «РП-101» → 101, «ТП-180» → 180, «ТП 1591» → 1591, «TP-42» → 42.
|
||
# Разделитель между токеном и числом — дефис/пробел/точка/двоеточие (или отсутствует).
|
||
_TP_TOKEN_NUM_RE = re.compile(
|
||
r"(?:тп|рп|tp|rp)\s*[-–—.:№]?\s*(\d{2,5})\b",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
# Голый ref-номер целиком: только цифры 2-5 знаков (напр. ref='101' без ТП-токена).
|
||
_BARE_NUM_RE = re.compile(r"^\s*(\d{2,5})\s*$")
|
||
|
||
|
||
def fetch_osm_substations() -> list[dict]:
|
||
"""Тянет OSM-подстанции ЕКБ (power=substation) в bbox через Overpass → element-list.
|
||
|
||
POST data= (GET area роняет соединение). Скип ``substation=traction`` (тяговые
|
||
ж/д подстанции — не городские ТП/РП). httpx с явным таймаутом. Возвращает
|
||
сырые Overpass-элементы ``{"type","id","lat"/"center","tags"}``.
|
||
"""
|
||
ql = _OVERPASS_QL.format(
|
||
timeout=_HTTP_TIMEOUT,
|
||
s=_EKB_BBOX[0],
|
||
w=_EKB_BBOX[1],
|
||
n=_EKB_BBOX[2],
|
||
e=_EKB_BBOX[3],
|
||
)
|
||
resp = httpx.post(OVERPASS_URL, data={"data": ql}, timeout=_HTTP_TIMEOUT)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
elements: list[dict] = (data or {}).get("elements") or []
|
||
|
||
kept: list[dict] = []
|
||
for el in elements:
|
||
tags = el.get("tags") or {}
|
||
# Тяговые ж/д подстанции — не городские ТП/РП, выкидываем.
|
||
if tags.get("substation") == "traction":
|
||
continue
|
||
kept.append(el)
|
||
|
||
logger.info(
|
||
"osm_tp_geocoder: Overpass отдал %d substation, после traction-скипа %d",
|
||
len(elements),
|
||
len(kept),
|
||
)
|
||
return kept
|
||
|
||
|
||
def extract_tp_number(tags: dict) -> str | None:
|
||
"""Извлекает номер ТП/РП из тегов OSM-фичи (``ref`` приоритетнее ``name``).
|
||
|
||
Консервативно — только когда число выглядит номером ТП/РП, не вольтажем:
|
||
- ``ref`` = голое число 2-5 цифр («101») → берём;
|
||
- в ``ref``/``name`` есть ТП/РП/TP/RP-токен + число («РП-101», «ТП 1591») → берём число;
|
||
- «110 кВ» / «110 kV» БЕЗ ТП-токена → None (это класс напряжения, не номер).
|
||
|
||
Приоритет источника: ref, затем name. Возвращает строку-номер или None.
|
||
"""
|
||
ref = (tags.get("ref") or "").strip()
|
||
name = (tags.get("name") or "").strip()
|
||
|
||
# 1. Голый ref-номер (наиболее надёжный сигнал: оператор проставил ref явно).
|
||
if ref:
|
||
m_bare = _BARE_NUM_RE.match(ref)
|
||
if m_bare:
|
||
return m_bare.group(1)
|
||
|
||
# 2. ТП/РП-токен + число — сперва в ref, потом в name.
|
||
for source in (ref, name):
|
||
if not source:
|
||
continue
|
||
m = _TP_TOKEN_NUM_RE.search(source)
|
||
if m:
|
||
return m.group(1)
|
||
|
||
# «110 кВ» и прочий вольтаж без ТП-токена и без голого ref — намеренно None.
|
||
return None
|
||
|
||
|
||
def _center_coords(el: dict) -> tuple[float, float] | None:
|
||
"""(lat, lon) из Overpass-элемента: node → lat/lon, way/relation → center. None — нет."""
|
||
if el.get("lat") is not None and el.get("lon") is not None:
|
||
return float(el["lat"]), float(el["lon"])
|
||
center = el.get("center") or {}
|
||
if center.get("lat") is not None and center.get("lon") is not None:
|
||
return float(center["lat"]), float(center["lon"])
|
||
return None
|
||
|
||
|
||
def _in_ekb_bbox(lat: float, lon: float) -> bool:
|
||
"""Координата в bbox ЕКБ? Overpass и так ограничен bbox'ом — проверка дёшева/страховка."""
|
||
s, w, n, e = _EKB_BBOX
|
||
return s <= lat <= n and w <= lon <= e
|
||
|
||
|
||
def _index_osm_by_number(elements: list[dict]) -> tuple[dict[str, tuple[float, float]], set[str]]:
|
||
"""Строит {номер → (lat,lon)} по OSM-фичам; номера с >1 фичей → ambiguous-set.
|
||
|
||
Номер извлекается ``extract_tp_number``; берётся только фича с валидной координатой
|
||
в bbox ЕКБ. Если один номер несут ≥2 разных фичи (разные координаты) — он попадает
|
||
в ambiguous и НЕ матчится (честность: не угадываем, какая из ТП «та самая»).
|
||
"""
|
||
by_number: dict[str, tuple[float, float]] = {}
|
||
seen_coords: dict[str, set[tuple[float, float]]] = {}
|
||
ambiguous: set[str] = set()
|
||
|
||
for el in elements:
|
||
tags = el.get("tags") or {}
|
||
number = extract_tp_number(tags)
|
||
if number is None:
|
||
continue
|
||
coords = _center_coords(el)
|
||
if coords is None:
|
||
continue
|
||
lat, lon = coords
|
||
if not _in_ekb_bbox(lat, lon):
|
||
logger.info(
|
||
"osm_tp_geocoder: номер %s вне bbox ЕКБ (lat=%.4f lon=%.4f) → скип",
|
||
number,
|
||
lat,
|
||
lon,
|
||
)
|
||
continue
|
||
|
||
prev = seen_coords.setdefault(number, set())
|
||
# Округляем до ~11 м, чтобы дубли-теги одной ТП (way+node) не считались коллизией.
|
||
coord_key = (round(lat, 4), round(lon, 4))
|
||
prev.add(coord_key)
|
||
if len(prev) > 1:
|
||
ambiguous.add(number)
|
||
by_number.pop(number, None)
|
||
continue
|
||
if number not in ambiguous:
|
||
by_number[number] = (lat, lon)
|
||
|
||
return by_number, ambiguous
|
||
|
||
|
||
# UPDATE geom + geocode_status='osm'. CAST(:x AS ...) — psycopg3-канон (backend.md,
|
||
# НИКОГДА :x::float). Пишем ТОЛЬКО ЕКБ + geom всё ещё NULL → idempotent при повторе.
|
||
_UPDATE_SQL = text("""
|
||
UPDATE power_tp_rp_reserves
|
||
SET geom = ST_SetSRID(
|
||
ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)),
|
||
4326
|
||
),
|
||
geocode_status = 'osm'
|
||
WHERE name_norm = CAST(:name_norm AS text)
|
||
AND municipality = 'Екатеринбург'
|
||
AND geom IS NULL
|
||
""")
|
||
|
||
# Кандидаты на геокод: ЕКБ-строки без координаты. name_norm = чистый номер.
|
||
_CANDIDATES_SQL = text("""
|
||
SELECT name_norm
|
||
FROM power_tp_rp_reserves
|
||
WHERE municipality = 'Екатеринбург'
|
||
AND geom IS NULL
|
||
AND name_norm IS NOT NULL
|
||
ORDER BY name_norm
|
||
""")
|
||
|
||
|
||
def geocode_tp_from_osm(db: Session | None = None, *, apply: bool = False) -> dict:
|
||
"""Матч name_norm ЕКБ-ТП/РП ↔ OSM-номер → UPDATE geom + geocode_status='osm'.
|
||
|
||
Дефолт dry-run (apply=False): считает счётчики + логирует первые 20 пар
|
||
номер→(lat,lon), НО В БД НЕ ПИШЕТ. apply=True — реально пишет.
|
||
|
||
Алгоритм:
|
||
1. fetch OSM-substation ЕКБ (traction уже отсеян).
|
||
2. индекс {номер → координата}; дубль-номер → ambiguous (не матчим).
|
||
3. SELECT ЕКБ-строк с geom IS NULL; matched, если номер есть в индексе.
|
||
4. UPDATE geom WHERE name_norm=... AND municipality='Екатеринбург' AND geom IS NULL
|
||
(idempotent). Per-row SAVEPOINT — битый UPDATE не валит батч.
|
||
|
||
Returns счётчики {osm_features, numbered, matched, updated, ambiguous, applied}.
|
||
"""
|
||
owns_session = db is None
|
||
if db is None:
|
||
db = SessionLocal()
|
||
|
||
counts = {
|
||
"osm_features": 0,
|
||
"numbered": 0,
|
||
"matched": 0,
|
||
"updated": 0,
|
||
"ambiguous": 0,
|
||
"applied": apply,
|
||
}
|
||
sample: list[tuple[str, tuple[float, float]]] = []
|
||
|
||
try:
|
||
elements = fetch_osm_substations()
|
||
counts["osm_features"] = len(elements)
|
||
|
||
by_number, ambiguous = _index_osm_by_number(elements)
|
||
counts["numbered"] = len(by_number)
|
||
counts["ambiguous"] = len(ambiguous)
|
||
|
||
candidates: list[str] = [str(row[0]) for row in db.execute(_CANDIDATES_SQL).all()]
|
||
|
||
for name_norm in candidates:
|
||
coords = by_number.get(name_norm)
|
||
if coords is None:
|
||
continue
|
||
counts["matched"] += 1
|
||
lat, lon = coords
|
||
if len(sample) < 20:
|
||
sample.append((name_norm, (lat, lon)))
|
||
|
||
if not apply:
|
||
continue
|
||
|
||
try:
|
||
with db.begin_nested(): # SAVEPOINT — откат только этой строки
|
||
res = db.execute(_UPDATE_SQL, {"name_norm": name_norm, "lat": lat, "lon": lon})
|
||
if res.rowcount and res.rowcount > 0:
|
||
counts["updated"] += res.rowcount
|
||
except Exception as e:
|
||
logger.warning("osm_tp_geocoder UPDATE failed for %r: %s", name_norm, e)
|
||
|
||
if apply:
|
||
db.commit()
|
||
except Exception as e:
|
||
if apply:
|
||
db.rollback()
|
||
logger.exception("geocode_tp_from_osm: rolled back: %s", e)
|
||
raise
|
||
finally:
|
||
if owns_session:
|
||
db.close()
|
||
|
||
logger.info("geocode_tp_from_osm done (apply=%s): %s", apply, counts)
|
||
if not apply:
|
||
for number, (lat, lon) in sample:
|
||
logger.info(" sample: ТП/РП %s → (%.6f, %.6f)", number, lat, lon)
|
||
return counts
|
||
|
||
|
||
def _main(argv: list[str] | None = None) -> int:
|
||
"""CLI: OSM-геокод городских ТП/РП ЕКБ → geom. Dry-run по умолчанию.
|
||
|
||
Примеры:
|
||
python -m app.services.site_finder.osm_tp_geocoder # dry-run
|
||
python -m app.services.site_finder.osm_tp_geocoder --apply # пишет в БД
|
||
"""
|
||
parser = argparse.ArgumentParser(
|
||
description="OSM-геокод городских ТП/РП ЕКБ (#2119 B2): матч name_norm ↔ "
|
||
"OSM substation ref/name → geom + geocode_status='osm'."
|
||
)
|
||
parser.add_argument(
|
||
"--apply",
|
||
action="store_true",
|
||
help="Реально писать в БД. Без флага — dry-run (только счётчики + сэмпл).",
|
||
)
|
||
args = parser.parse_args(argv)
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||
logger.info("osm_tp_geocoder result: %s", geocode_tp_from_osm(apply=args.apply))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__": # pragma: no cover
|
||
raise SystemExit(_main())
|