269 lines
12 KiB
Python
269 lines
12 KiB
Python
"""Загрузчик центров питания (ЦП) Россети из WFS — свободная мощность §3 (#2119).
|
||
|
||
No-B2B верифицированный источник: WFS геосервера портала-тп.рф отдаёт ~488
|
||
центров питания (ПС 35/110 кВ) по Свердловской области с координатами (WGS84,
|
||
округлены ~100 м), классом напряжения и индексом загрузки (открыт/ограничен/
|
||
закрыт для техприсоединения). UPSERT-ит в ``power_supply_centers``.
|
||
|
||
Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly /
|
||
manual docker exec). Punycode-host ОБЯЗАТЕЛЕН — кириллический алиас портал-тп.рф
|
||
не резолвится корректно из curl/httpx.
|
||
|
||
Структура зеркалит utility_infrastructure_loader: httpx с явным таймаутом,
|
||
per-row SAVEPOINT при UPSERT (битая фича не валит weekly-sync).
|
||
|
||
Также экспортирует ``normalize_sc_name`` — общий нормализатор имён ЦП, которым
|
||
пользуется и ``rosseti_reserve_loader`` (матч xlsx-резерва по имени), и endpoint.
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
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__)
|
||
|
||
# Punycode-host ОБЯЗАТЕЛЕН (кириллический алиас портал-тп.рф не для curl/httpx).
|
||
# xn----7sb7akeedqd.xn--p1ai == портал-тп.рф.
|
||
WFS_BASE_URL = "https://xn----7sb7akeedqd.xn--p1ai/geoserver/wfs"
|
||
|
||
# Слой центров питания (полный вид с резервами/напряжением).
|
||
_WFS_TYPE_NAME = "gisosslabs:sc_points_fullview"
|
||
|
||
# CQL-фильтр по региону — только Свердловская область.
|
||
_WFS_CQL_FILTER = "rg_code='SverdlovskOblast'"
|
||
|
||
# Таймаут WFS-запроса (сек). ~488 фич — с запасом.
|
||
_WFS_TIMEOUT = 60
|
||
|
||
# sc_indexload_id (справочник Россети) → индекс загрузки ЦП. Неизвестный id →
|
||
# None (сырое значение остаётся в raw). Верифицированные id из справочника:
|
||
_INDEXLOAD_MAP: dict[int, str] = {
|
||
256184: "open", # открыт для техприсоединения (есть резерв)
|
||
256186: "limited", # ограниченно
|
||
256188: "closed", # закрыт (резерва нет)
|
||
}
|
||
|
||
# «ПС»/«подстанция» + класс напряжения-префикс — срезаем при нормализации имени.
|
||
# Оба компонента опциональны, но хотя бы один должен присутствовать (иначе матч
|
||
# пустой и sub ничего не делает). Примеры:
|
||
# «ПС 110/35/10 Нижне-Исетская» → «нижне-исетская»
|
||
# «ПС Уктус» → «уктус» (только «ПС», без напряжения)
|
||
# «110/10 Уктус» → «уктус» (только напряжение)
|
||
_VOLTAGE_PREFIX_RE = re.compile(
|
||
r"^\s*(?:(?:пс|подстанци\w*)\s*)?(?:[\d/.,]+\s*(?:кв)?\s*)?",
|
||
re.IGNORECASE,
|
||
)
|
||
# Класс напряжения внутри имени ЦП, напр. «110/35/10» или «110/10».
|
||
_VOLTAGE_CLASS_RE = re.compile(r"\b(\d{1,3}(?:[/.]\d{1,3}){1,3})\b")
|
||
_MULTISPACE_RE = re.compile(r"\s+")
|
||
|
||
|
||
def normalize_sc_name(name: str | None) -> str:
|
||
"""Нормализует имя ЦП для матча xlsx-резерва с WFS-фичей.
|
||
|
||
Правила: lower, ё→е, срез «ПС»/подстанция + класс напряжения-префикса,
|
||
удаление кавычек, схлопывание тире/пробелов. Пусто → ''.
|
||
|
||
Examples:
|
||
«ПС 110/35/10 Нижне-Исетская» → «нижне-исетская»
|
||
«Нижне — Исетская» → «нижне-исетская»
|
||
«ПС "Уктус"» → «уктус»
|
||
"""
|
||
if not name:
|
||
return ""
|
||
s = name.strip().lower().replace("ё", "е")
|
||
s = s.replace("«", "").replace("»", "").replace('"', "").replace("'", "")
|
||
# Срез voltage/«ПС»-префикса в начале имени (не трогаем цифры внутри имени).
|
||
s = _VOLTAGE_PREFIX_RE.sub("", s, count=1)
|
||
# Схлопываем все виды тире (—, –, -) в одиночный дефис, пробелы вокруг него убираем.
|
||
s = re.sub(r"\s*[—–-]\s*", "-", s)
|
||
s = _MULTISPACE_RE.sub(" ", s).strip()
|
||
return s
|
||
|
||
|
||
def parse_voltage_class(name: str | None) -> str | None:
|
||
"""Извлекает класс напряжения из текста имени ЦП («ПС 110/35/10 …» → '110/35/10').
|
||
|
||
Возвращает первую последовательность вида «NNN/NNN[/NNN…]». None — если нет.
|
||
"""
|
||
if not name:
|
||
return None
|
||
m = _VOLTAGE_CLASS_RE.search(name)
|
||
if not m:
|
||
return None
|
||
return m.group(1).replace(".", "/")
|
||
|
||
|
||
def _stable_external_id(feature: dict, props: dict) -> str:
|
||
"""Стабильный external_id фичи: feature['id'] или хэш ключевых полей.
|
||
|
||
WFS обычно отдаёт стабильный ``feature['id']``; если его нет — детерминированный
|
||
sha1 по (sc_name, координаты) чтобы UPSERT оставался идемпотентным.
|
||
"""
|
||
fid = feature.get("id")
|
||
if fid:
|
||
return str(fid)
|
||
geom = feature.get("geometry") or {}
|
||
coords = geom.get("coordinates")
|
||
seed = f"{props.get('sc_name', '')}|{coords}"
|
||
# sha1 здесь — стабильный дедуп-id фичи, не криптография.
|
||
return "h:" + hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16]
|
||
|
||
|
||
def _map_load_index(props: dict) -> str | None:
|
||
"""sc_indexload_id → 'open'|'limited'|'closed'|None (неизвестный → None)."""
|
||
raw = props.get("sc_indexload_id")
|
||
if raw is None:
|
||
return None
|
||
try:
|
||
return _INDEXLOAD_MAP.get(int(raw))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def fetch_power_supply_centers() -> list[dict]:
|
||
"""Тянет WFS-фичи центров питания Свердловской области (GeoJSON Feature-list).
|
||
|
||
RUN-ON-PROD: источник гео-блокирует non-RU IP. httpx с явным таймаутом,
|
||
verify по умолчанию (валидный TLS у геосервера). Возвращает list feature-dict'ов
|
||
``{"id", "geometry": {...}, "properties": {...}}``.
|
||
"""
|
||
params = {
|
||
"service": "WFS",
|
||
"version": "1.1.0",
|
||
"request": "GetFeature",
|
||
"typeName": _WFS_TYPE_NAME,
|
||
"outputFormat": "json",
|
||
"CQL_FILTER": _WFS_CQL_FILTER,
|
||
}
|
||
resp = httpx.get(WFS_BASE_URL, params=params, timeout=_WFS_TIMEOUT)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
features: list[dict] = (data or {}).get("features") or []
|
||
logger.info("rosseti_wfs: загружено центров питания=%d", len(features))
|
||
return features
|
||
|
||
|
||
def _point_geom_sql(feature: dict) -> tuple[str, dict] | None:
|
||
"""SQL-выражение + params для geom из GeoJSON Point. None — если не Point/нет коорд."""
|
||
geom = feature.get("geometry") or {}
|
||
if geom.get("type") != "Point":
|
||
return None
|
||
coords = geom.get("coordinates")
|
||
if not coords or len(coords) < 2:
|
||
return None
|
||
lon, lat = coords[0], coords[1]
|
||
if lon is None or lat is None:
|
||
return None
|
||
return "ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)", {"lon": lon, "lat": lat}
|
||
|
||
|
||
def load_power_supply_centers(db: Session | None = None) -> dict[str, int]:
|
||
"""Тянет WFS + UPSERT-ит центры питания в power_supply_centers.
|
||
|
||
UPSERT по UNIQUE(source, external_id). Per-row SAVEPOINT — битая фича не валит
|
||
weekly-sync. ``db`` принимается для совместимости сигнатуры; если None — своя
|
||
SessionLocal (WFS-fetch + запись в БД sync, как utility_infrastructure_loader).
|
||
|
||
Returns: счётчики fetched/inserted/updated/skipped.
|
||
"""
|
||
features = fetch_power_supply_centers()
|
||
owns_session = db is None
|
||
if db is None:
|
||
db = SessionLocal()
|
||
|
||
inserted = 0
|
||
updated = 0
|
||
skipped = 0
|
||
fetched = len(features)
|
||
try:
|
||
for feature in features:
|
||
props: dict = feature.get("properties") or {}
|
||
sc_name = props.get("sc_name")
|
||
if not sc_name:
|
||
skipped += 1
|
||
continue
|
||
|
||
geom_pair = _point_geom_sql(feature)
|
||
geom_sql = geom_pair[0] if geom_pair else "NULL"
|
||
geom_params = geom_pair[1] if geom_pair else {}
|
||
|
||
params: dict = {
|
||
"source": "rosseti_wfs",
|
||
"external_id": _stable_external_id(feature, props),
|
||
"sc_name": sc_name,
|
||
"sc_name_norm": normalize_sc_name(sc_name),
|
||
"dzo_name": props.get("dzo_name"),
|
||
"org_name": props.get("org_name"),
|
||
"branch_name": props.get("br_name"),
|
||
"voltage_class": parse_voltage_class(sc_name),
|
||
"load_index": _map_load_index(props),
|
||
"municipality": props.get("municipality"),
|
||
"place_city": props.get("sc_placecity"),
|
||
"raw": json.dumps(feature, ensure_ascii=False),
|
||
**geom_params,
|
||
}
|
||
|
||
try:
|
||
with db.begin_nested(): # SAVEPOINT — откат только этой фичи
|
||
result = db.execute(
|
||
text(f"""
|
||
INSERT INTO power_supply_centers
|
||
(source, external_id, sc_name, sc_name_norm, dzo_name,
|
||
org_name, branch_name, voltage_class, load_index,
|
||
municipality, place_city, geom, raw, fetched_at)
|
||
VALUES (
|
||
:source, :external_id, :sc_name, :sc_name_norm, :dzo_name,
|
||
:org_name, :branch_name, :voltage_class, :load_index,
|
||
:municipality, :place_city, {geom_sql},
|
||
CAST(:raw AS jsonb), NOW()
|
||
)
|
||
ON CONFLICT (source, external_id) DO UPDATE
|
||
SET sc_name = EXCLUDED.sc_name,
|
||
sc_name_norm = EXCLUDED.sc_name_norm,
|
||
dzo_name = EXCLUDED.dzo_name,
|
||
org_name = EXCLUDED.org_name,
|
||
branch_name = EXCLUDED.branch_name,
|
||
voltage_class = EXCLUDED.voltage_class,
|
||
load_index = EXCLUDED.load_index,
|
||
municipality = EXCLUDED.municipality,
|
||
place_city = EXCLUDED.place_city,
|
||
geom = EXCLUDED.geom,
|
||
raw = EXCLUDED.raw,
|
||
fetched_at = NOW()
|
||
RETURNING (xmax = 0) AS is_insert
|
||
"""),
|
||
params,
|
||
).scalar()
|
||
if result:
|
||
inserted += 1
|
||
else:
|
||
updated += 1
|
||
except Exception as e:
|
||
logger.warning("rosseti_wfs upsert failed for %r: %s", sc_name, e)
|
||
skipped += 1
|
||
|
||
db.commit()
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.exception("rosseti_wfs: unexpected error, outer tx rolled back: %s", e)
|
||
raise
|
||
finally:
|
||
if owns_session:
|
||
db.close()
|
||
|
||
result_dict = {
|
||
"fetched": fetched,
|
||
"inserted": inserted,
|
||
"updated": updated,
|
||
"skipped": skipped,
|
||
}
|
||
logger.info("rosseti_wfs load done: %s", result_dict)
|
||
return result_dict
|