gendesign/backend/app/services/site_finder/osm_tp_geocoder.py
bot-backend 6f5bdffbe4
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m51s
Deploy / build-worker (push) Successful in 3m0s
Deploy / deploy (push) Successful in 1m36s
feat(site-finder): OSM-геокод городских ТП/РП ЕКБ (#2119 B2, финальный хвост) (#2252)
2026-07-03 05:22:02 +00:00

319 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-геокод городских ТП/РП ЕКБ → 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())