"""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())