diff --git a/backend/.coverage b/backend/.coverage index 1370b437..bd524d7c 100644 Binary files a/backend/.coverage and b/backend/.coverage differ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6e160230..dfb97f06 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 — при любом сбое/выключенности возвращается детерминированный diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py index 239eaaec..00c833d5 100644 --- a/backend/app/services/site_finder/gate_verdict.py +++ b/backend/app/services/site_finder/gate_verdict.py @@ -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 []): diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 0aa6ab18..d8c6e005 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -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 → ). # 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)) diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 0b9da878..d98e1908 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -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 diff --git a/backend/tests/test_gate_verdict.py b/backend/tests/test_gate_verdict.py index 6553c598..bc5bd67c 100644 --- a/backend/tests/test_gate_verdict.py +++ b/backend/tests/test_gate_verdict.py @@ -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 diff --git a/backend/tests/test_network_obremenenie.py b/backend/tests/test_network_obremenenie.py index 97aa5cc9..68378112 100644 --- a/backend/tests/test_network_obremenenie.py +++ b/backend/tests/test_network_obremenenie.py @@ -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()) diff --git a/backend/tests/test_quarter_dump_lookup.py b/backend/tests/test_quarter_dump_lookup.py index 94fcc2f2..7563d8a9 100644 --- a/backend/tests/test_quarter_dump_lookup.py +++ b/backend/tests/test_quarter_dump_lookup.py @@ -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: