feat(sf): wire ИРД-слой в analyze за флагом enable_ird_analyze (#1101)
D9b capstone — поле `ird` в ответе analyze за флагом enable_ird_analyze (default OFF): parcel_ird_overlaps (м.132, incl opportunity) + функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент (C8b cache-first). Логика в self-contained ird_analyze.py; parcels.py +13 строк flag-gated, defense-in-depth ×3 graceful. Schema additive (ird Optional). Замыкает ИРД-эпик #1067 (#1078/#1090/#1092/#1099/#1100/#1058/#1060). 4 теста. Refs #1067. Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
This commit is contained in:
parent
b1530467f7
commit
7b40e7e480
5 changed files with 251 additions and 0 deletions
|
|
@ -62,6 +62,7 @@ from app.services.site_finder.custom_pois import (
|
||||||
get_overlaps_for_scoring as _get_custom_poi_overlaps,
|
get_overlaps_for_scoring as _get_custom_poi_overlaps,
|
||||||
)
|
)
|
||||||
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
||||||
|
from app.services.site_finder.ird_analyze import build_ird_analyze_block
|
||||||
from app.services.site_finder.poi_score import PoiScoreResponse, compute_poi_weighted_top7
|
from app.services.site_finder.poi_score import PoiScoreResponse, compute_poi_weighted_top7
|
||||||
from app.services.site_finder.quarter_dump_lookup import (
|
from app.services.site_finder.quarter_dump_lookup import (
|
||||||
get_connection_points,
|
get_connection_points,
|
||||||
|
|
@ -2957,6 +2958,18 @@ def analyze_parcel(
|
||||||
)
|
)
|
||||||
result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}
|
result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}
|
||||||
|
|
||||||
|
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
|
||||||
|
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
|
||||||
|
# источники 2-3 — live-зависимость от внешнего геопортала в hot-пути. Полностью graceful —
|
||||||
|
# сбой не меняет успех/форму остального ответа. Additive: extra="allow" в AnalyzeResponse.
|
||||||
|
if settings.enable_ird_analyze:
|
||||||
|
try:
|
||||||
|
result_payload["ird"] = build_ird_analyze_block(
|
||||||
|
db, geom_wkt, centroid_lon, centroid_lat
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("ird block failed for cad=%s — analyze response unaffected", cad_num)
|
||||||
|
|
||||||
return result_payload
|
return result_payload
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,5 +134,12 @@ class Settings(BaseSettings):
|
||||||
# Если не задан — endpoint вернёт 503 с инструкцией по регистрации.
|
# Если не задан — endpoint вернёт 503 с инструкцией по регистрации.
|
||||||
openrouteservice_api_key: str = ""
|
openrouteservice_api_key: str = ""
|
||||||
|
|
||||||
|
# ИРД-слой в analyze (#1067 D9b «GG-форсайт»): поле `ird` в ответе analyze —
|
||||||
|
# parcel_ird_overlaps (м.132, incl opportunity) + функц.зона/КРТ (геопортал WFS) +
|
||||||
|
# ПЗЗ-регламент зоны (C8b). Default OFF: источники 2-3 — live-зависимость от внешнего
|
||||||
|
# ЕКБ-геопортала (reverse-eng, без SLA) в hot-пути analyze. Включать после прогона
|
||||||
|
# harvest на проде (B6) и заполнения zone_regulation_cache.
|
||||||
|
enable_ird_analyze: bool = False
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,10 @@ class AnalyzeResponse(BaseModel):
|
||||||
# — §22-форсайт stub (#995): pending|unavailable, добавляется после persist —
|
# — §22-форсайт stub (#995): pending|unavailable, добавляется после persist —
|
||||||
forecast: dict[str, Any] | None = None
|
forecast: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
# — ИРД-слой (#1067 D9b, flag enable_ird_analyze): ird_overlaps (м.132, incl opportunity) +
|
||||||
|
# functional_zone/krt (геопортал WFS) + zone_regulation (C8b). Отсутствует при флаге off —
|
||||||
|
ird: dict[str, Any] | None = None
|
||||||
|
|
||||||
# — 202 fetch-stub поля (#93): present ТОЛЬКО на 202-ответе, иначе отсутствуют —
|
# — 202 fetch-stub поля (#93): present ТОЛЬКО на 202-ответе, иначе отсутствуют —
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
job_id: int | None = None
|
job_id: int | None = None
|
||||||
|
|
|
||||||
114
backend/app/services/site_finder/ird_analyze.py
Normal file
114
backend/app/services/site_finder/ird_analyze.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Сборка ИРД-блока для analyze_parcel (#1067 D9b — analyze-wiring).
|
||||||
|
|
||||||
|
Единая точка, собирающая «слой ИРД/future-supply» участка для ответа analyze (GG-форсайт).
|
||||||
|
Объединяет ТРИ источника, каждый — graceful (сбой одного не валит блок и тем более analyze):
|
||||||
|
|
||||||
|
1. ``parcel_ird_overlaps`` (DB, м.132 ird_overlays) — ограничения/обременения + opportunity-ЗУ,
|
||||||
|
пересекающие участок (ЗОУИТ / ПЗЗ-границы / ОКН / ООПТ + opportunity_* из #1086).
|
||||||
|
Дёшево (GIST ST_Intersects), всегда безопасно.
|
||||||
|
2. ЕКБ-геопортал WFS по centroid (network, опц.): функц.зона генплана (#1058) + КРТ (#1060).
|
||||||
|
3. ПЗЗ-регламент зоны (#1067 C8b): zone_regulation_cache по индексу зоны (cache-first через
|
||||||
|
``get_or_fetch_zone_regulation`` — WFS index дёшев, urbanCard только на cache-miss).
|
||||||
|
|
||||||
|
Вызывается ТОЛЬКО за feature-flag ``settings.enable_ird_analyze`` (default off): источники 2-3 —
|
||||||
|
live-зависимость от внешнего геопортала (reverse-eng, без SLA) в hot-пути analyze. Флаг off →
|
||||||
|
parcels.py даже не зовёт этот модуль. Сетевой timeout укорочен (geoportal_timeout) чтобы не
|
||||||
|
держать sync-handler.
|
||||||
|
|
||||||
|
Логика вынесена из parcels.py (HOT-файл) сюда — в parcels.py остаётся 1 import + gated-блок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
|
||||||
|
from app.services.site_finder.ird_overlay_lookup import parcel_ird_overlaps
|
||||||
|
from app.services.site_finder.zone_regulation import get_or_fetch_zone_regulation
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Короткий timeout для geoportal-вызовов в hot-пути analyze (default клиента — 20s, слишком долго).
|
||||||
|
_GEOPORTAL_TIMEOUT_S = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _functional_zone_at(
|
||||||
|
client: EKBGeoportalClient, lon: float, lat: float
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Функц.зона генплана (#1058) в точке — properties первой WFS-фичи; None при сбое/пусто."""
|
||||||
|
try:
|
||||||
|
feats = client.features_at_point("functional_zone", lon, lat)
|
||||||
|
except Exception as exc: # внешний геопортал, graceful
|
||||||
|
logger.warning("ird_analyze: functional_zone WFS failed: %s", exc)
|
||||||
|
return None
|
||||||
|
return feats[0].properties if feats else None
|
||||||
|
|
||||||
|
|
||||||
|
def _krt_at(client: EKBGeoportalClient, lon: float, lat: float) -> list[dict[str, Any]]:
|
||||||
|
"""КРТ-объекты (#1060) в точке — properties всех WFS-фич. [] при отсутствии/сбое."""
|
||||||
|
try:
|
||||||
|
feats = client.features_at_point("krt", lon, lat)
|
||||||
|
except Exception as exc: # внешний геопортал, graceful
|
||||||
|
logger.warning("ird_analyze: krt WFS failed: %s", exc)
|
||||||
|
return []
|
||||||
|
return [f.properties for f in feats]
|
||||||
|
|
||||||
|
|
||||||
|
def _zone_regulation_at(
|
||||||
|
db: Session, client: EKBGeoportalClient, lon: float, lat: float
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""ПЗЗ-регламент зоны (#1067 C8b) в точке — cache-first. None при отсутствии/сбое."""
|
||||||
|
try:
|
||||||
|
return get_or_fetch_zone_regulation(db, lon, lat, client=client)
|
||||||
|
except Exception as exc: # внешний геопортал/БД, graceful
|
||||||
|
logger.warning("ird_analyze: zone_regulation failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_ird_analyze_block(
|
||||||
|
db: Session,
|
||||||
|
geom_wkt: str | None,
|
||||||
|
lon: float | None,
|
||||||
|
lat: float | None,
|
||||||
|
*,
|
||||||
|
client: EKBGeoportalClient | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Собрать ИРД-блок участка для analyze (#1067 D9b).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: сессия.
|
||||||
|
geom_wkt: геометрия участка WKT (EPSG:4326) для DB-overlay ``ird_overlays``.
|
||||||
|
lon/lat: centroid участка (EPSG:4326) для geoportal-резолва функц.зоны/КРТ/регламента.
|
||||||
|
client: переиспользуемый EKBGeoportalClient (для тестов/одного TLS-пула). По умолчанию
|
||||||
|
создаётся с укороченным timeout.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: ``ird_overlaps``/``ird_by_kind`` (DB-overlay, incl opportunity_*),
|
||||||
|
``opportunity_overlaps`` (подмножество layer_kind='opportunity_*'),
|
||||||
|
``functional_zone`` (генплан, dict|None), ``krt`` (list), ``zone_regulation`` (dict|None).
|
||||||
|
Любой источник деградирует независимо — блок всегда well-formed.
|
||||||
|
"""
|
||||||
|
overlay = parcel_ird_overlaps(db, geom_wkt)
|
||||||
|
overlaps: list[dict[str, Any]] = overlay.get("ird_overlaps", [])
|
||||||
|
opportunity = [o for o in overlaps if str(o.get("layer_kind", "")).startswith("opportunity_")]
|
||||||
|
block: dict[str, Any] = {
|
||||||
|
"ird_overlaps": overlaps,
|
||||||
|
"ird_by_kind": overlay.get("ird_by_kind", {}),
|
||||||
|
"opportunity_overlaps": opportunity,
|
||||||
|
"functional_zone": None,
|
||||||
|
"krt": [],
|
||||||
|
"zone_regulation": None,
|
||||||
|
}
|
||||||
|
# Geoportal-источники — только при валидном centroid (network, опц.).
|
||||||
|
if lon is not None and lat is not None:
|
||||||
|
client = client or EKBGeoportalClient(timeout=_GEOPORTAL_TIMEOUT_S)
|
||||||
|
block["functional_zone"] = _functional_zone_at(client, lon, lat)
|
||||||
|
block["krt"] = _krt_at(client, lon, lat)
|
||||||
|
block["zone_regulation"] = _zone_regulation_at(db, client, lon, lat)
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_ird_analyze_block"]
|
||||||
113
backend/tests/services/test_ird_analyze.py
Normal file
113
backend/tests/services/test_ird_analyze.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Тесты build_ird_analyze_block (#1067 D9b analyze-wiring).
|
||||||
|
|
||||||
|
DB/сеть не дёргаются: мокаем parcel_ird_overlaps + get_or_fetch_zone_regulation (module-level)
|
||||||
|
и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*, graceful-деградацию
|
||||||
|
каждого источника и skip geoportal при отсутствии centroid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.site_finder import ird_analyze
|
||||||
|
|
||||||
|
|
||||||
|
class _Feat:
|
||||||
|
def __init__(self, properties: dict[str, Any]) -> None:
|
||||||
|
self.properties = properties
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, *, fz: list[_Feat], krt: list[_Feat], boom: bool = False) -> None:
|
||||||
|
self._fz = fz
|
||||||
|
self._krt = krt
|
||||||
|
self._boom = boom
|
||||||
|
self.calls: list[tuple[str, float, float]] = []
|
||||||
|
|
||||||
|
def features_at_point(self, layer: str, lon: float, lat: float) -> list[_Feat]:
|
||||||
|
self.calls.append((layer, lon, lat))
|
||||||
|
if self._boom:
|
||||||
|
raise RuntimeError("geoportal down")
|
||||||
|
return self._fz if layer == "functional_zone" else self._krt
|
||||||
|
|
||||||
|
|
||||||
|
_OVERLAY = {
|
||||||
|
"ird_overlaps": [
|
||||||
|
{"layer_kind": "zouit_okn", "reg_numb_border": "66:41-6.1"},
|
||||||
|
{"layer_kind": "opportunity_auction", "reg_numb_border": "66:41:1:10"},
|
||||||
|
{"layer_kind": "opportunity_krt", "reg_numb_border": None},
|
||||||
|
],
|
||||||
|
"ird_by_kind": {"zouit_okn": 1, "opportunity_auction": 1, "opportunity_krt": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None:
|
||||||
|
monkeypatch.setattr(ird_analyze, "parcel_ird_overlaps", lambda db, wkt: overlay)
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_block_assembly(monkeypatch: Any) -> None:
|
||||||
|
"""Все источники → overlaps + split opportunity + функц.зона + КРТ + регламент."""
|
||||||
|
_patch_overlay(monkeypatch, _OVERLAY)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ird_analyze,
|
||||||
|
"get_or_fetch_zone_regulation",
|
||||||
|
lambda db, lon, lat, client=None: {"zone_index": "Ц-1"},
|
||||||
|
)
|
||||||
|
client = _FakeClient(fz=[_Feat({"name": "Ж-зона"})], krt=[_Feat({"num_oks_krt": "КРТ-7"})])
|
||||||
|
|
||||||
|
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
|
||||||
|
|
||||||
|
assert len(block["ird_overlaps"]) == 3
|
||||||
|
assert block["ird_by_kind"]["opportunity_auction"] == 1
|
||||||
|
assert [o["layer_kind"] for o in block["opportunity_overlaps"]] == [
|
||||||
|
"opportunity_auction",
|
||||||
|
"opportunity_krt",
|
||||||
|
]
|
||||||
|
assert block["functional_zone"] == {"name": "Ж-зона"}
|
||||||
|
assert block["krt"] == [{"num_oks_krt": "КРТ-7"}]
|
||||||
|
assert block["zone_regulation"] == {"zone_index": "Ц-1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_geoportal_failure_is_graceful(monkeypatch: Any) -> None:
|
||||||
|
"""Сбой геопортала → функц.зона None, КРТ [], регламент None; overlaps уцелели."""
|
||||||
|
_patch_overlay(monkeypatch, _OVERLAY)
|
||||||
|
|
||||||
|
def _boom(*a: Any, **kw: Any) -> Any:
|
||||||
|
raise RuntimeError("zone reg down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _boom)
|
||||||
|
client = _FakeClient(fz=[], krt=[], boom=True)
|
||||||
|
|
||||||
|
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
|
||||||
|
|
||||||
|
assert len(block["ird_overlaps"]) == 3 # DB-overlay не задет
|
||||||
|
assert block["functional_zone"] is None
|
||||||
|
assert block["krt"] == []
|
||||||
|
assert block["zone_regulation"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_centroid_skips_geoportal(monkeypatch: Any) -> None:
|
||||||
|
"""lon/lat None → только DB-overlay, geoportal не зовётся (client.calls пуст)."""
|
||||||
|
_patch_overlay(monkeypatch, _OVERLAY)
|
||||||
|
client = _FakeClient(fz=[_Feat({"x": 1})], krt=[])
|
||||||
|
|
||||||
|
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", None, None, client=client)
|
||||||
|
|
||||||
|
assert client.calls == []
|
||||||
|
assert block["functional_zone"] is None
|
||||||
|
assert block["krt"] == []
|
||||||
|
assert block["zone_regulation"] is None
|
||||||
|
assert len(block["ird_overlaps"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_overlay(monkeypatch: Any) -> None:
|
||||||
|
"""Пустой overlay → пустые списки, без KeyError."""
|
||||||
|
_patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}})
|
||||||
|
client = _FakeClient(fz=[], krt=[])
|
||||||
|
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
|
||||||
|
|
||||||
|
block = ird_analyze.build_ird_analyze_block(object(), None, None, None, client=client)
|
||||||
|
|
||||||
|
assert block["ird_overlaps"] == []
|
||||||
|
assert block["opportunity_overlaps"] == []
|
||||||
|
assert block["ird_by_kind"] == {}
|
||||||
Loading…
Add table
Reference in a new issue