diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py index 8b854cb3..ff970522 100644 --- a/backend/app/services/site_finder/gate_verdict.py +++ b/backend/app/services/site_finder/gate_verdict.py @@ -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", diff --git a/backend/app/services/site_finder/network_obremenenie.py b/backend/app/services/site_finder/network_obremenenie.py new file mode 100644 index 00000000..0a6cd54b --- /dev/null +++ b/backend/app/services/site_finder/network_obremenenie.py @@ -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", +] diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 10ed6210..4ba99055 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -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 → ). # geom — GEOGRAPHY, ST_AsGeoJSON требует geometry → CAST(... AS geometry). "geom_geojson": r[5], diff --git a/backend/tests/test_gate_verdict.py b/backend/tests/test_gate_verdict.py index 903f0f7a..4378d5ab 100644 --- a/backend/tests/test_gate_verdict.py +++ b/backend/tests/test_gate_verdict.py @@ -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(): diff --git a/backend/tests/test_network_obremenenie.py b/backend/tests/test_network_obremenenie.py new file mode 100644 index 00000000..97aa5cc9 --- /dev/null +++ b/backend/tests/test_network_obremenenie.py @@ -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