5 changed files with 319 additions and 6 deletions
|
|
@ -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",
|
||||
|
|
|
|||
129
backend/app/services/site_finder/network_obremenenie.py
Normal file
129
backend/app/services/site_finder/network_obremenenie.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
141
backend/tests/test_network_obremenenie.py
Normal file
141
backend/tests/test_network_obremenenie.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue