fix(gate): area-gate ЗОУИТ utility-easement blocker (sub-17) — снять over-block
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m52s
CI / backend-tests (pull_request) Successful in 11m34s

ZOUIT_OVERLAP_SUB17 (единственный subcategory-blocker = охранная зона
ЛЭП/газа/трубопровода) был ХАРД-блокером на ЛЮБОЕ пересечение → блокировал
434/574 (75.6%) участков как «нельзя строить МКД». Прод-замер: реальное
покрытие участка этими зонами median=6%, p90=47%, только 1/17 >60% — тонкие
инж-коридоры. По РФ охранная зона ограничивает застройку ВНУТРИ полосы, а не
весь участок (МКД сажается на незанятой части). Хард-блок на 6%-покрытии неверен
и (после фикса резолва зон #1891) прятал financial_estimate у ~90% участков.

Фикс: area-gate.
- quarter_dump_lookup._get_zouit_overlaps + _get_cad_zouit_overlaps: добавлен
  coverage_pct (доля площади участка под зоной, в geography м², NULLIF-guard).
- gate_verdict.compute_gate_verdict: sub-17 (и cad network/blocker) АГРЕГИРУЮТСЯ
  (sum-capped, консервативно к блоку) → блокер ТОЛЬКО если покрытие >
  settings.gate_zouit_engineering_blocker_min_coverage (0.6, env-tunable), иначе
  WARNING ZOUIT_ENGINEERING_PARTIAL с % покрытия. Больше нет 4× дубль-блокеров.
  СЗЗ/OKN/прочие ЗОУИТ — без изменений (warnings). main_vri/резидентность — без
  изменений.
- coverage_pct отсутствует (legacy/synthetic) → трактуем как 0 → warning
  (документировано: дамп всегда даёт coverage; ложный хард-блок дороже).

Тесты: +помесь gate/aggregation/coverage (131 combined passed); правка
_make_zouit_row (7-я колонка coverage_pct) в tests/test_quarter_dump_lookup.py.
Полный бэкенд локально: 2 failed → fixed → перепроверка. ruff чисто. Схема не
менялась (gate_verdict/overlaps — dict[str,Any], api-types regen не нужен).

Refs #1881
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Light1YT 2026-06-24 14:45:59 +05:00
parent 41d97aeaf9
commit bc1da8eac9
8 changed files with 421 additions and 59 deletions

Binary file not shown.

View file

@ -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 — при любом сбое/выключенности возвращается детерминированный

View file

@ -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 []):

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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())

View file

@ -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: