feat(analyze): real ПЗЗ КСИТ/height into nspd_zoning (ПТИЦА оживление, #1843 Route 2) #1847

Merged
lekss361 merged 1 commit from feat/ptica-real-ksit-backend into main 2026-06-20 18:27:39 +00:00
3 changed files with 319 additions and 0 deletions

View file

@ -53,6 +53,7 @@ from app.services.exporters.report_md import (
from app.services.exporters.report_pdf import export_report_pdf
from app.services.exporters.report_pptx import render_report_pptx
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
from app.services.site_finder.best_layouts import get_best_layouts
from app.services.site_finder.cadastre_fetch import (
cad_exists_in_db,
@ -96,6 +97,7 @@ from app.services.site_finder.weight_profiles import (
from app.services.site_finder.weight_profiles import (
resolve_weights as _resolve_weights,
)
from app.services.site_finder.zone_regulation import get_or_fetch_zone_regulation
from app.services.weather_cache import (
get_air_quality_cached,
get_seasonal_weather_cached,
@ -424,6 +426,11 @@ _GEOM_LABEL_MICRO_HA = 0.05 # ниже → label "микро" (комбинир
_GEOM_LABEL_GOOD = 0.7
_GEOM_LABEL_MEDIUM = 0.4
# ПЗЗ-градрегламент в analyze (#1067): bounded timeout (сек) для live-geoportal вызова
# на cache-miss. Короткий, чтобы медленный геопортал не вешал hot-path analyze —
# cache-hit (норма для ЕКБ) сетевых вызовов не делает вообще.
_ZONE_REGULATION_TIMEOUT_S = 3
def _polygon_suitability(geom_wkt: str) -> dict[str, Any]:
"""P1 (#45) — physical suitability участка по метрикам shape.
@ -2052,6 +2059,47 @@ def analyze_parcel(
# Independent dict per request — never mutate module singleton.
nspd_dump_data = make_empty_result()
# 9e-bis) Реальный ПЗЗ-градрегламент зоны → merge в nspd_zoning (Route 2, #1067).
# Резолвим числовые предельные параметры (КСИТ/max_far, высота, этажность,
# %застройки, min площадь ЗУ) по centroid через get_or_fetch_zone_regulation:
# cache-first (кэш прогрет для ЕКБ), live-geoportal только на miss и закапан
# коротким timeout'ом (_ZONE_REGULATION_TIMEOUT_S), чтобы медленный геопортал не
# вешал analyze. Hot-path-safe: любой сбой/таймаут → поля остаются None, analyze
# не падает. Существующие ключи nspd_zoning (zone_code/zone_name/source/raw_props)
# не трогаем — только ДОБАВЛЯЕМ regulation-поля.
_nspd_zoning = nspd_dump_data.get("nspd_zoning")
if (
settings.enable_zoning_regulation_in_analyze
and _nspd_zoning is not None
and centroid_lat is not None
and centroid_lon is not None
):
try:
_client = EKBGeoportalClient(timeout=_ZONE_REGULATION_TIMEOUT_S)
_regulation = get_or_fetch_zone_regulation(
db, centroid_lon, centroid_lat, client=_client
)
except Exception as e:
# Никогда не роняем analyze из-за регламента — деградируем в None.
logger.warning("zone regulation resolve failed for %s: %s", cad_num, e)
_regulation = None
if _regulation is not None:
_nspd_zoning["max_far"] = _regulation.get("max_far")
_nspd_zoning["max_height_m"] = _regulation.get("max_height_m")
_nspd_zoning["max_floors"] = _regulation.get("max_floors")
_nspd_zoning["max_building_pct"] = _regulation.get("max_building_pct")
_nspd_zoning["min_parcel_area_m2"] = _regulation.get("min_parcel_area_m2")
_nspd_zoning["regulation_zone_index"] = _regulation.get("zone_index")
_nspd_zoning["regulation_source"] = "ekb-geoportal-urbancard"
logger.debug(
"zone regulation hit for %s: zone_index=%s max_far=%s",
cad_num,
_regulation.get("zone_index"),
_regulation.get("max_far"),
)
else:
logger.debug("zone regulation miss for %s (no resolve)", cad_num)
# 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2)
parcel_meta: ParcelMeta | None = None
try:

View file

@ -207,6 +207,16 @@ class Settings(BaseSettings):
# (callable вручную для smoke-тестирования конкретного bbox).
enable_riasurt_harvest: bool = False
# Реальный ПЗЗ-градрегламент зоны (КСИТ/max_far, высота, этажность, %застройки,
# min площадь ЗУ) в ответе analyze: поле `nspd_zoning` дополняется числовыми
# предельными параметрами из zone_regulation_cache (Route 2 — coordinate-based
# resolver get_or_fetch_zone_regulation, cache-first + bounded live geoportal на
# miss). Кэш прогрет для ЕКБ (33 зоны), поэтому live-вызовы редки и закапаны
# коротким timeout'ом. Hot-path-safe: любой сбой/таймаут → поля None, analyze не
# падает. По умолчанию ON; выключение (env ENABLE_ZONING_REGULATION_IN_ANALYZE=false)
# полностью пропускает резолв — поведение analyze без изменений.
enable_zoning_regulation_in_analyze: bool = True
# ── LLM infrastructure (#960) ────────────────────────────────────────────
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный

View file

@ -0,0 +1,261 @@
"""Тесты для реального ПЗЗ-градрегламента в nspd_zoning ответа analyze (Route 2, #1067).
Покрывает merge числовых предельных параметров зоны (КСИТ/max_far, высота, этажность,
%застройки, min площадь ЗУ) в nspd_zoning через get_or_fetch_zone_regulation:
1. resolver вернул регламент nspd_zoning несёт max_far/max_height_m/regulation_source
2. resolver кинул исключение analyze всё равно 200 и nspd_zoning БЕЗ regulation-полей
(hot-path-safe, не падаем)
3. флаг enable_zoning_regulation_in_analyze=False resolver НЕ вызывается, полей нет
Стратегия mock: как в test_analyze_parcel_meta.py DB mock через dependency_overrides,
тяжёлые сервисы патчим через unittest.mock.patch. get_quarter_dump_data замокан так, чтобы
вернуть непустой nspd_zoning (иначе merge пропускается по guard `_nspd_zoning is not None`).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import app
_CAD = "66:41:0204016:10"
_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))"
_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}'
_NSPD_ZONING = {
"zone_code": "Ц-1",
"zone_name": "Общественно-деловая зона",
"source": "nspd-quarter-dump",
"raw_props": {"type_zone": "Общественно-деловая зона"},
}
_REGULATION = {
"zone_index": "Ц-1",
"max_far": 4.0,
"max_height_m": 25.0,
"max_floors": 9,
"max_building_pct": 100.0,
"min_parcel_area_m2": 1500.0,
"source": "ekb_geoportal",
}
def _make_mapping(data: dict[str, Any]) -> MagicMock:
m = MagicMock()
m.__getitem__ = lambda self, k: data[k]
m.get = lambda k, default=None: data.get(k, default)
return m
def _make_db_for_analyze() -> MagicMock:
"""Mock DB Session для analyze_parcel (минимальный — фокус на nspd_zoning merge)."""
db = MagicMock()
geom_row = _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
wkt_row = _make_mapping({"wkt": _WKT})
district_row = _make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"dist_to_center": 1500.0,
}
)
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
call_idx = [0]
responses: list[Any] = [
("first", geom_row), # 0: geom UNION ALL
("first", wkt_row), # 1: WKT
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor rows
("all", []), # 5: pipeline rows
("first", centroid_row), # 6: centroid
("all", []), # 7: noise rows
("all", []), # 8: hydrology rows
("all", []), # 9: utilities rows
# (далее nspd_zoning merge не ходит в db.execute — резолвер замокан)
("first", None), # 10: parcel_meta
("first", None), # 11: market trend
("first", None), # 12: zoning (begin_nested)
("all", []), # 13: success recommendation
("first", None), # 14: market price
("all", []), # 15: recent permits
("scalar", 0), # 16: geotech_risk
("all", []), # 17: neighbors
("first", None), # 18: overlap
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
idx = call_idx[0]
call_idx[0] += 1
if idx >= len(responses):
r = MagicMock()
r.mappings.return_value.first.return_value = None
r.mappings.return_value.all.return_value = []
r.scalar.return_value = 0
return r
kind, data = responses[idx]
r = MagicMock()
r.mappings.return_value.first.return_value = data
r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
r.scalar.return_value = data if kind == "scalar" else 0
return r
db.execute.side_effect = _execute_side_effect
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
db.begin_nested.return_value = ctx
return db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
def _base_patches() -> list[Any]:
"""Патчи тяжёлых сервисов; get_quarter_dump_data отдаёт непустой nspd_zoning."""
return [
patch("app.api.v1.parcels.get_air_quality_cached", return_value=None),
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
patch(
"app.api.v1.parcels.get_quarter_dump_data",
return_value={
# dict(_NSPD_ZONING) — свежая копия per-test, merge мутирует её in-place
"nspd_zoning": dict(_NSPD_ZONING),
"nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [],
"nspd_risk_zones": [],
"nspd_opportunity_parcels": [],
"nspd_red_lines": [],
"nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
},
),
patch("app.api.v1.parcels.compute_velocity", return_value=None),
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
patch(
"app.api.v1.parcels.build_ird_analyze_block",
return_value={
"ird_overlaps": [],
"ird_by_kind": {},
"opportunity_overlaps": [],
"planning_projects": [],
"functional_zone": None,
"krt": [],
"zone_regulation": None,
},
),
]
def test_zoning_regulation_merged_when_resolved() -> None:
"""resolver вернул регламент → nspd_zoning несёт max_far/max_height_m/regulation_source."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
patches = [
*_base_patches(),
patch(
"app.api.v1.parcels.get_or_fetch_zone_regulation",
return_value=dict(_REGULATION),
),
]
for p in patches:
p.start()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
zoning = body["nspd_zoning"]
assert zoning is not None
# существующие ключи сохранены
assert zoning["zone_code"] == "Ц-1"
assert zoning["source"] == "nspd-quarter-dump"
# regulation-поля добавлены
assert zoning["max_far"] == 4.0
assert zoning["max_height_m"] == 25.0
assert zoning["max_floors"] == 9
assert zoning["max_building_pct"] == 100.0
assert zoning["min_parcel_area_m2"] == 1500.0
assert zoning["regulation_zone_index"] == "Ц-1"
assert zoning["regulation_source"] == "ekb-geoportal-urbancard"
finally:
app.dependency_overrides.clear()
for p in patches:
p.stop()
def test_zoning_regulation_graceful_on_resolver_exception() -> None:
"""resolver кинул → analyze 200, nspd_zoning сохранён БЕЗ regulation-полей (не падаем)."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
patches = [
*_base_patches(),
patch(
"app.api.v1.parcels.get_or_fetch_zone_regulation",
side_effect=RuntimeError("geoportal timeout"),
),
]
for p in patches:
p.start()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
zoning = body["nspd_zoning"]
assert zoning is not None
assert zoning["zone_code"] == "Ц-1" # существующие ключи целы
# regulation-поля НЕ просочились (graceful degrade)
assert "max_far" not in zoning
assert "max_height_m" not in zoning
assert "regulation_source" not in zoning
finally:
app.dependency_overrides.clear()
for p in patches:
p.stop()
def test_zoning_regulation_skipped_when_flag_off() -> None:
"""флаг OFF → resolver НЕ вызывается, regulation-полей нет, nspd_zoning без изменений."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
resolver_mock = MagicMock(return_value=dict(_REGULATION))
patches = [
*_base_patches(),
patch("app.api.v1.parcels.settings.enable_zoning_regulation_in_analyze", False),
patch("app.api.v1.parcels.get_or_fetch_zone_regulation", resolver_mock),
]
for p in patches:
p.start()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
zoning = body["nspd_zoning"]
assert zoning is not None
assert zoning["zone_code"] == "Ц-1"
assert "max_far" not in zoning # резолв пропущен → полей нет
resolver_mock.assert_not_called() # флаг OFF → резолвер не дёрнут
finally:
app.dependency_overrides.clear()
for p in patches:
p.stop()