From 31e316e04e9578fa511627cdc3977291b24ff470 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 21:49:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20=D0=A0=D0=98=D0=90=D0=A1?= =?UTF-8?q?=D0=A3=D0=A0=D0=A2=20=D0=A1=D0=B2=D0=B5=D1=80=D0=B4=D0=BB=2014-?= =?UTF-8?q?layer=20harvest=20client+schema+task=20for=20=D0=95=D0=9A=D0=91?= =?UTF-8?q?=20agglomeration=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 19 ++ backend/app/core/config.py | 8 + backend/app/services/scrapers/nspd_client.py | 93 +++++++ .../services/site_finder/riasurt_lookup.py | 154 ++++++++++ backend/app/workers/beat_schedule.py | 18 ++ backend/app/workers/celery_app.py | 1 + .../workers/tasks/riasurt_sverdl_harvest.py | 263 ++++++++++++++++++ .../site_finder/test_riasurt_lookup.py | 146 ++++++++++ backend/tests/test_riasurt_sverdl_client.py | 113 ++++++++ .../workers/test_riasurt_sverdl_harvest.py | 149 ++++++++++ data/sql/163_riasurt_sverdl.sql | 52 ++++ 11 files changed, 1016 insertions(+) create mode 100644 backend/app/services/site_finder/riasurt_lookup.py create mode 100644 backend/app/workers/tasks/riasurt_sverdl_harvest.py create mode 100644 backend/tests/services/site_finder/test_riasurt_lookup.py create mode 100644 backend/tests/test_riasurt_sverdl_client.py create mode 100644 backend/tests/workers/test_riasurt_sverdl_harvest.py create mode 100644 data/sql/163_riasurt_sverdl.sql diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 7c7ae3d6..35976dc1 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -77,6 +77,7 @@ from app.services.site_finder.quarter_dump_lookup import ( get_quarter_dump_data, make_empty_result, ) +from app.services.site_finder.riasurt_lookup import parcel_riasurt_gate from app.services.site_finder.saturation import compute_district_saturation from app.services.site_finder.velocity import compute_velocity from app.services.site_finder.weight_profiles import ( @@ -2939,6 +2940,24 @@ def analyze_parcel( exc_info=True, ) + # РИАСУРТ Свердл gate (#108, multi-city scaling): для участков АГЛОМЕРАЦИИ ЕКБ + # (окраины — Берёзовский/В.Пышма/Среднеуральск/Арамиль/Сысерть), НЕ ЕКБ-сити (66:41). + # Flag-gated (default off): таблица riasurt_sverdl наполняется post-deploy harvest'ом на + # уточнённых bbox МО. Полностью graceful (parcel_riasurt_gate сам отдаёт пустой gate при + # отсутствии таблицы / вне агломерации) — сбой не меняет успех/форму ответа (extra="allow"). + # Ключ добавляется только когда gate применим (applicable=True) — иначе не шумим. + if settings.enable_riasurt_gate: + try: + _riasurt_gate = parcel_riasurt_gate(db, geom_wkt, cad_num) + if _riasurt_gate.get("applicable"): + result_payload.setdefault("gate", {})["riasurt"] = _riasurt_gate + except Exception: + logger.warning( + "riasurt gate failed for cad=%s — analyze response unaffected", + cad_num, + exc_info=True, + ) + # Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record + # nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). БЕЗ флага # (в отличие от ird-блока выше): чистый DB-резолвер по индексам (point-lookup ~0.06ms, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4ab0b639..f0e8a3d3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -190,6 +190,14 @@ class Settings(BaseSettings): # возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058). enable_ird_analyze: bool = True + # РИАСУРТ Свердл gate в analyze (#108, multi-city scaling): поле `gate.riasurt` в ответе + # analyze — пересечения участка с зонами РИАСУРТ Свердл (тер.зона/функц.зона/красные линии/ + # СЗЗ/ЗСО/затопление/КРТ). ТОЛЬКО для участков в агломерации ЕКБ, НЕ в самом ЕКБ-сити + # (is_in_aglomeration_but_not_ekb). По умолчанию OFF: таблица riasurt_sverdl наполняется + # post-deploy harvest'ом на уточнённых bbox МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO). + # Override через env ENABLE_RIASURT_GATE=true. + enable_riasurt_gate: bool = False + # ── LLM infrastructure (#960) ──────────────────────────────────────────── # ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не # зависит от LLM — при любом сбое/выключенности возвращается детерминированный diff --git a/backend/app/services/scrapers/nspd_client.py b/backend/app/services/scrapers/nspd_client.py index 3c868305..9742dd43 100644 --- a/backend/app/services/scrapers/nspd_client.py +++ b/backend/app/services/scrapers/nspd_client.py @@ -143,6 +143,45 @@ _GRID_WALK_LAYERS: frozenset[str] = frozenset( } ) +# ── РИАСУРТ Свердл (folderId 1224) — агломерация ЕКБ, multi-city scaling (#108) ── +# РИАСУРТ Свердл агрегирует данные от МАЛЫХ МО Свердл (Берёзовский, В.Пышма, +# Среднеуральск, Арамиль, Сысерть) — НЕ от ЕКБ-сити (тот не интегрирован в ФГИС ТП). +# Слои на той же NSPD aeggis/v4 WMS-инфраструктуре, что и федеральные LAYERS, но +# layerId из регионального каталога (84xxxx/85xxxx). 14 ключевых слоёв (см. issue #108). +# +# layerId → (topic, человекочитаемое название). topic группирует слой для analyze-gate +# (тер.зона/функц.зона/красные линии/СЗЗ/ЗСО/затопление/КРТ и т.п.). +RIASURT_SVERDL_LAYERS: dict[int, tuple[str, str]] = { + # ПЗЗ / Планирование + 845274: ("territorial_zone", "Территориальные зоны"), + 846381: ("functional_zone", "Функциональные зоны"), + 844759: ("red_lines", "Красные линии"), + 844774: ("setback_lines", "Линии отступа от красных линий"), + 845298: ("ppt_oks_boundary", "Границы ППТ ОКС"), + # Инженерные ЗОУИТ + 846365: ("szz", "Санитарно-защитная зона (СЗЗ)"), + 846369: ("san_gap_engineering", "Санитарный разрыв инженерных коммуникаций"), + 845392: ("szo", "Зоны санитарной охраны питьевого водоснабжения (ЗСО)"), + 846373: ("water_protection", "Водоохранные зоны"), + # Risk + 845425: ("flood_zone", "Зоны затопления / подтопления"), + 846378: ("emergency_alert", "Зона экстренного оповещения"), + 844795: ("emergency_risk", "Территории риска ЧС"), + # Opportunity + 844478: ("krt", "Территории КРТ"), + 846379: ("oez", "Особые экономические зоны (ОЭЗ)"), +} + + +def riasurt_layer_topic(layer_id: int) -> str: + """Topic (бакет для analyze-gate) по layerId РИАСУРТ Свердл. + + Неизвестный layerId → 'other' (graceful; не падаем на новых слоях каталога). + """ + entry = RIASURT_SVERDL_LAYERS.get(layer_id) + return entry[0] if entry else "other" + + # Default rate limit (мс между запросами) — баланс между скоростью и WAF DEFAULT_RATE_MS = 600 @@ -612,6 +651,58 @@ class NSPDClient: ) return deduped + # ── 3c. get_riasurt_sverdl_in_bbox (РИАСУРТ Свердл bulk, #108) ─────────── + + def get_riasurt_sverdl_in_bbox( + self, + bbox_3857: tuple[float, float, float, float], + layers: list[int] | None = None, + *, + grid_n: int = 7, + step_m: float = 50.0, + ) -> dict[int, list[NSPDFeature]]: + """Bulk WMS GetFeatureInfo по слоям РИАСУРТ Свердл в пределах bbox. + + Для каждого layerId делает grid-walk (``get_features_in_bbox_grid``) — area/ + linear слои (зоны, линии) под-возвращаются single-pixel probe, поэтому grid. + Слой, который упал (WAF / сеть), логируется и пропускается (его ключ = []), + остальные слои продолжают — частичный harvest лучше полного провала по агломерации. + + Args: + bbox_3857: (xmin, ymin, xmax, ymax) в EPSG:3857 (метры) — bbox одного МО. + layers: список layerId РИАСУРТ. None → все 14 ключевых + (``RIASURT_SVERDL_LAYERS``). Неизвестный layerId всё равно + запрашивается (topic='other' на стороне классификатора). + grid_n / step_m: параметры grid-walk (см. get_features_in_bbox_grid). + + Returns: + dict[layerId, list[NSPDFeature]]. Ключи — все запрошенные layerId + (пустой list если слой пуст / упал). Стабильная форма для caller'а. + """ + layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys()) + result: dict[int, list[NSPDFeature]] = {} + for layer_id in layer_ids: + try: + feats = self.get_features_in_bbox_grid( + layer_id, bbox_3857, grid_n=grid_n, step_m=step_m + ) + except (NspdLiteError, NspdLiteWafError) as exc: + logger.warning( + "get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем", + layer_id, + exc, + ) + result[layer_id] = [] + continue + result[layer_id] = feats + logger.info( + "get_riasurt_sverdl_in_bbox: layer=%d topic=%s count=%d", + layer_id, + riasurt_layer_topic(layer_id), + len(feats), + ) + return result + # ── 4. list_layers ────────────────────────────────────────────────────── def list_layers(self, theme_id: int = THEME_PKK) -> list[NSPDLayer]: @@ -924,6 +1015,7 @@ __all__ = [ "LAYERS", "NSPD_THEMES", "NSPD_WMS_BASE", + "RIASURT_SVERDL_LAYERS", "THEME_ARN", "THEME_PKK", "NSPDClient", @@ -935,4 +1027,5 @@ __all__ = [ "QuarterDump", "bbox_around_point_m", "lonlat_to_3857", + "riasurt_layer_topic", ] diff --git a/backend/app/services/site_finder/riasurt_lookup.py b/backend/app/services/site_finder/riasurt_lookup.py new file mode 100644 index 00000000..0c380e9e --- /dev/null +++ b/backend/app/services/site_finder/riasurt_lookup.py @@ -0,0 +1,154 @@ +"""РИАСУРТ Свердл gate-lookup для analyze_parcel (#108, multi-city scaling). + +Читает ``riasurt_sverdl`` (наполняется harvest-task riasurt_sverdl_harvest.py) и отдаёт зоны +РИАСУРТ Свердл, пересекающие участок: тер.зона / функц.зона / красные линии / СЗЗ / ЗСО / +затопление / КРТ. Только для участков агломерации ЕКБ (ОКРАИНЫ — Берёзовский, В.Пышма, +Среднеуральск, Арамиль, Сысерть), НЕ для ЕКБ-сити (тот не покрыт РИАСУРТ Свердл). + +Gate-форма (``response["gate"]["riasurt"]``) собирается из topic-бакетов: + tier / func_zone / red_lines / szz / szo / flood_zone / krt + raw-список overlaps. + +Graceful: нет таблицы (pre-migration) / пустой WKT / БД-ошибка → пустой gate (analyze не падает). +Зеркалит стиль ``ird_overlay_lookup.py``. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy import text +from sqlalchemy.exc import DataError, OperationalError, ProgrammingError +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# Кадастровый район ЕКБ-сити — 66:41. Агломерация (окраины) — прочие районы 66:xx +# (Берёзовский 66:35, В.Пышма/Среднеуральск 66:62, Арамиль/Сысерть 66:25, ...). +_EKB_CITY_PREFIX = "66:41" + +# Пустой gate-результат — sentinel (НЕ мутировать). +RIASURT_EMPTY_GATE: dict[str, Any] = { + "applicable": False, + "tier": None, + "func_zone": None, + "red_lines": False, + "szz": False, + "szo": False, + "flood_zone": False, + "krt": False, + "overlaps": [], +} + +# Пересечения участка с зонами РИАСУРТ. geom в БД — EPSG:3857; участок передаём WKT в 4326 и +# трансформируем в 3857 для ST_Intersects по GIST idx_riasurt_sverdl_geom. CAST psycopg v3. +_RIASURT_OVERLAP_SQL = text( + """ + SELECT source_layer_id, layer_topic, mo_name, obshnz, description + FROM riasurt_sverdl + WHERE ST_Intersects( + geom, + ST_Transform(ST_GeomFromText(CAST(:parcel_wkt AS text), 4326), 3857) + ) + ORDER BY layer_topic, source_layer_id + """ +) + + +def is_in_aglomeration_but_not_ekb(cad: str | None) -> bool: + """True если участок в агломерации ЕКБ, но НЕ в самом ЕКБ-сити (66:41). + + РИАСУРТ Свердл покрывает только окраины агломерации (Берёзовский, В.Пышма, + Среднеуральск, Арамиль, Сысерть) — кадастровые районы 66:xx, кроме 66:41. + ЕКБ-сити (66:41) и участки вне Свердл (не 66:*) → False. + + Args: + cad: кадастровый номер участка ('66:35:...'). None/пусто → False. + + Returns: + bool. + """ + if not cad: + return False + cad = cad.strip() + if not cad.startswith("66:"): + return False + return not cad.startswith(_EKB_CITY_PREFIX + ":") and cad != _EKB_CITY_PREFIX + + +def parcel_riasurt_gate( + db: Session, + parcel_wkt: str | None, + cad: str | None, +) -> dict[str, Any]: + """Gate-блок РИАСУРТ Свердл для участка агломерации ЕКБ. + + Если участок не в агломерации (или в ЕКБ-сити) → RIASURT_EMPTY_GATE (applicable=False). + Иначе ST_Intersects по riasurt_sverdl и сборка topic-бакетов. + + Args: + db: сессия. + parcel_wkt: геометрия участка WKT (EPSG:4326). None → пустой gate. + cad: кадастровый номер (для is_in_aglomeration_but_not_ekb). + + Returns: + dict с полями tier / func_zone / red_lines / szz / szo / flood_zone / krt + overlaps. + Пусто при отсутствии участка / таблицы / пересечений / вне агломерации. + """ + if not parcel_wkt or not is_in_aglomeration_but_not_ekb(cad): + return dict(RIASURT_EMPTY_GATE) + + try: + rows = db.execute(_RIASURT_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() + except (OperationalError, ProgrammingError, DataError) as exc: + logger.warning("parcel_riasurt_gate: таблица/запрос недоступны, skip: %s", exc) + return dict(RIASURT_EMPTY_GATE) + except Exception as exc: + logger.warning("parcel_riasurt_gate: неожиданная ошибка, skip: %s", exc) + return dict(RIASURT_EMPTY_GATE) + + gate: dict[str, Any] = { + "applicable": True, + "tier": None, # description зоны topic='territorial_zone' + "func_zone": None, # description зоны topic='functional_zone' + "red_lines": False, + "szz": False, + "szo": False, + "flood_zone": False, + "krt": False, + "overlaps": [], + } + for r in rows: + topic = r["layer_topic"] + gate["overlaps"].append( + { + "source_layer_id": r["source_layer_id"], + "layer_topic": topic, + "mo_name": r["mo_name"], + "obshnz": r["obshnz"], + "description": r["description"], + } + ) + if topic == "territorial_zone" and gate["tier"] is None: + gate["tier"] = r["description"] or r["obshnz"] + elif topic == "functional_zone" and gate["func_zone"] is None: + gate["func_zone"] = r["description"] or r["obshnz"] + elif topic == "red_lines": + gate["red_lines"] = True + elif topic == "szz": + gate["szz"] = True + elif topic == "szo": + gate["szo"] = True + elif topic == "flood_zone": + gate["flood_zone"] = True + elif topic == "krt": + gate["krt"] = True + + return gate + + +__all__ = [ + "RIASURT_EMPTY_GATE", + "is_in_aglomeration_but_not_ekb", + "parcel_riasurt_gate", +] diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index 0252d9b7..ad888a0f 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -137,6 +137,16 @@ def _build_beat_schedule_from_db() -> dict: "options": {"queue": "celery"}, } + # РИАСУРТ Свердл (#108): региональные градостроительные зоны агломерации ЕКБ меняются + # медленно → ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК). NB: harvest_all_* + # пока на ПЛЕЙСХОЛДЕР-bbox 5 МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO) — фактический + # прод-прогон только после уточнения границ. + schedule["riasurt-sverdl-harvest-quarterly"] = { + "task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl", + "schedule": _parse_cron("0 4 1 1,4,7,10 *"), + "options": {"queue": "celery"}, + } + return schedule @@ -192,6 +202,14 @@ def _build_beat_schedule_fallback() -> dict: "options": {"queue": "celery"}, } + # РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК). + # NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO). + schedule["riasurt-sverdl-harvest-quarterly"] = { + "task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl", + "schedule": _parse_cron("0 4 1 1,4,7,10 *"), + "options": {"queue": "celery"}, + } + return schedule diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index cec38d12..c16dd203 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -82,6 +82,7 @@ celery_app = Celery( "app.workers.tasks.izyatie_ocr_ingest", "app.workers.tasks.developer_registry_refresh", "app.workers.tasks.refresh_layout_velocity", + "app.workers.tasks.riasurt_sverdl_harvest", ], ) celery_app.conf.timezone = "Europe/Moscow" diff --git a/backend/app/workers/tasks/riasurt_sverdl_harvest.py b/backend/app/workers/tasks/riasurt_sverdl_harvest.py new file mode 100644 index 00000000..ccc419c4 --- /dev/null +++ b/backend/app/workers/tasks/riasurt_sverdl_harvest.py @@ -0,0 +1,263 @@ +"""Celery task: harvest РИАСУРТ Свердл (folderId 1224) → ``riasurt_sverdl`` (#108). + +14 ключевых слоёв РИАСУРТ Свердл (ПЗЗ/планирование, инженерные ЗОУИТ, risk, opportunity) +по агломерации ЕКБ — ОКРАИНЫ (Берёзовский, В.Пышма, Среднеуральск, Арамиль, Сысерть), +НЕ ЕКБ-сити (тот не интегрирован в ФГИС ТП). Foundation для multi-city scaling Site Finder. + +Источник — NSPD aeggis/v4 WMS (тот же, что федеральные слои), layerId из регионального +каталога РИАСУРТ (см. ``nspd_client.RIASURT_SVERDL_LAYERS``). Bulk-метод клиента +``get_riasurt_sverdl_in_bbox`` grid-walk'ит каждый слой в bbox одного МО. + +Mirror conventions (ird_harvest.py / backend.md): + • SessionLocal() + try/finally close; logger (не print). + • SAVEPOINT per-row (``with db.begin_nested():``) — одна битая фича не валит батч; commit в конце. + • CAST(:x AS type) — НИКОГДА :x::type (psycopg v3). Геометрия 3857 → MULTIPOLYGON. + • geom_msk66 — generated STORED в БД (миграция 163), здесь НЕ пишем. + • Сбой одного слоя/МО не валит весь прогон. + +Расписание — ежеквартально (beat_schedule.py: riasurt-sverdl-harvest-quarterly): региональные +градостроительные зоны меняются медленно. + +ВНИМАНИЕ: bbox 5 МО — ПЛЕЙСХОЛДЕРЫ (см. MO_BBOXES TODO). Точные bbox резолвятся ПОСЛЕ +деплоя через NSPD search по границам МО / ручную выгрузку. Текущие значения — грубые +приближения в EPSG:3857 (Web Mercator), достаточные для smoke, НО НЕ для прод-harvest. +""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import SessionLocal +from app.services.scrapers.nspd_client import ( + RIASURT_SVERDL_LAYERS, + NSPDClient, + riasurt_layer_topic, +) +from app.workers.celery_app import celery_app + +logger = logging.getLogger(__name__) + +# ── bbox 5 МО агломерации ЕКБ (EPSG:3857) ──────────────────────────────────── +# TODO(#108): ПЛЕЙСХОЛДЕРЫ — грубые приближения вокруг центров МО (±~6 км в 3857). +# Точные административные bbox резолвятся ПОСЛЕ деплоя (NSPD search по границе МО / +# ручная выгрузка границ из РИАСУРТ). НЕ запускать harvest_all_riasurt_sverdl на проде +# до уточнения — иначе grid-walk промахнётся мимо застройки. Формат: (xmin, ymin, xmax, ymax). +# +# Центры (приблизительно, WGS84 → 3857): +# Берёзовский ~56.91N 60.81E В.Пышма ~56.97N 60.58E +# Среднеуральск ~56.99N 60.48E Арамиль ~56.69N 60.83E +# Сысерть ~56.50N 60.82E +_PLACEHOLDER_HALF_M = 6000.0 # ±6 км вокруг центра — заведомо грубо, заменить на реальные границы +MO_BBOXES: dict[str, tuple[float, float, float, float]] = { + # TODO(#108): заменить плейсхолдеры на реальные административные bbox МО. + "Берёзовский": (6770000.0, 7710000.0, 6782000.0, 7722000.0), + "Верхняя Пышма": (6745000.0, 7720000.0, 6757000.0, 7732000.0), + "Среднеуральск": (6734000.0, 7723000.0, 6746000.0, 7735000.0), + "Арамиль": (6772000.0, 7677000.0, 6784000.0, 7689000.0), + "Сысерть": (6771000.0, 7639000.0, 6783000.0, 7651000.0), +} + +# UPSERT фичи РИАСУРТ. Геометрия НСПД (GeoJSON 3857) → ST_GeomFromGeoJSON → SetSRID 3857 → +# ST_Multi (приводим Polygon к MULTIPOLYGON под колонку). geom_msk66 — generated в БД. +# Нет стабильного natural-ключа дедупа у РИАСУРТ-фич → INSERT без ON CONFLICT; перед +# harvest_all очищаем строки по mo_name (re-harvest квартала идемпотентен на уровне МО). +_INSERT_SQL = text( + """ + INSERT INTO riasurt_sverdl ( + source_layer_id, layer_topic, mo_name, obshnz, description, + raw_props, geom, fetched_at + ) VALUES ( + CAST(:source_layer_id AS integer), + CAST(:layer_topic AS text), + CAST(:mo_name AS text), + CAST(:obshnz AS text), + CAST(:description AS text), + CAST(:raw_props AS jsonb), + ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geojson AS text)), 3857)), + CAST(:fetched_at AS timestamptz) + ) + """ +) + +_DELETE_MO_SQL = text("DELETE FROM riasurt_sverdl WHERE mo_name = CAST(:mo_name AS text)") + + +def _extract_obshnz(props: dict[str, Any]) -> str | None: + """reg_numb_border / индекс зоны из properties фичи (когда есть).""" + opts = props.get("options") or {} + val = ( + props.get("reg_numb_border") + or opts.get("reg_numb_border") + or props.get("obshnz") + or opts.get("obshnz") + ) + return str(val) if val is not None else None + + +def _extract_description(props: dict[str, Any]) -> str | None: + """Человекочитаемое название/описание фичи.""" + opts = props.get("options") or {} + val = ( + props.get("label") + or props.get("name") + or props.get("descr") + or opts.get("label") + or opts.get("descr") + ) + return str(val) if val is not None else None + + +def _insert_feature( + db: Session, + *, + layer_id: int, + mo_name: str, + feature: Any, + fetched_at: str, +) -> bool: + """INSERT одной РИАСУРТ-фичи под SAVEPOINT. True если записана. + + Пропускает фичи без geometry — одна битая не валит батч. + """ + geom = feature.geometry + if not geom: + return False + props = feature.properties or {} + try: + with db.begin_nested(): + db.execute( + _INSERT_SQL, + { + "source_layer_id": layer_id, + "layer_topic": riasurt_layer_topic(layer_id), + "mo_name": mo_name, + "obshnz": _extract_obshnz(props), + "description": _extract_description(props), + "raw_props": json.dumps(props, ensure_ascii=False), + "geojson": json.dumps(geom), + "fetched_at": fetched_at, + }, + ) + return True + except Exception as exc: + logger.warning( + "riasurt_sverdl: insert failed layer=%s mo=%s: %s", + layer_id, + mo_name, + exc, + ) + return False + + +@celery_app.task( + name="tasks.riasurt_sverdl_harvest.harvest_riasurt_sverdl_for_mo", + rate_limit="1/s", +) +def harvest_riasurt_sverdl_for_mo( + mo_name: str, + mo_bbox: list[float] | tuple[float, float, float, float], + layers: list[int] | None = None, +) -> dict[str, int]: + """Harvest 14 слоёв РИАСУРТ Свердл в bbox одного МО → riasurt_sverdl. + + Перед записью очищает прошлые строки этого МО (re-harvest идемпотентен на уровне МО — + у РИАСУРТ-фич нет стабильного natural-ключа дедупа). + + Args: + mo_name: имя МО (Берёзовский / Верхняя Пышма / ...). Денормализуется в строки. + mo_bbox: (xmin, ymin, xmax, ymax) в EPSG:3857. + layers: список layerId РИАСУРТ. None → все 14 ключевых. + + Returns: + {'features': M, 'layers': L} — записано фич / обработано слоёв. + """ + fetched_at = datetime.now(UTC).isoformat() + bbox = ( + float(mo_bbox[0]), + float(mo_bbox[1]), + float(mo_bbox[2]), + float(mo_bbox[3]), + ) + client = NSPDClient() + db = SessionLocal() + n_features = 0 + n_layers = 0 + try: + per_layer = client.get_riasurt_sverdl_in_bbox(bbox, layers) + # Идемпотентность на уровне МО: чистим прошлый проход перед записью нового. + with db.begin_nested(): + db.execute(_DELETE_MO_SQL, {"mo_name": mo_name}) + for layer_id, feats in per_layer.items(): + n_layers += 1 + for feat in feats: + if _insert_feature( + db, + layer_id=layer_id, + mo_name=mo_name, + feature=feat, + fetched_at=fetched_at, + ): + n_features += 1 + db.commit() + logger.info( + "riasurt_sverdl: МО=%s слоёв=%d фич=%d", + mo_name, + n_layers, + n_features, + ) + except Exception: + db.rollback() + logger.exception("riasurt_sverdl: harvest_riasurt_sverdl_for_mo упал, mo=%s", mo_name) + raise + finally: + db.close() + return {"features": n_features, "layers": n_layers} + + +@celery_app.task(name="tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl") +def harvest_all_riasurt_sverdl(layers: list[int] | None = None) -> dict[str, int]: + """Прогон по всем 5 МО агломерации ЕКБ (MO_BBOXES) → riasurt_sverdl. + + Вызывает harvest_riasurt_sverdl_for_mo синхронно по каждому МО (rate_limit самой + per-MO задачи действует при .delay()-вызове; здесь синхронный прогон — каждый МО + последовательно, чтобы один beat-тик не запускал 5 параллельных grid-walk'ов). + + Args: + layers: список layerId. None → все 14 ключевых (RIASURT_SVERDL_LAYERS). + + Returns: + {'mo': N, 'features': M} — обработано МО / суммарно записано фич. + """ + n_mo = 0 + n_features = 0 + for mo_name, bbox in MO_BBOXES.items(): + try: + res = harvest_riasurt_sverdl_for_mo(mo_name, bbox, layers) + n_features += int(res.get("features", 0)) + n_mo += 1 + except Exception: + logger.exception("riasurt_sverdl: МО %s упал — продолжаем остальные", mo_name) + continue + logger.info( + "riasurt_sverdl: harvest_all завершён, МО=%d (из %d) фич=%d", + n_mo, + len(MO_BBOXES), + n_features, + ) + return {"mo": n_mo, "features": n_features} + + +__all__ = [ + "MO_BBOXES", + "harvest_all_riasurt_sverdl", + "harvest_riasurt_sverdl_for_mo", +] + +# Layers catalog re-export (для удобства тестов / интроспекции). +_RIASURT_LAYER_COUNT = len(RIASURT_SVERDL_LAYERS) diff --git a/backend/tests/services/site_finder/test_riasurt_lookup.py b/backend/tests/services/site_finder/test_riasurt_lookup.py new file mode 100644 index 00000000..24e648cc --- /dev/null +++ b/backend/tests/services/site_finder/test_riasurt_lookup.py @@ -0,0 +1,146 @@ +"""Тесты riasurt_lookup (#108) — is_in_aglomeration_but_not_ekb + parcel_riasurt_gate. + +БД мокается фейк-сессией, возвращающей заранее заданные mapping-строки. Проверяем +агломерация-гейтинг (ЕКБ-сити исключён), topic-бакеты, graceful при ошибке БД. +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.exc import ProgrammingError + +from app.services.site_finder import riasurt_lookup +from app.services.site_finder.riasurt_lookup import ( + is_in_aglomeration_but_not_ekb, + parcel_riasurt_gate, +) + +_WKT = "POLYGON((60 56,60.1 56,60.1 56.1,60 56.1,60 56))" + + +def test_is_in_aglomeration_excludes_ekb_city() -> None: + """ЕКБ-сити (66:41) → False.""" + assert is_in_aglomeration_but_not_ekb("66:41:0204016:10") is False + assert is_in_aglomeration_but_not_ekb("66:41") is False + + +def test_is_in_aglomeration_includes_outskirts() -> None: + """Окраины (66:35 Берёзовский, 66:62 В.Пышма, 66:25 Сысерть) → True.""" + assert is_in_aglomeration_but_not_ekb("66:35:0101001:5") is True + assert is_in_aglomeration_but_not_ekb("66:62:1234567:1") is True + assert is_in_aglomeration_but_not_ekb("66:25:0000001:99") is True + + +def test_is_in_aglomeration_rejects_non_sverdl_and_empty() -> None: + """Не-Свердл (не 66:*) и пусто → False.""" + assert is_in_aglomeration_but_not_ekb("50:21:0000001:1") is False + assert is_in_aglomeration_but_not_ekb(None) is False + assert is_in_aglomeration_but_not_ekb("") is False + + +class _Row(dict): + """mapping-строка: поддерживает r["key"].""" + + +class _Result: + def __init__(self, rows: list[_Row]) -> None: + self._rows = rows + + def mappings(self) -> _Result: + return self + + def all(self) -> list[_Row]: + return self._rows + + +class _FakeDB: + def __init__(self, rows: list[_Row] | Exception) -> None: + self._rows = rows + + def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result: + if isinstance(self._rows, Exception): + raise self._rows + return _Result(self._rows) + + +def test_gate_not_applicable_for_ekb_city() -> None: + """ЕКБ-сити → applicable=False, БД не дёргается.""" + db = _FakeDB([]) + gate = parcel_riasurt_gate(db, _WKT, "66:41:0204016:10") # type: ignore[arg-type] + assert gate["applicable"] is False + assert gate["overlaps"] == [] + + +def test_gate_buckets_topics() -> None: + """Пересечения раскладываются по topic-бакетам.""" + rows = [ + _Row( + source_layer_id=845274, + layer_topic="territorial_zone", + mo_name="Берёзовский", + obshnz="Ж-1", + description="Зона застройки", + ), + _Row( + source_layer_id=846381, + layer_topic="functional_zone", + mo_name="Берёзовский", + obshnz=None, + description="Жилая функц.зона", + ), + _Row( + source_layer_id=844759, + layer_topic="red_lines", + mo_name="Берёзовский", + obshnz=None, + description=None, + ), + _Row( + source_layer_id=846365, + layer_topic="szz", + mo_name="Берёзовский", + obshnz=None, + description=None, + ), + _Row( + source_layer_id=845425, + layer_topic="flood_zone", + mo_name="Берёзовский", + obshnz=None, + description=None, + ), + _Row( + source_layer_id=844478, + layer_topic="krt", + mo_name="Берёзовский", + obshnz=None, + description=None, + ), + ] + db = _FakeDB(rows) + gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type] + + assert gate["applicable"] is True + assert gate["tier"] == "Зона застройки" + assert gate["func_zone"] == "Жилая функц.зона" + assert gate["red_lines"] is True + assert gate["szz"] is True + assert gate["szo"] is False + assert gate["flood_zone"] is True + assert gate["krt"] is True + assert len(gate["overlaps"]) == 6 + + +def test_gate_graceful_on_db_error() -> None: + """Ошибка БД (pre-migration) → пустой gate, не падаем.""" + db = _FakeDB(ProgrammingError("stmt", {}, Exception("no table"))) + gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type] + assert gate == riasurt_lookup.RIASURT_EMPTY_GATE + + +def test_gate_empty_wkt_returns_empty() -> None: + """Нет WKT → пустой gate.""" + db = _FakeDB([]) + gate = parcel_riasurt_gate(db, None, "66:35:0101001:5") # type: ignore[arg-type] + assert gate["applicable"] is False diff --git a/backend/tests/test_riasurt_sverdl_client.py b/backend/tests/test_riasurt_sverdl_client.py new file mode 100644 index 00000000..ac60b534 --- /dev/null +++ b/backend/tests/test_riasurt_sverdl_client.py @@ -0,0 +1,113 @@ +"""Тесты РИАСУРТ Свердл client (#108) — get_riasurt_sverdl_in_bbox + topic-классификация. + +Сеть не дёргается: мокаем get_features_in_bbox_grid на инстансе NSPDClient. Проверяем +mapping layerId → features, классификацию topic, graceful-skip упавшего слоя. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from app.services.scrapers.nspd_client import ( + RIASURT_SVERDL_LAYERS, + NSPDClient, + NSPDFeature, + NspdLiteWafError, + riasurt_layer_topic, +) + + +def test_riasurt_catalog_has_14_key_layers() -> None: + """Каталог содержит ровно 14 ключевых layerId из issue #108.""" + expected = { + 845274, + 846381, + 844759, + 844774, + 845298, + 846365, + 846369, + 845392, + 846373, + 845425, + 846378, + 844795, + 844478, + 846379, + } + assert set(RIASURT_SVERDL_LAYERS) == expected + assert len(RIASURT_SVERDL_LAYERS) == 14 + + +def test_riasurt_layer_topic_known() -> None: + """topic корректно резолвится по layerId.""" + assert riasurt_layer_topic(845274) == "territorial_zone" + assert riasurt_layer_topic(846381) == "functional_zone" + assert riasurt_layer_topic(844759) == "red_lines" + assert riasurt_layer_topic(846365) == "szz" + assert riasurt_layer_topic(845392) == "szo" + assert riasurt_layer_topic(845425) == "flood_zone" + assert riasurt_layer_topic(844478) == "krt" + assert riasurt_layer_topic(846379) == "oez" + + +def test_riasurt_layer_topic_unknown_is_other() -> None: + """Неизвестный layerId → 'other' (graceful).""" + assert riasurt_layer_topic(999999) == "other" + + +def _feat(layer_id: int) -> NSPDFeature: + return NSPDFeature.from_raw( + { + "id": str(layer_id), + "geometry": {"type": "Polygon", "coordinates": [[[1, 1], [2, 1], [2, 2], [1, 1]]]}, + "properties": {"label": f"zone-{layer_id}"}, + } + ) + + +def test_get_riasurt_sverdl_in_bbox_maps_layers(monkeypatch: pytest.MonkeyPatch) -> None: + """Каждый запрошенный layerId → его features; форма dict[int, list].""" + client = NSPDClient() + calls: list[int] = [] + + def fake_grid(layer_id: int, bbox: Any, **kw: Any) -> list[NSPDFeature]: + calls.append(layer_id) + return [_feat(layer_id)] + + monkeypatch.setattr(client, "get_features_in_bbox_grid", fake_grid) + + bbox = (6770000.0, 7710000.0, 6782000.0, 7722000.0) + result = client.get_riasurt_sverdl_in_bbox(bbox, [845274, 844478]) + + assert set(result) == {845274, 844478} + assert len(result[845274]) == 1 + assert result[845274][0].feature_id == "845274" + assert calls == [845274, 844478] + + +def test_get_riasurt_sverdl_in_bbox_default_all_14(monkeypatch: pytest.MonkeyPatch) -> None: + """layers=None → все 14 ключевых слоёв.""" + client = NSPDClient() + monkeypatch.setattr(client, "get_features_in_bbox_grid", lambda lid, bbox, **kw: [_feat(lid)]) + result = client.get_riasurt_sverdl_in_bbox((0.0, 0.0, 100.0, 100.0)) + assert set(result) == set(RIASURT_SVERDL_LAYERS) + + +def test_get_riasurt_sverdl_in_bbox_skips_failed_layer(monkeypatch: pytest.MonkeyPatch) -> None: + """Упавший слой (WAF) → его ключ = [], остальные продолжают.""" + client = NSPDClient() + + def fake_grid(layer_id: int, bbox: Any, **kw: Any) -> list[NSPDFeature]: + if layer_id == 846365: + raise NspdLiteWafError("HTTP 429") + return [_feat(layer_id)] + + monkeypatch.setattr(client, "get_features_in_bbox_grid", fake_grid) + result = client.get_riasurt_sverdl_in_bbox((0.0, 0.0, 100.0, 100.0), [845274, 846365, 844478]) + + assert result[846365] == [] + assert len(result[845274]) == 1 + assert len(result[844478]) == 1 diff --git a/backend/tests/workers/test_riasurt_sverdl_harvest.py b/backend/tests/workers/test_riasurt_sverdl_harvest.py new file mode 100644 index 00000000..9dc481de --- /dev/null +++ b/backend/tests/workers/test_riasurt_sverdl_harvest.py @@ -0,0 +1,149 @@ +"""Тесты harvest_riasurt_sverdl_for_mo / harvest_all_riasurt_sverdl (#108). + +Сеть/БД не дёргаются: мокаем NSPDClient + SessionLocal. Проверяем delete-per-MO, +insert-параметры (layer_topic-классификация, mo_name), skip битых фич, прогон по 5 МО. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any + +from app.workers.tasks import riasurt_sverdl_harvest + + +class _Feat: + def __init__(self, geometry: dict[str, Any] | None, properties: dict[str, Any]) -> None: + self.geometry = geometry + self.properties = properties + + +class _FakeClient: + """NSPDClient-двойник: bbox → 1 фича на каждый запрошенный слой.""" + + def __init__(self, *a: Any, **kw: Any) -> None: + self.bbox_calls: list[Any] = [] + + def get_riasurt_sverdl_in_bbox( + self, bbox: Any, layers: list[int] | None = None, **kw: Any + ) -> dict[int, list[_Feat]]: + self.bbox_calls.append(bbox) + layer_ids = layers or list(riasurt_sverdl_harvest.RIASURT_SVERDL_LAYERS.keys()) + return { + lid: [ + _Feat( + {"type": "Polygon", "coordinates": [[[1, 1], [2, 1], [2, 2], [1, 1]]]}, + {"label": f"zone-{lid}", "reg_numb_border": f"66:35-{lid}"}, + ) + ] + for lid in layer_ids + } + + +class _FakeDB: + def __init__(self) -> None: + self.executed: list[tuple[Any, dict[str, Any] | None]] = [] + self.commit_count = 0 + self.rollback_count = 0 + self.closed = False + + @contextmanager + def begin_nested(self) -> Any: + yield + + def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any: + self.executed.append((sql, params)) + return None + + def commit(self) -> None: + self.commit_count += 1 + + def rollback(self) -> None: + self.rollback_count += 1 + + def close(self) -> None: + self.closed = True + + +def _patch(monkeypatch: Any, db: _FakeDB) -> _FakeClient: + client = _FakeClient() + monkeypatch.setattr(riasurt_sverdl_harvest, "NSPDClient", lambda *a, **kw: client) + monkeypatch.setattr(riasurt_sverdl_harvest, "SessionLocal", lambda: db) + return client + + +def test_harvest_for_mo_deletes_then_inserts(monkeypatch: Any) -> None: + """1 МО × 2 слоя → delete-MO + 2 insert; commit + close.""" + db = _FakeDB() + _patch(monkeypatch, db) + + res = riasurt_sverdl_harvest.harvest_riasurt_sverdl_for_mo( + "Берёзовский", + (6770000.0, 7710000.0, 6782000.0, 7722000.0), + [845274, 844478], + ) + + assert res == {"features": 2, "layers": 2} + assert db.commit_count == 1 + assert db.closed is True + # 1 delete + 2 insert + assert len(db.executed) == 3 + delete_params = db.executed[0][1] + assert delete_params == {"mo_name": "Берёзовский"} + + +def test_harvest_for_mo_inserts_correct_topic(monkeypatch: Any) -> None: + """layer_topic денормализуется из riasurt_layer_topic; mo_name проставлен.""" + db = _FakeDB() + _patch(monkeypatch, db) + + riasurt_sverdl_harvest.harvest_riasurt_sverdl_for_mo( + "Сысерть", (0.0, 0.0, 100.0, 100.0), [845274, 845425] + ) + + insert_params = [p for _, p in db.executed if p and "layer_topic" in p] + topics = {p["source_layer_id"]: p["layer_topic"] for p in insert_params} + assert topics[845274] == "territorial_zone" + assert topics[845425] == "flood_zone" + assert all(p["mo_name"] == "Сысерть" for p in insert_params) + assert all(p["obshnz"] == f"66:35-{p['source_layer_id']}" for p in insert_params) + + +def test_harvest_for_mo_skips_geomless_feature(monkeypatch: Any) -> None: + """Фича без geometry не пишется (skip), счётчик не растёт.""" + db = _FakeDB() + client = _FakeClient() + + def only_empty(bbox: Any, layers: list[int] | None = None, **kw: Any) -> dict[int, list[_Feat]]: + return {845274: [_Feat(None, {"label": "broken"})]} + + client.get_riasurt_sverdl_in_bbox = only_empty # type: ignore[method-assign] + monkeypatch.setattr(riasurt_sverdl_harvest, "NSPDClient", lambda *a, **kw: client) + monkeypatch.setattr(riasurt_sverdl_harvest, "SessionLocal", lambda: db) + + res = riasurt_sverdl_harvest.harvest_riasurt_sverdl_for_mo("Арамиль", (0.0, 0.0, 10.0, 10.0)) + assert res["features"] == 0 + # только delete-MO, никаких insert + assert len(db.executed) == 1 + + +def test_harvest_all_iterates_5_mo(monkeypatch: Any) -> None: + """harvest_all прогоняет все 5 МО агломерации.""" + db = _FakeDB() + _patch(monkeypatch, db) + + res = riasurt_sverdl_harvest.harvest_all_riasurt_sverdl([845274]) + assert res["mo"] == 5 + # 5 МО × 1 фича каждая + assert res["features"] == 5 + + +def test_mo_bboxes_has_5_agglomeration_municipalities() -> None: + """MO_BBOXES содержит ровно 5 МО окраин агломерации.""" + assert set(riasurt_sverdl_harvest.MO_BBOXES) == { + "Берёзовский", + "Верхняя Пышма", + "Среднеуральск", + "Арамиль", + "Сысерть", + } diff --git a/data/sql/163_riasurt_sverdl.sql b/data/sql/163_riasurt_sverdl.sql new file mode 100644 index 00000000..0c6f3e33 --- /dev/null +++ b/data/sql/163_riasurt_sverdl.sql @@ -0,0 +1,52 @@ +-- 163_riasurt_sverdl.sql +-- РИАСУРТ Свердл (folderId 1224) — 14-слойный harvest по агломерации ЕКБ (#108). +-- Источник: NSPD aeggis/v4 WMS, региональный каталог РИАСУРТ Свердл. Покрывает ОКРАИНЫ +-- агломерации ЕКБ (Берёзовский, В.Пышма, Среднеуральск, Арамиль, Сысерть), НЕ ЕКБ-сити +-- (тот не интегрирован в ФГИС ТП). Foundation для multi-city scaling Site Finder. +-- +-- Наполняется Celery task harvest_all_riasurt_sverdl (riasurt_sverdl_harvest.py). +-- Читается в analyze_parcel через riasurt_lookup.parcel_riasurt_overlaps (ST_Intersects). +-- +-- Deploy order: apply after 162_drop_district_economics_sat_factor.sql. +-- Зависимости: PostGIS, spatial_ref_sys (МСК-66 srid=100000 заведён в 91_engineering_networks_191fz.sql). +-- +-- geom — EPSG:3857 (НСПД отдаёт в Web Mercator); geom_msk66 — generated STORED ST_Transform +-- в МСК-66 (srid=100000), официальную проекцию Свердл для градостроительных документов. + +BEGIN; + +CREATE TABLE IF NOT EXISTS riasurt_sverdl ( + id BIGSERIAL PRIMARY KEY, + -- layerId РИАСУРТ Свердл (845274 Тер.зона, 846381 Функц.зоны, 844478 КРТ, ...). + source_layer_id INTEGER NOT NULL, + -- бакет слоя для analyze-gate: 'territorial_zone' | 'functional_zone' | 'red_lines' | + -- 'szz' | 'szo' | 'flood_zone' | 'krt' | ... (см. nspd_client.riasurt_layer_topic). + layer_topic TEXT NOT NULL, + -- МО агломерации, к которому привязан harvest-проход (Берёзовский / В.Пышма / ...). + mo_name TEXT, + -- reg_numb_border / индекс зоны из properties фичи (когда есть). + obshnz TEXT, + -- человекочитаемое описание / название фичи (label / name из properties). + description TEXT, + -- сырые properties фичи WMS GetFeatureInfo — для будущего резолва без re-harvest. + raw_props JSONB NOT NULL DEFAULT '{}'::jsonb, + -- геометрия в EPSG:3857 (как отдаёт НСПД). + geom GEOMETRY(MULTIPOLYGON, 3857) NOT NULL, + -- МСК-66 (официальная проекция Свердл) — generated STORED из geom. + geom_msk66 GEOMETRY(MULTIPOLYGON, 100000) + GENERATED ALWAYS AS (ST_Transform(geom, 100000)) STORED, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- GIST на обе геометрии — analyze делает ST_Intersects по geom (после трансформа участка +-- в 3857) либо по geom_msk66; индексируем обе чтобы планировщик имел выбор. +CREATE INDEX IF NOT EXISTS idx_riasurt_sverdl_geom + ON riasurt_sverdl USING GIST (geom); +CREATE INDEX IF NOT EXISTS idx_riasurt_sverdl_geom_msk66 + ON riasurt_sverdl USING GIST (geom_msk66); + +-- Фильтр по topic в analyze (tier / func zone / red_lines / szz / szo / flood / krt). +CREATE INDEX IF NOT EXISTS idx_riasurt_sverdl_layer_topic + ON riasurt_sverdl (layer_topic); + +COMMIT;