gendesign/backend/app/services/site_finder/network_zone_humanize.py
bot-backend 5ae0fcc4f5
All checks were successful
CI / changes (pull_request) Successful in 21s
CI Trade-In / changes (pull_request) Successful in 22s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 2m52s
CI / openapi-codegen-check (pull_request) Successful in 3m44s
CI / backend-tests (pull_request) Successful in 15m40s
feat(site-finder): позитив-разрез сетевых ЗОУИТ «сеть рядом с участком» (#2119 C2/C3)
Охранная зона инженерной сети = коридор, где физически проходит сеть →
«теплотрасса в 24 м, врезка дешёвая» — решающий сигнал §3 (особенно heat,
у которого не было гео-привязки). Новое поле nearby_network_zones в
connection-capacity: до 2 ближайших сетевых охранных зон cad_zouit на net_kind
в радиусе 300 м (GIST-префильтр в градусах + точный geography, ~70 мс,
statement_timeout 4s с явным сбросом), гуманизированные подписи (кад.номер
трубопровода / напряжение ВЛ + ТП, ≤1 кВ помечен как last-mile). general
«инженерные коммуникации» дочищается по name_by_doc (на проде это реально
тепло/газ). Пересекающие участок зоны — distance_m=0.
2026-07-03 13:01:23 +05:00

168 lines
9.2 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.

"""Гуманизация «сетевых охранных зон» ЗОУИТ для §3 ПТИЦА (#2119 C3).
Позитив-разрез охранных зон инженерных сетей: рядом с участком физически ПРОХОДИТ
сеть (охранная зона = коридор сети) → «теплотрасса в 24 м — врезка дешёвая». Pure /
без БД: на вход ``type_zone`` + ``name_by_doc`` (детальные имена из cad_zouit), на
выход — уточнённый ``net_kind`` + человекочитаемый ``human_label`` для финдиректора.
Зачем отдельно от ``network_obremenenie.classify_network_zone``:
- ``classify_network_zone`` смотрит ТОЛЬКО на ``type_zone`` (грубый вид: thermal /
general / …). На проде (2026-07-03) большинство «Охранная зона инженерных
коммуникаций» (``general``) — по ``name_by_doc`` РЕАЛЬНО теплосети/газопроводы
(«Трубопровод (тепловые сети) с кадастровым номером 66:41:…», «Газопровод
низкого давления…»). Оставить их ``general`` = потерять решающий тепло-сигнал.
- Поэтому здесь ``refine_net_kind`` дочищает ``general`` по ``name_by_doc``, а
``human_label`` вытаскивает кад.номер трубопровода / напряжение ВЛ / номер ТП
regex-ами. Классификатор type_zone НЕ дублируем — переиспользуем как базу.
"""
from __future__ import annotations
import re
from app.services.site_finder.network_obremenenie import NetworkKind, classify_network_zone
# Кад.номер РФ: 2:2:до7цифр:до-длинная последняя секция (напр. 66:41:0108079:76).
# Последняя секция бывает с ведущими нулями («0036») — берём как есть, не нормализуем.
_CAD_NUM_RE = re.compile(r"\b\d{2}:\d{2}:\d{6,7}:\d+\b")
# «ВЛ 0,4 кВ» / «КЛ 10кВ» / «ВЛИ-0,4 кВ» — тип линии + напряжение. Запятая-десятичный
# разделитель (0,4) и точка (0.4) оба валидны; пробел перед «кВ» опционален.
_VL_KV_RE = re.compile(
r"\b(ВЛИ|ВЛЗ|ВЛ|КЛ|КВЛ)\b[\s-]*?(\d+(?:[.,]\d+)?)\s*кВ",
re.IGNORECASE,
)
# Номер ТП/РП/КТП: «ТП 3164», «ТП 32505», «ТП 3736-3747», «КТП №589». Берём первый
# «якорный» ТП (источник питания ввода). Номер — цифры, опционально с дефисом-диапазоном.
_TP_RE = re.compile(
r"\b(ТП|РП|КТП|КТПН|БКТП|РТП)\b\s*(?:п/?№|№|N)?\s*(\d+(?:-\d+)?)",
re.IGNORECASE,
)
# Маркеры «на самом деле тепло» в name_by_doc для general-зон (инженерные коммуникации).
_THERMAL_NEEDLES: tuple[str, ...] = (
"теплоснабж",
"тепловая сеть",
"тепловые сети",
"тепловых сетей",
"теплосет",
"теплотрасс",
)
# Маркеры «на самом деле газ» в name_by_doc для general-зон.
_GAS_NEEDLES: tuple[str, ...] = (
"газопров",
"газораспредел",
"газоснаб",
"догазифик",
)
# Маркеры электро (ВЛ/КЛ/электросет/подстанция) в name_by_doc для general-зон.
# «трансформаторная подстанция» / «распределительный пункт» — объект электросети без
# явного «ВЛ N кВ» (прод-кейс 66:41:0404019:333). ТП здесь = трансформаторная (электро),
# не путать с тепловой ТК (тепловая камера) — та ловится _THERMAL_NEEDLES раньше.
_ELECTRICITY_NEEDLES: tuple[str, ...] = (
"электросет",
"электроснабж",
"электросетевого",
"кабельн",
"трансформаторн",
"распределительн",
)
def refine_net_kind(type_zone: str | None, name_by_doc: str | None) -> NetworkKind | None:
"""Уточнить вид сети: база по ``type_zone``, дочистка ``general`` по ``name_by_doc``.
Возвращает ``None``, если зона вообще не сетевая (как ``classify_network_zone``).
Для ``general`` («инженерные коммуникации») пытается распознать реальный вид по
детальному имени: тепло → thermal, газ → gas, электро → electricity. Если вид не
распознан — остаётся ``general`` («вид не уточнён»).
Специфичные type_zone (thermal / electricity / gas / …) НЕ переопределяем именем —
для них type_zone уже точный, а name_by_doc лишь обогащает подпись.
"""
base = classify_network_zone(type_zone)
if base != "general":
return base
name = (name_by_doc or "").lower()
if any(n in name for n in _THERMAL_NEEDLES):
return "thermal"
if any(n in name for n in _GAS_NEEDLES):
return "gas"
# ВЛ/КЛ N кВ в имени → электро (regex поймает даже без слова «электросеть»).
if any(n in name for n in _ELECTRICITY_NEEDLES) or _VL_KV_RE.search(name_by_doc or ""):
return "electricity"
return "general"
def _fmt_voltage(raw: str) -> str:
"""«0,4» / «0.4» / «10» → нормализованная строка напряжения («0,4», «10»)."""
return raw.replace(".", ",")
def _is_last_mile_voltage(raw: str) -> bool:
"""Напряжение ≤1 кВ (0,4 кВ и т.п.) — last-mile ввод, не магистраль."""
try:
return float(raw.replace(",", ".")) <= 1.0
except ValueError:
return False
def human_label(net_kind: NetworkKind | None, name_by_doc: str | None) -> str:
"""Человекочитаемая подпись сетевой зоны для §3 (финдиректор-facing).
thermal → «Тепловая сеть (по охранной зоне)» + кад.номер трубопровода, если есть.
electricity → «Ввод N кВ от ТП M (по охранной зоне)» (regex ВЛ/КЛ + ТП);
напряжение ≤1 кВ подаём как «(ввод 0,4 кВ, last-mile)» — второстепенно.
gas/pipeline → «Трубопровод (газо-/нефтепровод) по охранной зоне».
general → «Инженерные коммуникации (вид не уточнён)».
None → «Охранная зона» (fallback, не должно доходить для сетевых).
"""
name = name_by_doc or ""
if net_kind == "thermal":
cad = _CAD_NUM_RE.search(name)
base = "Тепловая сеть (по охранной зоне)"
if cad:
return f"{base}, кад. № {cad.group(0)}"
return base
if net_kind == "electricity":
vl = _VL_KV_RE.search(name)
tp = _TP_RE.search(name)
if vl:
voltage = _fmt_voltage(vl.group(2))
last_mile = _is_last_mile_voltage(vl.group(2))
if tp:
tp_num = tp.group(2)
if last_mile:
# ≤1 кВ — last-mile, подаём как второстепенный факт (уточнение в скобках).
return f"Ввод от ТП {tp_num} (по охранной зоне, {voltage} кВ — last-mile)"
return f"Ввод {voltage} кВ от ТП {tp_num} (по охранной зоне)"
if last_mile:
return f"Электроввод {voltage} кВ — last-mile (по охранной зоне)"
return f"Ввод {voltage} кВ (по охранной зоне)"
if tp:
return f"Электросеть от ТП {tp.group(2)} (по охранной зоне)"
return "Объект электросетевого хозяйства (по охранной зоне)"
if net_kind in ("gas", "pipeline"):
cad = _CAD_NUM_RE.search(name)
base = "Трубопровод (газо-/нефтепровод) по охранной зоне"
if cad:
return f"{base}, кад. № {cad.group(0)}"
return base
if net_kind == "water":
return "Сеть водоснабжения/водоотведения (по охранной зоне)"
if net_kind == "communication":
return "Линия связи (по охранной зоне)"
if net_kind == "general":
return "Инженерные коммуникации (вид не уточнён)"
return "Охранная зона"
__all__ = ["human_label", "refine_net_kind"]