fix(gate): area-gate ЗОУИТ utility-easement blocker (sub-17) — снять over-block can_build (#1881) #1892
8 changed files with 421 additions and 59 deletions
Binary file not shown.
|
|
@ -217,6 +217,16 @@ class Settings(BaseSettings):
|
|||
# полностью пропускает резолв — поведение analyze без изменений.
|
||||
enable_zoning_regulation_in_analyze: bool = True
|
||||
|
||||
# Area-gate для blocker'а «инженерная/утилитарная охранная зона» в gate_verdict.
|
||||
# ЗОУИТ охранной зоны инж.сети (ЛЭП/газ/трубопровод/тепло/электро) в РФ ограничивает
|
||||
# застройку ВНУТРИ полосы (отступы, запрет капстроя над линией), а НЕ стерилизует весь
|
||||
# участок — МКД сажается на необременённом остатке. Поэтому такая зона блокирует МКД
|
||||
# ТОЛЬКО когда покрывает > этой доли площади участка (фактически стерилизует его);
|
||||
# ниже порога — warning «учесть при посадке». Default 0.6: на проде ЕКБ медиана покрытия
|
||||
# ~6%, p90 ~47% → блокирует лишь экстремальный хвост (1/17 участков). Override через env
|
||||
# GATE_ZOUIT_ENGINEERING_BLOCKER_MIN_COVERAGE.
|
||||
gate_zouit_engineering_blocker_min_coverage: float = 0.6
|
||||
|
||||
# ── LLM infrastructure (#960) ────────────────────────────────────────────
|
||||
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
|
||||
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ Pure function — no DB dependencies, consumes already-fetched data from analyze
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Literal, TypedDict
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.site_finder.network_obremenenie import (
|
||||
classify_network_zone,
|
||||
network_kind_label,
|
||||
|
|
@ -81,8 +82,11 @@ NSPD_RESIDENTIAL_SUBCATEGORIES: frozenset[int] = frozenset({2, 3})
|
|||
|
||||
# ── ЗОУИТ taxonomy ────────────────────────────────────────────────────────────
|
||||
|
||||
# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода).
|
||||
# Используется для overlaps из NSPD dump (subcategory INT там заполнен).
|
||||
# Subcategory codes охранных зон инж.сетей (ЛЭП/газа/трубопровода) из NSPD dump.
|
||||
# ВНИМАНИЕ: это НЕ безусловные блокеры — они AREA-GATED в compute_gate_verdict
|
||||
# (блокируют МКД только при суммарном покрытии участка > порога settings, иначе
|
||||
# warning ZOUIT_ENGINEERING_PARTIAL). Эти зоны ограничивают застройку ВНУТРИ полосы,
|
||||
# а не стерилизуют весь участок. dict-значение — человекочитаемый ярлык подкатегории.
|
||||
BLOCKER_SUBCATEGORIES: dict[int, str] = {
|
||||
17: "Инженерные коммуникации (охранная зона ЛЭП/газа/трубопровода)",
|
||||
}
|
||||
|
|
@ -193,6 +197,49 @@ def is_residential_zone(
|
|||
return False
|
||||
|
||||
|
||||
# ── Area-gate helpers (engineering/utility-easement ЗОУИТ) ────────────────────
|
||||
|
||||
|
||||
def _coverage_of(overlap: dict[str, Any]) -> float:
|
||||
"""coverage_pct overlap'а как float 0..1; отсутствует/None/нечисловое → 0.0.
|
||||
|
||||
Legacy/synthetic overlaps без ключа `coverage_pct` трактуются как 0.0 → warning, а
|
||||
НЕ blocker. Это намеренно: dump-путь (_get_zouit_overlaps / _get_cad_zouit_overlaps)
|
||||
теперь ВСЕГДА проставляет coverage_pct, поэтому отсутствие ключа = синтетический/
|
||||
устаревший overlap без гео-данных. Трактовка «0 → warning» рискует НЕ заблокировать
|
||||
реальную полнопокровную охранную зону, у которой нет coverage — но без площади мы и
|
||||
не можем доказать стерилизацию, а ложный hard-block на тонкой полосе (исходный баг)
|
||||
дороже: он прячет финмодель у 90% участков. Порог конфигурируем, dump всегда даёт %.
|
||||
"""
|
||||
raw = overlap.get("coverage_pct")
|
||||
if raw is None:
|
||||
return 0.0
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
if val != val: # NaN
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, val))
|
||||
|
||||
|
||||
def _aggregate_coverage(overlaps: list[dict[str, Any]]) -> float:
|
||||
"""Суммарное покрытие участка группой ЗОУИТ-overlaps, clamp в [0, 1].
|
||||
|
||||
Сумма (а не union) — намеренно КОНСЕРВАТИВНАЯ переоценка: перекрывающиеся полосы
|
||||
считаются дважды, поэтому агрегат ≥ истинного union → ошибка В СТОРОНУ блокировки
|
||||
(safe — скорее заблокируем спорный, чем пропустим стерилизованный). Clamp 1.0
|
||||
не даёт сумме уйти за 100%. Так участок с множеством мелких полос, дающих в сумме
|
||||
> порога, блокируется; и одна крупная полоса > порога блокирует.
|
||||
"""
|
||||
return min(1.0, sum(_coverage_of(o) for o in overlaps))
|
||||
|
||||
|
||||
def _coverage_pct_label(coverage: float) -> int:
|
||||
"""Округлить долю 0..1 до целого процента для человекочитаемой detail-строки."""
|
||||
return round(coverage * 100)
|
||||
|
||||
|
||||
# ── Main aggregator ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -206,7 +253,10 @@ def compute_gate_verdict(
|
|||
|
||||
Checks performed (in order):
|
||||
1. ПЗЗ residential zone match (blocker if non-residential)
|
||||
2. ЗОУИТ subcategory 17 overlap (blocker); other subcategories → warnings
|
||||
2. ЗОУИТ overlaps — охранные зоны инж.сетей (NSPD sub-17 / cad_zouit сетевое
|
||||
обременение / keyword-blocker) AREA-GATED: блокируют МКД только при суммарном
|
||||
покрытии участка > settings.gate_zouit_engineering_blocker_min_coverage, иначе →
|
||||
warning ZOUIT_ENGINEERING_PARTIAL. СЗЗ и прочие ЗОУИТ → warnings как раньше.
|
||||
3. Engineering nearby presence check (warning-only, not a blocker)
|
||||
|
||||
Returns GateVerdict с blockers/warnings/checks_performed и source label.
|
||||
|
|
@ -264,7 +314,21 @@ def compute_gate_verdict(
|
|||
)
|
||||
|
||||
# Check 2 — ЗОУИТ overlaps
|
||||
#
|
||||
# Охранные зоны инж.сетей (ЛЭП/газ/трубопровод/тепло/электро) — AREA-GATED: они
|
||||
# ограничивают застройку ВНУТРИ полосы, а не стерилизуют весь участок, поэтому
|
||||
# блокируют МКД только при суммарном покрытии > порога (settings), иначе → warning.
|
||||
# Накапливаем такие overlaps в bucket'ы и решаем ОДИН blocker/warning на группу
|
||||
# (no more 4× дублирующих блокеров на тонких полосах). СЗЗ/прочие ЗОУИТ остаются
|
||||
# per-overlap warnings как раньше.
|
||||
checks.append("ЗОУИТ пересечения")
|
||||
threshold = settings.gate_zouit_engineering_blocker_min_coverage
|
||||
# NSPD-dump path: sub-17 (охранная зона инж.коммуникаций) overlaps.
|
||||
sub17_overlaps: list[dict[str, Any]] = []
|
||||
# cad_zouit path: сетевое обременение + keyword-blocker (утилитарная охранная зона).
|
||||
cad_utility_overlaps: list[dict[str, Any]] = []
|
||||
# Подпись вида сети для cad-detail (первый встреченный network_kind).
|
||||
cad_utility_label: str | None = None
|
||||
for overlap in nspd_zouit_overlaps or []:
|
||||
src = overlap.get("source", "nspd-quarter-dump")
|
||||
if src == "cad_zouit":
|
||||
|
|
@ -280,8 +344,8 @@ def compute_gate_verdict(
|
|||
# Порядок веток: СЗЗ-warning ПЕРВЫМ (#1341) — маркер 'сзз'/'санитарно-защитная'
|
||||
# специфичнее широких blocker-подстрок ('газ'/'электр'), СЗЗ газораспределительной
|
||||
# станции/электроподстанции = ограничение, а не hard-blocker. Затем сетевое
|
||||
# обременение (#1070, выделенный код раньше широких подстрок), затем общие
|
||||
# blocker-keywords, иначе — generic warning.
|
||||
# обременение (#1070) и общие blocker-keywords — оба AREA-GATED (копим в bucket),
|
||||
# иначе — generic warning.
|
||||
if any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS):
|
||||
warnings.append(
|
||||
Warning(
|
||||
|
|
@ -291,27 +355,15 @@ def compute_gate_verdict(
|
|||
),
|
||||
)
|
||||
)
|
||||
elif 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 net_kind is not None or any(
|
||||
kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS
|
||||
):
|
||||
# Утилитарная охранная зона — копим для area-gate (см. ниже).
|
||||
cad_utility_overlaps.append(overlap)
|
||||
if cad_utility_label is None and net_kind is not None:
|
||||
cad_utility_label = overlap.get("network_kind_label") or network_kind_label(
|
||||
net_kind
|
||||
)
|
||||
)
|
||||
elif any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS):
|
||||
blockers.append(
|
||||
Blocker(
|
||||
code="ZOUIT_CAD_BLOCKER",
|
||||
detail=(
|
||||
f"Охранная зона ({overlap.get('type_zone', '')}): "
|
||||
f"{overlap.get('name', '')}"
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
warnings.append(
|
||||
Warning(
|
||||
|
|
@ -326,8 +378,7 @@ def compute_gate_verdict(
|
|||
# NSPD dump path: subcategory-based logic (backward-compat).
|
||||
# subcategory из _get_zouit_overlaps = `subcategory or type_zone` → может быть
|
||||
# строкой ('17') или fallback на type_zone (#1360). Нормализуем к int как
|
||||
# _get_zouit_engineering_overlaps, иначе blocker subcategory-17 тихо
|
||||
# деградирует до warning.
|
||||
# _get_zouit_engineering_overlaps, иначе sub-17 тихо деградирует.
|
||||
sub_raw = overlap.get("subcategory")
|
||||
sub: int | None
|
||||
if isinstance(sub_raw, bool):
|
||||
|
|
@ -338,12 +389,8 @@ def compute_gate_verdict(
|
|||
except (ValueError, TypeError):
|
||||
sub = None
|
||||
if sub is not None and sub in BLOCKER_SUBCATEGORIES:
|
||||
blockers.append(
|
||||
Blocker(
|
||||
code=f"ZOUIT_OVERLAP_SUB{sub}",
|
||||
detail=f"{BLOCKER_SUBCATEGORIES[sub]}: {overlap.get('name', '')}",
|
||||
)
|
||||
)
|
||||
# Охранная зона инж.коммуникаций — копим для area-gate (см. ниже).
|
||||
sub17_overlaps.append(overlap)
|
||||
else:
|
||||
# Сохраняем исходную метку (str/int) в warning-коде если нормализация
|
||||
# не дала int — иначе теряется человекочитаемый ярлык subcategory.
|
||||
|
|
@ -360,6 +407,70 @@ def compute_gate_verdict(
|
|||
)
|
||||
)
|
||||
|
||||
# Area-gate aggregation — NSPD sub-17 (охранная зона инж.сетей).
|
||||
if sub17_overlaps:
|
||||
coverage = _aggregate_coverage(sub17_overlaps)
|
||||
pct = _coverage_pct_label(coverage)
|
||||
if coverage > threshold:
|
||||
blockers.append(
|
||||
Blocker(
|
||||
code="ZOUIT_OVERLAP_SUB17",
|
||||
detail=(
|
||||
f"Охранная зона инж.сетей покрывает {pct}% участка — "
|
||||
f"застройка МКД невозможна"
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
warnings.append(
|
||||
Warning(
|
||||
code="ZOUIT_ENGINEERING_PARTIAL",
|
||||
detail=(
|
||||
f"Охранная зона инж.сетей (ЛЭП/газ/трубопровод) на {pct}% участка — "
|
||||
f"учесть при посадке МКД"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Area-gate aggregation — cad_zouit утилитарная охранная зона (сеть/keyword-blocker).
|
||||
if cad_utility_overlaps:
|
||||
coverage = _aggregate_coverage(cad_utility_overlaps)
|
||||
pct = _coverage_pct_label(coverage)
|
||||
# Код-различение сетевого обременения (#1070) vs общего охранного keyword-blocker:
|
||||
# blocker'у с network_kind отдаём ZOUIT_NETWORK_OBREMENENIE, иначе ZOUIT_CAD_BLOCKER.
|
||||
is_network = cad_utility_label is not None
|
||||
if coverage > threshold:
|
||||
if is_network:
|
||||
blockers.append(
|
||||
Blocker(
|
||||
code="ZOUIT_NETWORK_OBREMENENIE",
|
||||
detail=(
|
||||
f"Сетевое обременение ({cad_utility_label}) покрывает {pct}% "
|
||||
f"участка — застройка МКД невозможна"
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
blockers.append(
|
||||
Blocker(
|
||||
code="ZOUIT_CAD_BLOCKER",
|
||||
detail=(
|
||||
f"Охранная зона инж.сетей покрывает {pct}% участка — "
|
||||
f"застройка МКД невозможна"
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
warnings.append(
|
||||
Warning(
|
||||
code="ZOUIT_ENGINEERING_PARTIAL",
|
||||
detail=(
|
||||
f"Охранная зона инж.сетей (ЛЭП/газ/трубопровод) на {pct}% участка — "
|
||||
f"учесть при посадке МКД"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Check 3 — Engineering nearby (warning only, not a blocker)
|
||||
checks.append(f"Инженерные сети в радиусе {ENGINEERING_NEARBY_THRESHOLD_M}м")
|
||||
if not (nspd_engineering_nearby or []):
|
||||
|
|
|
|||
|
|
@ -118,6 +118,24 @@ def derive_quarter_cad(cad_num: str) -> str | None:
|
|||
return quarter
|
||||
|
||||
|
||||
def _coerce_coverage(raw: Any) -> float | None:
|
||||
"""Нормализовать SQL coverage_pct (доля участка под ЗОУИТ) в float 0..1 | None.
|
||||
|
||||
NULL (делитель=0 через NULLIF) / NaN / нечисловое → None (gate трактует как 0 →
|
||||
warning). Клампим в [0, 1]: floating-point дрейф ST_Area/ST_Intersection может дать
|
||||
1.0000001 или крошечный отрицательный остаток на касании границ.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if math.isnan(val):
|
||||
return None
|
||||
return max(0.0, min(1.0, val))
|
||||
|
||||
|
||||
def get_quarter_dump_data(
|
||||
db: Session,
|
||||
cad_num: str,
|
||||
|
|
@ -367,11 +385,29 @@ def _get_zouit_overlaps(
|
|||
nspd_has_zouit = layer_counts is None or layer_counts.get("zouit_count", 1) != 0
|
||||
if nspd_has_zouit:
|
||||
try:
|
||||
# coverage_pct — доля площади участка, покрытая ЗОУИТ (0..1). Считаем по
|
||||
# geography для корректных м². Делитель — площадь участка; guard от деления на
|
||||
# ноль через NULLIF(...,0) → NULL → coverage_pct None в Python. Zone-4326
|
||||
# выражение совпадает с ST_Intersects ниже (transform 3857→4326).
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT feat.value->>'layer' AS layer,
|
||||
feat.value->'properties' AS props
|
||||
feat.value->'properties' AS props,
|
||||
ST_Area(
|
||||
ST_Intersection(
|
||||
ST_Transform(
|
||||
ST_SetSRID(
|
||||
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||
3857
|
||||
),
|
||||
4326
|
||||
),
|
||||
ST_GeomFromText(:wkt, 4326)
|
||||
)::geography
|
||||
) / NULLIF(
|
||||
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
|
||||
) AS coverage_pct
|
||||
FROM nspd_quarter_dumps d,
|
||||
jsonb_array_elements(d.features_json) AS feat(value)
|
||||
WHERE d.quarter_cad = :q
|
||||
|
|
@ -404,6 +440,7 @@ def _get_zouit_overlaps(
|
|||
"layer": layer,
|
||||
"subcategory": props.get("subcategory") or props.get("type_zone"),
|
||||
"name": props.get("name") or props.get("object_name"),
|
||||
"coverage_pct": _coerce_coverage(r[2]),
|
||||
"raw_props": props,
|
||||
"source": "nspd-quarter-dump",
|
||||
}
|
||||
|
|
@ -425,6 +462,10 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
|
|||
Возвращает тот же формат что nspd-dump path (с доп. полем source='cad_zouit').
|
||||
"""
|
||||
try:
|
||||
# coverage_pct — доля площади участка под ЗОУИТ (0..1). cad_zouit.geom уже
|
||||
# GEOGRAPHY(4326) → ST_Intersection требует geometry-операндов, поэтому intersect
|
||||
# считаем в planar 4326 (CAST(... AS geometry)), затем результат и делитель кастуем
|
||||
# в ::geography для корректных м². NULLIF guard от деления на ноль → coverage None.
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -433,7 +474,15 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
|
|||
name_by_doc AS name,
|
||||
reg_numb_border,
|
||||
id AS zouit_id,
|
||||
ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson
|
||||
ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson,
|
||||
ST_Area(
|
||||
ST_Intersection(
|
||||
CAST(geom AS geometry),
|
||||
ST_GeomFromText(:wkt, 4326)
|
||||
)::geography
|
||||
) / NULLIF(
|
||||
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
|
||||
) AS coverage_pct
|
||||
FROM cad_zouit
|
||||
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
|
||||
ORDER BY id
|
||||
|
|
@ -472,6 +521,8 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
|
|||
# #255: geom для ZouitLayer на Leaflet (frontend JSON.parse → <GeoJSON>).
|
||||
# geom — GEOGRAPHY, ST_AsGeoJSON требует geometry → CAST(... AS geometry).
|
||||
"geom_geojson": r[5],
|
||||
# Area-gate: доля участка под зоной (0..1), None при отсутствии geom.
|
||||
"coverage_pct": _coerce_coverage(r[6]),
|
||||
}
|
||||
)
|
||||
logger.info("cad_zouit fallback: found %d overlaps for wkt prefix", len(result))
|
||||
|
|
|
|||
|
|
@ -496,7 +496,7 @@ def test_cad_zouit_fallback_fires_when_no_dump() -> None:
|
|||
Ожидаем: nspd_zouit_overlaps непустой, source='cad_zouit'.
|
||||
"""
|
||||
cad_zouit_rows: list[tuple[Any, ...]] = [
|
||||
# (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson)
|
||||
# (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson, coverage_pct)
|
||||
(
|
||||
"Охранная зона трубопровода",
|
||||
"Трубопровод",
|
||||
|
|
@ -504,6 +504,7 @@ def test_cad_zouit_fallback_fires_when_no_dump() -> None:
|
|||
"66-66-00/123",
|
||||
42,
|
||||
'{"type":"Polygon","coordinates":[[[60,56],[61,56],[61,57],[60,56]]]}',
|
||||
0.15,
|
||||
),
|
||||
]
|
||||
db = _make_mock_db_no_dump_with_cad_zouit(cad_zouit_rows)
|
||||
|
|
@ -516,6 +517,7 @@ def test_cad_zouit_fallback_fires_when_no_dump() -> None:
|
|||
assert overlaps[0]["source"] == "cad_zouit"
|
||||
assert overlaps[0]["type_zone"] == "Охранная зона трубопровода"
|
||||
assert overlaps[0]["name"] == "ГВС ул. Ленина"
|
||||
assert overlaps[0]["coverage_pct"] == 0.15
|
||||
# dump.available остаётся False (нет дампа)
|
||||
assert result["nspd_dump"]["available"] is False
|
||||
|
||||
|
|
@ -547,7 +549,7 @@ def test_cad_zouit_fallback_empty_when_no_overlaps() -> None:
|
|||
def test_get_cad_zouit_overlaps_returns_correct_schema() -> None:
|
||||
"""_get_cad_zouit_overlaps: поля group_key/layer/source/type_zone корректны."""
|
||||
rows: list[tuple[Any, ...]] = [
|
||||
# (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson)
|
||||
# (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson, coverage_pct)
|
||||
(
|
||||
"Охранная зона ЛЭП 110кВ",
|
||||
"Электроснабжение",
|
||||
|
|
@ -555,8 +557,9 @@ def test_get_cad_zouit_overlaps_returns_correct_schema() -> None:
|
|||
"66-123",
|
||||
7,
|
||||
'{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}',
|
||||
0.42,
|
||||
),
|
||||
("СЗЗ промышленного объекта", "Санитарная", "Завод", "66-456", 8, None),
|
||||
("СЗЗ промышленного объекта", "Санитарная", "Завод", "66-456", 8, None, None),
|
||||
]
|
||||
db = _make_mock_db_with_rows(rows)
|
||||
|
||||
|
|
@ -570,10 +573,13 @@ def test_get_cad_zouit_overlaps_returns_correct_schema() -> None:
|
|||
assert lep["subcategory"] is None # всегда NULL в cad_zouit
|
||||
assert lep["name"] == "ВЛ-110"
|
||||
assert lep["raw_props"]["zouit_id"] == 7
|
||||
# area-gate: coverage_pct проброшен (0..1) и нормализован
|
||||
assert lep["coverage_pct"] == 0.42
|
||||
# #255: geom_geojson проброшен для ZouitLayer (string из ST_AsGeoJSON)
|
||||
assert "geom_geojson" in lep
|
||||
assert lep["geom_geojson"].startswith('{"type":"Polygon"')
|
||||
assert result[1]["geom_geojson"] is None # NULL geom не валит маппинг
|
||||
assert result[1]["coverage_pct"] is None # NULL coverage (делитель 0) → None
|
||||
|
||||
|
||||
def test_nspd_path_used_when_zouit_count_nonzero() -> None:
|
||||
|
|
@ -600,8 +606,8 @@ def test_nspd_path_used_when_zouit_count_nonzero() -> None:
|
|||
][i]
|
||||
|
||||
nspd_zouit_rows: list[tuple[Any, ...]] = [
|
||||
# (layer, props)
|
||||
("zouit_engineering", {"name": "ЛЭП", "subcategory": 17}),
|
||||
# (layer, props, coverage_pct)
|
||||
("zouit_engineering", {"name": "ЛЭП", "subcategory": 17}, 0.33),
|
||||
]
|
||||
|
||||
db = MagicMock()
|
||||
|
|
@ -634,5 +640,7 @@ def test_nspd_path_used_when_zouit_count_nonzero() -> None:
|
|||
overlaps: list[dict[str, Any]] = result["nspd_zouit_overlaps"]
|
||||
assert len(overlaps) == 1
|
||||
assert overlaps[0]["source"] == "nspd-quarter-dump"
|
||||
# area-gate: coverage_pct проброшен из SQL (r[2]) и нормализован
|
||||
assert overlaps[0]["coverage_pct"] == 0.33
|
||||
# dump.available=True т.к. свежий дамп
|
||||
assert result["nspd_dump"]["available"] is True
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
"""Tests for gate_verdict aggregator — pure function, no DB."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.site_finder.gate_verdict import (
|
||||
_aggregate_coverage,
|
||||
_coverage_of,
|
||||
compute_gate_verdict,
|
||||
is_residential_zone,
|
||||
mkd_permitted_from_vri,
|
||||
|
|
@ -183,14 +187,44 @@ def test_production_zone_blocks():
|
|||
assert any(b["code"] == "PZZ_NOT_RESIDENTIAL" for b in verdict["blockers"])
|
||||
|
||||
|
||||
def test_zouit_subcategory_17_blocks():
|
||||
"""ЗОУИТ subcategory=17 (охранная зона ЛЭП) блокирует даже жилую зону."""
|
||||
def test_zouit_subcategory_17_full_coverage_blocks():
|
||||
"""ЗОУИТ sub-17 покрывающий весь участок (coverage 0.9 > порога) → blocker."""
|
||||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578"}]
|
||||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578", "coverage_pct": 0.9}]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||
assert verdict["can_build_mkd"] is False
|
||||
assert any("ZOUIT_OVERLAP_SUB17" == b["code"] for b in verdict["blockers"])
|
||||
assert "ЛЭП 110кВ" in verdict["blockers"][0]["detail"]
|
||||
assert "90%" in verdict["blockers"][0]["detail"]
|
||||
|
||||
|
||||
def test_zouit_subcategory_17_thin_strip_is_warning_not_blocker():
|
||||
"""ЗОУИТ sub-17 тонкой полосой (coverage 0.06 < порога) → warning, НЕ blocker (THE FIX).
|
||||
|
||||
Охранная зона инж.сети на 6% участка ограничивает застройку внутри полосы, но МКД
|
||||
сажается на остатке — раньше это был безусловный hard-block (исходный баг).
|
||||
"""
|
||||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578", "coverage_pct": 0.06}]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||||
assert verdict["can_build_mkd"] is True
|
||||
assert verdict["blockers"] == []
|
||||
assert any(w["code"] == "ZOUIT_ENGINEERING_PARTIAL" for w in verdict["warnings"])
|
||||
partial = next(w for w in verdict["warnings"] if w["code"] == "ZOUIT_ENGINEERING_PARTIAL")
|
||||
assert "6%" in partial["detail"]
|
||||
|
||||
|
||||
def test_zouit_subcategory_17_no_coverage_treated_as_zero_warning():
|
||||
"""ЗОУИТ sub-17 БЕЗ coverage_pct (legacy/synthetic) → трактуется как 0 → warning.
|
||||
|
||||
Намеренно: dump-путь всегда проставляет coverage_pct, отсутствие ключа = синтетика
|
||||
без гео-данных. Без площади не доказать стерилизацию → warning, не hard-block.
|
||||
"""
|
||||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578"}]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||||
assert verdict["can_build_mkd"] is True
|
||||
assert verdict["blockers"] == []
|
||||
assert any(w["code"] == "ZOUIT_ENGINEERING_PARTIAL" for w in verdict["warnings"])
|
||||
|
||||
|
||||
def test_zouit_non_blocker_subcategory_is_warning():
|
||||
|
|
@ -236,9 +270,9 @@ def test_no_engineering_plus_real_warning_still_downgrades():
|
|||
|
||||
|
||||
def test_no_engineering_real_blocker_still_rejects():
|
||||
"""Реальный блокер (охранная зона ЛЭП) при отсутствии сетей → «Нельзя» (#1617)."""
|
||||
"""Реальный блокер (охранная зона ЛЭП покрывает участок) при отсутствии сетей → «Нельзя»."""
|
||||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578"}]
|
||||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578", "coverage_pct": 0.85}]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||
assert verdict["can_build_mkd"] is False
|
||||
assert verdict["verdict_label"] == "Нельзя"
|
||||
|
|
@ -272,12 +306,11 @@ def test_pzz_unknown_warning_when_no_zoning():
|
|||
# ── cad_zouit fallback path (#232) ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
|
||||
"""cad_zouit 'охранная зона трубопроводов' → сетевое обременение (газ) → Нельзя (#1070).
|
||||
def test_cad_zouit_ohranaya_zona_truboprovodov_full_coverage_blocks():
|
||||
"""cad_zouit газопровод покрывающий весь участок (coverage > порога) → blocker (#1070).
|
||||
|
||||
После #1070 трубопроводы/газ классифицируются как ZOUIT_NETWORK_OBREMENENIE
|
||||
(более специфичный код, чем прежний ZOUIT_CAD_BLOCKER) — поведение «блокирует МКД»
|
||||
сохранено.
|
||||
Трубопроводы/газ классифицируются как ZOUIT_NETWORK_OBREMENENIE, но теперь AREA-GATED:
|
||||
полное покрытие → blocker.
|
||||
"""
|
||||
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
|
||||
overlaps = [
|
||||
|
|
@ -286,6 +319,7 @@ def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
|
|||
"type_zone": "Охранная зона трубопроводов (газопроводов, нефтепроводов)",
|
||||
"layer": "Охранная зона трубопроводов",
|
||||
"name": "Газопровод высокого давления",
|
||||
"coverage_pct": 0.8,
|
||||
}
|
||||
]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||
|
|
@ -294,8 +328,26 @@ def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
|
|||
assert any(b["code"] == "ZOUIT_NETWORK_OBREMENENIE" for b in verdict["blockers"])
|
||||
|
||||
|
||||
def test_cad_zouit_elektr_blocks():
|
||||
"""cad_zouit электросетевая охранная зона → сетевое обременение (электро) → blocker (#1070)."""
|
||||
def test_cad_zouit_ohranaya_zona_thin_strip_is_warning():
|
||||
"""cad_zouit газопровод тонкой полосой (coverage 0.1 < порога) → warning, не blocker."""
|
||||
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
|
||||
overlaps = [
|
||||
{
|
||||
"source": "cad_zouit",
|
||||
"type_zone": "Охранная зона трубопроводов (газопроводов, нефтепроводов)",
|
||||
"layer": "Охранная зона трубопроводов",
|
||||
"name": "Газопровод высокого давления",
|
||||
"coverage_pct": 0.1,
|
||||
}
|
||||
]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||||
assert verdict["can_build_mkd"] is True
|
||||
assert verdict["blockers"] == []
|
||||
assert any(w["code"] == "ZOUIT_ENGINEERING_PARTIAL" for w in verdict["warnings"])
|
||||
|
||||
|
||||
def test_cad_zouit_elektr_full_coverage_blocks():
|
||||
"""cad_zouit электросетевая охранная зона с полным покрытием → blocker (#1070)."""
|
||||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||
overlaps = [
|
||||
{
|
||||
|
|
@ -303,6 +355,7 @@ def test_cad_zouit_elektr_blocks():
|
|||
"type_zone": "Охранная зона электросетевого объекта",
|
||||
"layer": "Охранная зона электросетевого объекта",
|
||||
"name": "ЛЭП 110 кВ",
|
||||
"coverage_pct": 0.7,
|
||||
}
|
||||
]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||
|
|
@ -346,7 +399,7 @@ def test_cad_zouit_other_is_warning():
|
|||
|
||||
|
||||
def test_nspd_subcategory_path_backward_compat():
|
||||
"""NSPD dump path (source != 'cad_zouit') продолжает работать через subcategory."""
|
||||
"""NSPD dump path (source != 'cad_zouit') sub-17 с полным покрытием → blocker."""
|
||||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||
overlaps = [
|
||||
{
|
||||
|
|
@ -354,6 +407,7 @@ def test_nspd_subcategory_path_backward_compat():
|
|||
"subcategory": 17,
|
||||
"name": "ЛЭП 110кВ",
|
||||
"layer": "37578",
|
||||
"coverage_pct": 0.75,
|
||||
}
|
||||
]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||
|
|
@ -507,3 +561,116 @@ def test_verdict_main_vri_enables_ts2_can_build():
|
|||
assert verdict["can_build_mkd"] is True
|
||||
assert verdict["verdict_label"] == "Можно"
|
||||
assert verdict["blockers"] == []
|
||||
|
||||
|
||||
# ── ЗОУИТ engineering area-gate: coverage helpers + aggregation ────────────────
|
||||
|
||||
|
||||
def test_coverage_of_present():
|
||||
assert _coverage_of({"coverage_pct": 0.42}) == 0.42
|
||||
|
||||
|
||||
def test_coverage_of_missing_key_is_zero():
|
||||
"""Legacy/synthetic overlap без coverage_pct → 0.0 (намеренно → warning, не blocker)."""
|
||||
assert _coverage_of({"name": "ЛЭП"}) == 0.0
|
||||
|
||||
|
||||
def test_coverage_of_none_is_zero():
|
||||
assert _coverage_of({"coverage_pct": None}) == 0.0
|
||||
|
||||
|
||||
def test_coverage_of_non_numeric_is_zero():
|
||||
assert _coverage_of({"coverage_pct": "n/a"}) == 0.0
|
||||
|
||||
|
||||
def test_coverage_of_clamps_above_one():
|
||||
assert _coverage_of({"coverage_pct": 1.0000001}) == 1.0
|
||||
|
||||
|
||||
def test_aggregate_coverage_sum_below_threshold():
|
||||
"""coverage [0.05, 0.04] → сумма 0.09 — ниже дефолтного порога 0.6."""
|
||||
overlaps = [{"coverage_pct": 0.05}, {"coverage_pct": 0.04}]
|
||||
assert _aggregate_coverage(overlaps) == pytest.approx(0.09)
|
||||
|
||||
|
||||
def test_aggregate_coverage_single_big_strip():
|
||||
assert _aggregate_coverage([{"coverage_pct": 0.7}]) == pytest.approx(0.7)
|
||||
|
||||
|
||||
def test_aggregate_coverage_many_small_strips_sum_over_threshold():
|
||||
"""coverage [0.4, 0.4] → сумма 0.8 > 0.6 (агрегация мелких полос в blocker)."""
|
||||
overlaps = [{"coverage_pct": 0.4}, {"coverage_pct": 0.4}]
|
||||
assert _aggregate_coverage(overlaps) == pytest.approx(0.8)
|
||||
|
||||
|
||||
def test_aggregate_coverage_clamps_to_one():
|
||||
"""Сумма > 1 клампится в 1.0 (overlapping strips двойной счёт)."""
|
||||
overlaps = [{"coverage_pct": 0.7}, {"coverage_pct": 0.6}]
|
||||
assert _aggregate_coverage(overlaps) == 1.0
|
||||
|
||||
|
||||
def test_aggregate_coverage_none_treated_as_zero():
|
||||
overlaps = [{"coverage_pct": None}, {"coverage_pct": None}]
|
||||
assert _aggregate_coverage(overlaps) == 0.0
|
||||
|
||||
|
||||
# ── ЗОУИТ engineering area-gate: verdict-level (THE FIX) ───────────────────────
|
||||
|
||||
|
||||
def test_verdict_residential_sub17_thin_strip_can_build():
|
||||
"""Жилая зона (main_vri МКД) + sub-17 покрытие 0.06 + нет др. блокеров →
|
||||
can_build_mkd=True с warning ZOUIT_ENGINEERING_PARTIAL (THE FIX — раньше было False)."""
|
||||
nspd_zoning = {
|
||||
"zone_code": "Ж-3",
|
||||
"zone_name": "Жилая Ж-3",
|
||||
"raw_props": None,
|
||||
"main_vri": _VRI_HAS_MKD_25,
|
||||
}
|
||||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578", "coverage_pct": 0.06}]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||||
assert verdict["can_build_mkd"] is True
|
||||
assert verdict["blockers"] == []
|
||||
assert any(w["code"] == "ZOUIT_ENGINEERING_PARTIAL" for w in verdict["warnings"])
|
||||
|
||||
|
||||
def test_verdict_residential_sub17_full_coverage_blocks():
|
||||
"""Та же жилая зона + sub-17 покрытие 0.8 → can_build_mkd=False с blocker (стерилизован)."""
|
||||
nspd_zoning = {
|
||||
"zone_code": "Ж-3",
|
||||
"zone_name": "Жилая Ж-3",
|
||||
"raw_props": None,
|
||||
"main_vri": _VRI_HAS_MKD_25,
|
||||
}
|
||||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578", "coverage_pct": 0.8}]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||||
assert verdict["can_build_mkd"] is False
|
||||
assert verdict["verdict_label"] == "Нельзя"
|
||||
assert any(b["code"] == "ZOUIT_OVERLAP_SUB17" for b in verdict["blockers"])
|
||||
assert "80%" in verdict["blockers"][0]["detail"]
|
||||
|
||||
|
||||
def test_verdict_sub17_many_small_strips_aggregate_blocks():
|
||||
"""Несколько мелких sub-17 полос (0.4 + 0.4 = 0.8 > 0.6) → ОДИН blocker (агрегация)."""
|
||||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||
overlaps = [
|
||||
{"subcategory": 17, "name": "ЛЭП A", "layer": "37578", "coverage_pct": 0.4},
|
||||
{"subcategory": 17, "name": "ЛЭП B", "layer": "37578", "coverage_pct": 0.4},
|
||||
]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||
assert verdict["can_build_mkd"] is False
|
||||
sub17_blockers = [b for b in verdict["blockers"] if b["code"] == "ZOUIT_OVERLAP_SUB17"]
|
||||
assert len(sub17_blockers) == 1 # один агрегированный, не 4× дубля
|
||||
|
||||
|
||||
def test_verdict_sub17_two_thin_strips_sum_below_threshold_warning():
|
||||
"""Две тонкие sub-17 полосы (0.05 + 0.04 = 0.09 < 0.6) → ОДИН warning, не blocker."""
|
||||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||
overlaps = [
|
||||
{"subcategory": 17, "name": "ЛЭП A", "layer": "37578", "coverage_pct": 0.05},
|
||||
{"subcategory": 17, "name": "ЛЭП B", "layer": "37578", "coverage_pct": 0.04},
|
||||
]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||||
assert verdict["can_build_mkd"] is True
|
||||
assert verdict["blockers"] == []
|
||||
partials = [w for w in verdict["warnings"] if w["code"] == "ZOUIT_ENGINEERING_PARTIAL"]
|
||||
assert len(partials) == 1
|
||||
|
|
|
|||
|
|
@ -89,7 +89,10 @@ def _dump_available() -> dict:
|
|||
|
||||
|
||||
def test_gate_verdict_thermal_network_blocker():
|
||||
"""Теплосеть (#1070 ООО Брик) → ZOUIT_NETWORK_OBREMENENIE blocker."""
|
||||
"""Теплосеть (#1070 ООО Брик) покрывающая участок → ZOUIT_NETWORK_OBREMENENIE blocker.
|
||||
|
||||
Area-gated: при полном покрытии (coverage > порога) сетевое обременение блокирует.
|
||||
"""
|
||||
overlaps = [
|
||||
{
|
||||
"source": "cad_zouit",
|
||||
|
|
@ -98,6 +101,7 @@ def test_gate_verdict_thermal_network_blocker():
|
|||
"is_network_zone": True,
|
||||
"network_kind": "thermal",
|
||||
"network_kind_label": "теплоснабжение",
|
||||
"coverage_pct": 0.8,
|
||||
}
|
||||
]
|
||||
verdict = compute_gate_verdict(None, overlaps, None, _dump_available())
|
||||
|
|
@ -128,12 +132,13 @@ def test_gate_verdict_servitude_not_network_blocker():
|
|||
|
||||
|
||||
def test_gate_verdict_network_classified_when_flag_absent():
|
||||
"""Back-compat: overlap без флага → gate_verdict классифицирует сам."""
|
||||
"""Back-compat: overlap без флага → gate_verdict классифицирует сам (area-gated blocker)."""
|
||||
overlaps = [
|
||||
{
|
||||
"source": "cad_zouit",
|
||||
"type_zone": "Охранная зона объектов электроэнергетики",
|
||||
"name": "ВЛ-10кВ",
|
||||
"coverage_pct": 0.75,
|
||||
}
|
||||
]
|
||||
verdict = compute_gate_verdict(None, overlaps, None, _dump_available())
|
||||
|
|
|
|||
|
|
@ -378,12 +378,22 @@ def _make_zouit_row(
|
|||
reg_numb_border: str | None,
|
||||
zouit_id: int,
|
||||
geom_geojson: str | None,
|
||||
coverage_pct: float | None = None,
|
||||
) -> tuple[Any, ...]:
|
||||
"""Строка cad_zouit в порядке SELECT _get_cad_zouit_overlaps.
|
||||
|
||||
(type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson)
|
||||
(type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson,
|
||||
coverage_pct) — coverage_pct добавлен area-gate'ом (доля участка под ЗОУИТ).
|
||||
"""
|
||||
return (type_zone, category_name, name, reg_numb_border, zouit_id, geom_geojson)
|
||||
return (
|
||||
type_zone,
|
||||
category_name,
|
||||
name,
|
||||
reg_numb_border,
|
||||
zouit_id,
|
||||
geom_geojson,
|
||||
coverage_pct,
|
||||
)
|
||||
|
||||
|
||||
def test_cad_zouit_overlaps_includes_geom_geojson() -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue