feat(site-finder): сетевые обременения из ЗОУИТ (#1070, Refs #103) (#1328)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m36s
Deploy / build-worker (push) Successful in 2m40s
Deploy / deploy (push) Successful in 1m12s

This commit is contained in:
bot-backend 2026-06-14 14:49:32 +00:00
parent 5507bbc1a4
commit e5e0ccb283
5 changed files with 319 additions and 6 deletions

View file

@ -9,6 +9,11 @@ from __future__ import annotations
from typing import Literal, TypedDict
from app.services.site_finder.network_obremenenie import (
classify_network_zone,
network_kind_label,
)
# ── Zone matching ─────────────────────────────────────────────────────────────
# Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5)
@ -155,7 +160,25 @@ def compute_gate_verdict(
# cad_zouit fallback path: classify by type_zone keywords (#232).
# subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется.
type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower()
if any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS):
# #1070: «сетевое обременение» — охранная зона инж.сети — выделенный
# blocker-код с видом сети (тепло/электро/…) в детали. Классификация
# пришла из _get_cad_zouit_overlaps (is_network_zone/network_kind); если
# overlap пришёл из старого пути без флага — определяем здесь же.
net_kind = overlap.get("network_kind")
if net_kind is None and overlap.get("is_network_zone") is None:
net_kind = classify_network_zone(overlap.get("type_zone") or overlap.get("layer"))
if net_kind is not None:
kind_label = overlap.get("network_kind_label") or network_kind_label(net_kind)
blockers.append(
Blocker(
code="ZOUIT_NETWORK_OBREMENENIE",
detail=(
f"Сетевое обременение ({kind_label}): "
f"{overlap.get('type_zone', '')} {overlap.get('name', '')}".strip()
),
)
)
elif any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS):
blockers.append(
Blocker(
code="ZOUIT_CAD_BLOCKER",

View file

@ -0,0 +1,129 @@
"""Классификация «сетевых обременений» из ЗОУИТ (#1070).
Pure-function классификатор: по тексту ``type_zone`` ЗОУИТ (cad_zouit / НСПД-дамп)
определяет, является ли зона *охранной зоной инженерной сети* и если да, к какому
виду сети относится (тепло/электро/газ/водоснабжение/связь/общая инженерная).
Контекст (#1070 ООО «Брик»): теплосеть 66:41:0000000:90310 проходит через пятно
застройки реальное обременение. Эпик #1067 (слой ИРД для analyze) НЕ содержал явного
среза «сетевые обременения». ЗОУИТ-данные cad_zouit уже текут в analyze
(``quarter_dump_lookup._get_cad_zouit_overlaps``, #232/#243) и классифицируются в
gate_verdict, но как ОБЩИЕ ЗОУИТ-overlaps без выделенного «сетевого» среза.
Этот модуль выделяет сетевые охранные зоны в отдельную типизированную категорию, чтобы
analyze/фронт могли показать «сетевое обременение (теплосеть)» как самостоятельный флаг.
VERIFY против PROD (cad_zouit, 2026-06-14): сетевые охранные зоны РЕАЛЬНО присутствуют
~1930 строк из 3485:
«Охранная зона инженерных коммуникаций» 1281
«Охранная зона тепловых сетей» 548
«Охранная зона объектов электроэнергетики » 69
«Охранная зона трубопроводов (газопроводов )» 23
«Охранная зона линий и сооружений связи» 9
Зачем pure-function (без БД): тестируемо без фикстур, переиспользуемо и в
``_get_cad_zouit_overlaps`` (теггинг overlap-строк), и в ``gate_verdict`` (выделенный
warning-код), без дублирования keyword-таблиц.
"""
from __future__ import annotations
from typing import Literal
# Виды сетевого обременения. 'general' — «инженерные коммуникации» без указания вида сети.
NetworkKind = Literal[
"thermal", # теплоснабжение
"electricity", # электросети / объекты электроэнергетики
"gas", # газопроводы (входит в «трубопроводы …»)
"water", # водоснабжение / водоотведение / канализация
"communication", # линии и сооружения связи
"pipeline", # трубопроводы (нефте-/нефтепродукто-/аммиако- — не газ, но сетевое)
"general", # «охранная зона инженерных коммуникаций» (вид сети не уточнён)
]
# Человекочитаемые подписи (RU) для UI/детали обременения.
NETWORK_KIND_LABELS: dict[str, str] = {
"thermal": "теплоснабжение",
"electricity": "электроснабжение",
"gas": "газоснабжение",
"water": "водоснабжение/водоотведение",
"communication": "связь",
"pipeline": "трубопровод",
"general": "инженерные коммуникации",
}
# Упорядоченные правила «подстрока type_zone → вид сети». Порядок ВАЖЕН: более
# специфичные виды проверяются раньше «general» (инженерные коммуникации).
# Все подстроки lowercase; матч по `substring in type_zone.lower()`.
# Сверено с distinct type_zone из cad_zouit PROD (2026-06-14).
_NETWORK_RULES: tuple[tuple[NetworkKind, tuple[str, ...]], ...] = (
("thermal", ("тепл",)), # «… тепловых сетей»
("electricity", ("электроэнерг", "электросет", "лэп", "линий электропередач")),
("gas", ("газопров", "газоснаб")),
("water", ("водопров", "водоснаб", "водоотвед", "канализ")),
("communication", ("линий и сооружений связи", "сооружений связи", "линий связи")),
# «Охранная зона трубопроводов (газопроводов, нефтепроводов …)» — если газ не
# сматчился выше, относим к pipeline (нефте-/нефтепродукто-/аммиако-проводы).
("pipeline", ("трубопров", "нефтепров", "нефтепродуктопров", "аммиакопров")),
# Самое общее — «охранная зона инженерных коммуникаций» (вид сети не уточнён).
("general", ("инженерных коммуникаций", "инженерной инфраструктур", "инженерных сет")),
)
# Зона должна быть именно ОХРАННОЙ (обременение), а не, например, «зона публичного
# сервитута» без сетевой привязки. Требуем маркер охранной/защитной зоны ИЛИ явный
# сетевой вид в тексте — иначе «инженерные коммуникации» в произвольной зоне дало бы
# ложные срабатывания. На практике cad_zouit использует «Охранная зона …».
_PROTECTION_MARKERS: tuple[str, ...] = ("охранная зона", "охранн")
def classify_network_zone(type_zone: str | None) -> NetworkKind | None:
"""Определить вид сетевого обременения по тексту ``type_zone`` ЗОУИТ.
Args:
type_zone: текст типа зоны (cad_zouit.type_zone / НСПД-дамп). None/пусто None.
Returns:
``NetworkKind`` если зона охранная зона инженерной сети; иначе ``None``
(зона не сетевая: СЗЗ, ЗРЗ, сервитут без сети, природные ЗОУИТ и т.п.).
"""
if not type_zone:
return None
tz = type_zone.lower()
is_protection = any(m in tz for m in _PROTECTION_MARKERS)
# ЗСО (зона санитарной охраны ИСТОЧНИКА водоснабжения) — это зона вокруг водозабора,
# НЕ охранная зона водопроводной СЕТИ → не сетевое обременение. Иначе water-needles
# («водоснаб») ложно отнесли бы ЗСО к сети и флипнули verdict на blocker.
is_sanitary_source = "санитарн" in tz
for kind, needles in _NETWORK_RULES:
if any(n in tz for n in needles):
# «general» (инженерные коммуникации) принимаем только при маркере охранной
# зоны — иначе слишком широкая ловля. Специфичные виды (тепло/газ/…) сами по
# себе достаточно сетевые, но в cad_zouit они тоже всегда «Охранная зона …».
if kind == "general" and not is_protection:
continue
# water-зона санитарной охраны источника — не сеть, пропускаем.
if kind == "water" and is_sanitary_source:
continue
return kind
return None
def network_kind_label(kind: NetworkKind | None) -> str | None:
"""RU-подпись вида сети для UI/детали (None → None)."""
if kind is None:
return None
return NETWORK_KIND_LABELS.get(kind, kind)
def is_network_obremenenie(type_zone: str | None) -> bool:
"""True если ЗОУИТ — сетевое обременение (любой вид сети)."""
return classify_network_zone(type_zone) is not None
__all__ = [
"NETWORK_KIND_LABELS",
"NetworkKind",
"classify_network_zone",
"is_network_obremenenie",
"network_kind_label",
]

View file

@ -23,6 +23,11 @@ from sqlalchemy import text
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
from sqlalchemy.orm import Session
from app.services.site_finder.network_obremenenie import (
classify_network_zone,
network_kind_label,
)
logger = logging.getLogger(__name__)
# Порог свежести дампа — 180 дней. Совпадает с beat-расписанием (раз в квартал
@ -405,6 +410,10 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
result: list[dict[str, Any]] = []
for r in rows:
type_zone: str = r[0] or ""
# #1070: выделяем «сетевые обременения» — охранные зоны инж.сетей
# (тепло/электро/газ/вода/связь/трубопровод/общие инж.коммуникации).
# Pure-function классификация по type_zone; None если зона не сетевая.
net_kind = classify_network_zone(type_zone)
result.append(
{
"group_key": "cad_zouit",
@ -419,6 +428,12 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
},
"source": "cad_zouit",
"type_zone": type_zone,
# #1070 «сетевые обременения»: флаг + вид сети + RU-подпись.
# is_network_zone=True → охранная зона инж.сети (обременение);
# network_kind ∈ NetworkKind | None; network_kind_label — RU.
"is_network_zone": net_kind is not None,
"network_kind": net_kind,
"network_kind_label": network_kind_label(net_kind),
# #255: geom для ZouitLayer на Leaflet (frontend JSON.parse → <GeoJSON>).
# geom — GEOGRAPHY, ST_AsGeoJSON требует geometry → CAST(... AS geometry).
"geom_geojson": r[5],

View file

@ -124,12 +124,17 @@ def test_pzz_unknown_warning_when_no_zoning():
def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
"""cad_zouit overlap с 'охранная зона трубопроводов' → ZOUIT_CAD_BLOCKER → Нельзя."""
"""cad_zouit 'охранная зона трубопроводов' → сетевое обременение (газ) → Нельзя (#1070).
После #1070 трубопроводы/газ классифицируются как ZOUIT_NETWORK_OBREMENENIE
(более специфичный код, чем прежний ZOUIT_CAD_BLOCKER) поведение «блокирует МКД»
сохранено.
"""
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
overlaps = [
{
"source": "cad_zouit",
"type_zone": "Охранная зона трубопроводов",
"type_zone": "Охранная зона трубопроводов (газопроводов, нефтепроводов)",
"layer": "Охранная зона трубопроводов",
"name": "Газопровод высокого давления",
}
@ -137,11 +142,11 @@ def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
assert verdict["can_build_mkd"] is False
assert verdict["verdict_label"] == "Нельзя"
assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"])
assert any(b["code"] == "ZOUIT_NETWORK_OBREMENENIE" for b in verdict["blockers"])
def test_cad_zouit_elektr_blocks():
"""cad_zouit overlap с 'электр' substring в type_zone → blocker."""
"""cad_zouit электросетевая охранная зона → сетевое обременение (электро) → blocker (#1070)."""
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
overlaps = [
{
@ -153,7 +158,7 @@ def test_cad_zouit_elektr_blocks():
]
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
assert verdict["can_build_mkd"] is False
assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"])
assert any(b["code"] == "ZOUIT_NETWORK_OBREMENENIE" for b in verdict["blockers"])
def test_cad_zouit_szz_is_warning_not_blocker():

View file

@ -0,0 +1,141 @@
"""Tests for network-obremenenie классификатор (#1070) — pure function, no DB.
type_zone-строки взяты из реальных distinct-значений cad_zouit (PROD, 2026-06-14).
"""
from app.services.site_finder.gate_verdict import compute_gate_verdict
from app.services.site_finder.network_obremenenie import (
classify_network_zone,
is_network_obremenenie,
network_kind_label,
)
# ── classify_network_zone: реальные type_zone из cad_zouit ────────────────────
def test_thermal_zone():
assert classify_network_zone("Охранная зона тепловых сетей") == "thermal"
def test_electricity_zone():
tz = (
"Охранная зона объектов электроэнергетики (объектов электросетевого хозяйства "
"и объектов по производству электрической энергии)"
)
assert classify_network_zone(tz) == "electricity"
def test_pipeline_zone_gas_first():
# «трубопроводов (газопроводов, нефтепроводов …)» — газ ловится раньше pipeline.
tz = (
"Охранная зона трубопроводов (газопроводов, нефтепроводов "
"и нефтепродуктопроводов, аммиакопроводов)"
)
assert classify_network_zone(tz) == "gas"
def test_communication_zone():
assert classify_network_zone("Охранная зона линий и сооружений связи") == "communication"
def test_general_engineering_zone():
assert classify_network_zone("Охранная зона инженерных коммуникаций") == "general"
# ── негативные: НЕ сетевые ЗОУИТ ──────────────────────────────────────────────
def test_servitude_not_network():
assert classify_network_zone("Зона публичного сервитута") is None
def test_szz_not_network():
assert classify_network_zone("Санитарно-защитная зона") is None
def test_natural_zouit_not_network():
assert classify_network_zone("Зоны с особыми условиями использования территории") is None
def test_none_and_empty():
assert classify_network_zone(None) is None
assert classify_network_zone("") is None
def test_general_keyword_without_protection_marker_is_skipped():
# «инженерные коммуникации» без «охранная зона» → не ловим (избегаем ложных).
assert classify_network_zone("Размещение инженерных коммуникаций") is None
# ── helpers ───────────────────────────────────────────────────────────────────
def test_is_network_obremenenie():
assert is_network_obremenenie("Охранная зона тепловых сетей") is True
assert is_network_obremenenie("Зона публичного сервитута") is False
def test_network_kind_label():
assert network_kind_label("thermal") == "теплоснабжение"
assert network_kind_label("general") == "инженерные коммуникации"
assert network_kind_label(None) is None
# ── gate_verdict: сетевое обременение → выделенный blocker ─────────────────────
def _dump_available() -> dict:
return {"available": True, "stale": False}
def test_gate_verdict_thermal_network_blocker():
"""Теплосеть (#1070 ООО Брик) → ZOUIT_NETWORK_OBREMENENIE blocker."""
overlaps = [
{
"source": "cad_zouit",
"type_zone": "Охранная зона тепловых сетей",
"name": "ТС-123",
"is_network_zone": True,
"network_kind": "thermal",
"network_kind_label": "теплоснабжение",
}
]
verdict = compute_gate_verdict(None, overlaps, None, _dump_available())
codes = [b["code"] for b in verdict["blockers"]]
assert "ZOUIT_NETWORK_OBREMENENIE" in codes
assert verdict["can_build_mkd"] is False
detail = next(
b["detail"] for b in verdict["blockers"] if b["code"] == "ZOUIT_NETWORK_OBREMENENIE"
)
assert "теплоснабжение" in detail
def test_gate_verdict_servitude_not_network_blocker():
"""Сервитут (не сеть) → НЕ ZOUIT_NETWORK_OBREMENENIE."""
overlaps = [
{
"source": "cad_zouit",
"type_zone": "Зона публичного сервитута",
"name": "",
"is_network_zone": False,
"network_kind": None,
"network_kind_label": None,
}
]
verdict = compute_gate_verdict(None, overlaps, None, _dump_available())
codes = [b["code"] for b in verdict["blockers"]]
assert "ZOUIT_NETWORK_OBREMENENIE" not in codes
def test_gate_verdict_network_classified_when_flag_absent():
"""Back-compat: overlap без флага → gate_verdict классифицирует сам."""
overlaps = [
{
"source": "cad_zouit",
"type_zone": "Охранная зона объектов электроэнергетики",
"name": "ВЛ-10кВ",
}
]
verdict = compute_gate_verdict(None, overlaps, None, _dump_available())
codes = [b["code"] for b in verdict["blockers"]]
assert "ZOUIT_NETWORK_OBREMENENIE" in codes