gendesign/backend/app/services/site_finder/gate_verdict.py
Light1YT bc1da8eac9
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
fix(gate): area-gate ЗОУИТ utility-easement blocker (sub-17) — снять over-block
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>
2026-06-24 14:45:59 +05:00

508 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Gate Verdict aggregator — собирает can-build-MKD signal из NSPD lookups.
Per #32 G5: user-facing answer на главный вопрос девелопера "Можно ли тут МКД?"
Foundation: nspd_zoning, nspd_zouit_overlaps, nspd_engineering_nearby (Sprint 1.1).
Pure function — no DB dependencies, consumes already-fetched data from analyze_parcel.
"""
from __future__ import annotations
import re
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,
)
# ── Zone matching ─────────────────────────────────────────────────────────────
# Regex для zone_code из PKK6 / pzz_zones_ekb: "Ж", "Ж-1", "Жс", "ЖС-2" и т.д.
# Кейс-инсенситив для защиты от "ж-1" в нестандартных источниках.
# NSPD quarter-dump в zone_code кладёт кадастровый рег.номер (напр. "66:41-7.14") —
# он никогда не начинается на "Ж", поэтому этот regex не даст false positive.
_RESIDENTIAL_CODE_RE = re.compile(r"^[Жж]", re.UNICODE)
# Fallback по zone_name (lowercase substring) — "жилая", "жилой", "жилая зона" и т.д.
RESIDENTIAL_KEYWORDS = ("жил",)
# ── main_vri (authoritative permitted-uses) ────────────────────────────────────
#
# zone_regulation_cache.main_vri несёт авторитетный список разрешённых ВРИ зоны
# (JSON list of strings, напр. "2.5 Среднеэтажная жилая застройка"). Это единственный
# источник, точно различающий ИЖС-зону (Ж-1/Ж-2, только код 2.1/2.2 — МКД нельзя) от
# МКД-зоны (Ж-3/Ж-4/Ж-5/Ц-2). regex ^Ж и subcategory — лишь эвристики:
# - Ж-1/Ж-2 = ИЖС → ^Ж даёт FALSE POSITIVE ("можно МКД" на ИЖС — реальный дефект)
# - Ц-2 разрешает МКД (коды 2.5/2.6), но нет префикса Ж → ^Ж даёт FALSE NEGATIVE
# МКД разрешён ⟺ main_vri содержит любой из: 2.1.1 (Малоэтажная многоквартирная),
# 2.5 (Среднеэтажная жилая), 2.6 (Многоэтажная жилая). Проверено на проде (true-set
# Ж-3/Ж-4/Ж-5/Ц-2; false: Ж-1/Ж-2/Ц-1/Ц-3/Ц-4/ЦС-1..4).
_MKD_VRI_CODES: frozenset[str] = frozenset({"2.1.1", "2.5", "2.6"})
def mkd_permitted_from_vri(main_vri: list[str] | None) -> bool | None:
"""Authoritative МКД-permission из main_vri (список разрешённых ВРИ зоны).
Returns:
None — main_vri отсутствует/пуст (нет информации, нужен fallback);
True — хотя бы один элемент имеет ведущий код-токен из _MKD_VRI_CODES;
False — иначе (ВРИ есть, но МКД-кодов среди них нет).
Матчинг по ТОЧНОМУ дотированному код-токену: элемент выглядит как
"2.5 Среднеэтажная жилая застройка" или "2.1.1\\n Малоэтажная многоквартирная …",
иногда просто "2.5". Берём первый whitespace-разделённый токен и сравниваем на
равенство с set — поэтому "2.50"/"2.59"/"12.0" НЕ матчат "2.5" (нет ложного
prefix-совпадения).
"""
if not main_vri:
return None
for elem in main_vri:
if not elem:
continue
# Первый whitespace-разделённый токен (split разбивает по \n/\t/пробелам).
token = str(elem).split(None, 1)[0] if str(elem).split() else ""
if token in _MKD_VRI_CODES:
return True
return False
# NSPD territorial zones layer 875838: subcategory (int) → тип зоны.
# Значения получены из реальных данных nspd_quarter_dumps (ЕКБ, 2026-02):
# subcategory=1 — 39 объектов — Жилые зоны малоэтажной застройки (ИЖС/таунхаусы)
# subcategory=2 — 221 объект — Жилые зоны (МКД разрешён, крупнейшая группа ЕКБ)
# subcategory=3 — 126 объектов — Зоны смешанного использования (МКД по ПЗЗ ЕКБ допустим)
# subcategory=4 — 77 объектов — Общественно-деловые
# subcategory=5 — 112 объектов — Производственные
# subcategory=6 — 92 объекта — Рекреационные
# subcategory=8 — 16 объектов — Специальные
# subcategory=10 — 113 объектов — Промышленные/транспортные
# subcategory=1 исключён намеренно: ИЖС-зона МКД не допускает.
NSPD_RESIDENTIAL_SUBCATEGORIES: frozenset[int] = frozenset({2, 3})
# ── ЗОУИТ taxonomy ────────────────────────────────────────────────────────────
# Subcategory codes охранных зон инж.сетей (ЛЭП/газа/трубопровода) из NSPD dump.
# ВНИМАНИЕ: это НЕ безусловные блокеры — они AREA-GATED в compute_gate_verdict
# (блокируют МКД только при суммарном покрытии участка > порога settings, иначе
# warning ZOUIT_ENGINEERING_PARTIAL). Эти зоны ограничивают застройку ВНУТРИ полосы,
# а не стерилизуют весь участок. dict-значение — человекочитаемый ярлык подкатегории.
BLOCKER_SUBCATEGORIES: dict[int, str] = {
17: "Инженерные коммуникации (охранная зона ЛЭП/газа/трубопровода)",
}
# Keyword-based blockers для cad_zouit fallback (#232).
# cad_zouit.subcategory = 100% NULL, поэтому классификация по type_zone substring.
# Порядок: lowercase, substring match (any wins → blocker).
BLOCKER_TYPE_ZONE_KEYWORDS: tuple[str, ...] = (
"охранная зона",
"трубопровод",
"электр",
"газ",
)
# type_zone substrings, которые дают warning (не blocker) из cad_zouit.
WARNING_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ("сзз", "санитарно-защитная")
# Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py
ENGINEERING_NEARBY_THRESHOLD_M = 200
# Warning codes that are purely informational — they appear in warnings[] for the user
# but do NOT downgrade the final verdict from «Можно» to «С ограничениями» (#1617).
# A clean residential parcel with only these warnings still gets verdict_label="Можно".
INFORMATIONAL_WARNING_CODES: frozenset[str] = frozenset({"NO_ENGINEERING_NEARBY"})
# ── TypedDicts ────────────────────────────────────────────────────────────────
class Blocker(TypedDict):
code: str
detail: str
class Warning(TypedDict):
code: str
detail: str
class GateVerdict(TypedDict):
can_build_mkd: bool | Literal["unknown"]
verdict_label: str # "Можно" | "Нельзя" | "С ограничениями" | "Нужна проверка"
blockers: list[Blocker]
warnings: list[Warning]
checks_performed: list[str]
source: Literal["nspd_dump", "nspd_dump_partial", "no_data"]
# ── Helpers ───────────────────────────────────────────────────────────────────
def is_residential_zone(
zone_code: str | None,
zone_name: str | None,
raw_props: dict | None = None,
main_vri: list[str] | None = None,
) -> bool:
"""Check if ПЗЗ zone permits residential (МКД) building.
Detection strategy (in priority order):
0. main_vri (AUTHORITATIVE) — авторитетный список разрешённых ВРИ зоны из
zone_regulation_cache. Если непустой → решение принимается ТОЛЬКО по нему
(mkd_permitted_from_vri), перебивая regex/subcategory/keyword. Это и подавляет
FALSE POSITIVE на ИЖС (Ж-1/Ж-2), и включает FALSE NEGATIVE (Ц-2 без префикса Ж).
1. zone_code regex «^Ж» — ПЗЗ коды PKK6/pzz_zones_ekb: "Ж-1", "Жс", "ЖС-2" и т.д.
NSPD quarter-dump кладёт в zone_code кадастровый рег.номер ("66:41-7.14") —
он не начинается на «Ж», ложных срабатываний нет.
2. NSPD subcategory из raw_props — единственный надёжный тип-дискриминатор в
реальных данных nspd_quarter_dumps (layer 875838, ЕКБ 2026-02):
subcategory=2 ("Жилые зоны") и subcategory=3 ("Смешанное использование")
допускают МКД по ПЗЗ ЕКБ. subcategory=1 (ИЖС) — намеренно исключён.
3. zone_name substring «жил» — fallback для нестандартных источников.
Эвристики 1-3 применяются ТОЛЬКО как fallback когда main_vri None/пуст
(dump-путь без geoportal main_vri). main_vri по умолчанию None — backward-compat
для существующих вызовов/тестов.
"""
# 0. main_vri — authoritative permitted-uses (overrides all heuristics)
if main_vri:
verdict = mkd_permitted_from_vri(main_vri)
if verdict is not None:
return verdict
# 1. PKK6-style zone_code: starts with Ж (case-insensitive)
if zone_code and _RESIDENTIAL_CODE_RE.match(zone_code):
return True
# 2. NSPD subcategory from raw_props (primary discriminator for quarter-dump data)
if raw_props:
sub_raw = raw_props.get("subcategory")
if sub_raw is not None:
try:
if int(sub_raw) in NSPD_RESIDENTIAL_SUBCATEGORIES:
return True
except (ValueError, TypeError):
pass
# 3. zone_name substring fallback
if zone_name:
zn_lower = zone_name.lower()
if any(kw in zn_lower for kw in RESIDENTIAL_KEYWORDS):
return True
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 ───────────────────────────────────────────────────────────
def compute_gate_verdict(
nspd_zoning: dict | None,
nspd_zouit_overlaps: list[dict] | None,
nspd_engineering_nearby: list[dict] | None,
nspd_dump: dict | None,
) -> GateVerdict:
"""Aggregate NSPD lookups into can-build-MKD verdict.
Checks performed (in order):
1. ПЗЗ residential zone match (blocker if non-residential)
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.
"""
blockers: list[Blocker] = []
warnings: list[Warning] = []
checks: list[str] = []
# Guard: no NSPD data at all
dump_available = (nspd_dump or {}).get("available", False)
dump_stale = (nspd_dump or {}).get("stale", False)
if not dump_available:
return GateVerdict(
can_build_mkd="unknown",
verdict_label="Нужна проверка",
blockers=[],
warnings=[
Warning(
code="NO_NSPD_DUMP",
detail="Данные NSPD ещё не подгружены",
)
],
checks_performed=[],
source="no_data",
)
source: Literal["nspd_dump", "nspd_dump_partial"] = (
"nspd_dump_partial" if dump_stale else "nspd_dump"
)
# Check 1 — ПЗЗ residential zone
checks.append("ПЗЗ зональность")
if nspd_zoning:
if not is_residential_zone(
nspd_zoning.get("zone_code"),
nspd_zoning.get("zone_name"),
nspd_zoning.get("raw_props"),
nspd_zoning.get("main_vri"),
):
zone_label = (
nspd_zoning.get("zone_name") or nspd_zoning.get("zone_code") or "неизвестна"
)
blockers.append(
Blocker(
code="PZZ_NOT_RESIDENTIAL",
detail=f"Зона: {zone_label}",
)
)
else:
warnings.append(
Warning(
code="PZZ_UNKNOWN",
detail="ПЗЗ зона не определена",
)
)
# 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":
# 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()
# #1070: классификация «сетевого обременения» (охранная зона инж.сети).
# Флаг приходит из _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"))
# Порядок веток: СЗЗ-warning ПЕРВЫМ (#1341) — маркер 'сзз'/'санитарно-защитная'
# специфичнее широких blocker-подстрок ('газ'/'электр'), СЗЗ газораспределительной
# станции/электроподстанции = ограничение, а не hard-blocker. Затем сетевое
# обременение (#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(
code="ZOUIT_CAD_SZZ",
detail=(
f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}"
),
)
)
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
)
else:
warnings.append(
Warning(
code="ZOUIT_CAD_OTHER",
detail=(
f"ЗОУИТ cad ({overlap.get('type_zone', '')}): "
f"{overlap.get('name', '')}"
),
)
)
else:
# 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, иначе sub-17 тихо деградирует.
sub_raw = overlap.get("subcategory")
sub: int | None
if isinstance(sub_raw, bool):
sub = None
else:
try:
sub = int(sub_raw) # type: ignore[arg-type]
except (ValueError, TypeError):
sub = None
if sub is not None and sub in BLOCKER_SUBCATEGORIES:
# Охранная зона инж.коммуникаций — копим для area-gate (см. ниже).
sub17_overlaps.append(overlap)
else:
# Сохраняем исходную метку (str/int) в warning-коде если нормализация
# не дала int — иначе теряется человекочитаемый ярлык subcategory.
if sub is not None:
sub_label: int | str = sub
elif sub_raw is not None:
sub_label = sub_raw
else:
sub_label = "unknown"
warnings.append(
Warning(
code=f"ZOUIT_SUB{sub_label}",
detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}",
)
)
# 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 []):
warnings.append(
Warning(
code="NO_ENGINEERING_NEARBY",
detail=f"Сетей не найдено в радиусе {ENGINEERING_NEARBY_THRESHOLD_M}м",
)
)
# Final verdict.
# Informational warnings (e.g. NO_ENGINEERING_NEARBY) appear in warnings[] for display
# but do NOT by themselves downgrade a clean parcel to «С ограничениями» (#1617).
verdict_relevant_warnings = [
w for w in warnings if w["code"] not in INFORMATIONAL_WARNING_CODES
]
can_build: bool | Literal["unknown"]
if blockers:
can_build = False
label = "Нельзя"
elif verdict_relevant_warnings:
can_build = True
label = "С ограничениями"
else:
can_build = True
label = "Можно"
return GateVerdict(
can_build_mkd=can_build,
verdict_label=label,
blockers=blockers,
warnings=warnings,
checks_performed=checks,
source=source,
)