diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py index b49d976a..b860a46c 100644 --- a/backend/app/services/site_finder/gate_verdict.py +++ b/backend/app/services/site_finder/gate_verdict.py @@ -7,6 +7,7 @@ Pure function — no DB dependencies, consumes already-fetched data from analyze from __future__ import annotations +import re from typing import Literal, TypedDict from app.services.site_finder.network_obremenenie import ( @@ -16,11 +17,28 @@ from app.services.site_finder.network_obremenenie import ( # ── Zone matching ───────────────────────────────────────────────────────────── -# Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5) -RESIDENTIAL_ZONE_PREFIXES = ("Ж-", "Ж1", "Ж2", "Ж3", "Ж4", "Ж5") -# Fallback по zone_name (lowercase substring) +# 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 = ("жил",) +# 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 которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода). @@ -71,19 +89,46 @@ class GateVerdict(TypedDict): # ── Helpers ─────────────────────────────────────────────────────────────────── -def is_residential_zone(zone_code: str | None, zone_name: str | None) -> bool: +def is_residential_zone( + zone_code: str | None, + zone_name: str | None, + raw_props: dict | None = None, +) -> bool: """Check if ПЗЗ zone permits residential (МКД) building. - Checks zone_code prefix first (exact ПЗЗ codes), then zone_name lowercase - substring match as fallback for non-standard zone labels. + Detection strategy (in priority order): + + 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 для нестандартных источников. """ - if zone_code: - if any(zone_code.startswith(p) for p in RESIDENTIAL_ZONE_PREFIXES): - return True + # 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 @@ -134,7 +179,11 @@ def compute_gate_verdict( # Check 1 — ПЗЗ residential zone checks.append("ПЗЗ зональность") if nspd_zoning: - if not is_residential_zone(nspd_zoning.get("zone_code"), nspd_zoning.get("zone_name")): + if not is_residential_zone( + nspd_zoning.get("zone_code"), + nspd_zoning.get("zone_name"), + nspd_zoning.get("raw_props"), + ): zone_label = ( nspd_zoning.get("zone_name") or nspd_zoning.get("zone_code") or "неизвестна" )