feat(site-finder): РИАСУРТ Свердл 14-layer harvest client+schema+task for ЕКБ agglomeration (#108) #1712
11 changed files with 1051 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -190,6 +190,23 @@ 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
|
||||
|
||||
# РИАСУРТ Свердл harvest kill-switch (#108 review): ежеквартальный beat
|
||||
# `harvest_all_riasurt_sverdl` прогоняет grid-walk по MO_BBOXES. Эти bbox — ПЛЕЙСХОЛДЕРЫ
|
||||
# (грубые ±6 км вокруг центров МО), реальные административные границы резолвятся post-deploy.
|
||||
# По умолчанию OFF, иначе beat на следующем тике дёргает WMS с мусорными bbox.
|
||||
# Включать ТОЛЬКО после замены MO_BBOXES на реальные границы. Override через
|
||||
# env ENABLE_RIASURT_HARVEST=true. Single-MO harvest_riasurt_sverdl_for_mo не гейтится
|
||||
# (callable вручную для smoke-тестирования конкретного bbox).
|
||||
enable_riasurt_harvest: bool = False
|
||||
|
||||
# ── LLM infrastructure (#960) ────────────────────────────────────────────
|
||||
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
|
||||
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
154
backend/app/services/site_finder/riasurt_lookup.py
Normal file
154
backend/app/services/site_finder/riasurt_lookup.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"app.workers.tasks.mv_sales_tracker_refresh",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
271
backend/app/workers/tasks/riasurt_sverdl_harvest.py
Normal file
271
backend/app/workers/tasks/riasurt_sverdl_harvest.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""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.config import settings
|
||||
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} — обработано МО / суммарно записано фич.
|
||||
"""
|
||||
if not settings.enable_riasurt_harvest:
|
||||
logger.warning(
|
||||
"RIASURT harvest disabled — set ENABLE_RIASURT_HARVEST=true after configuring "
|
||||
"real MO_BBOXES (#108)"
|
||||
)
|
||||
return {"mo": 0, "features": 0}
|
||||
|
||||
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)
|
||||
146
backend/tests/services/site_finder/test_riasurt_lookup.py
Normal file
146
backend/tests/services/site_finder/test_riasurt_lookup.py
Normal file
|
|
@ -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
|
||||
113
backend/tests/test_riasurt_sverdl_client.py
Normal file
113
backend/tests/test_riasurt_sverdl_client.py
Normal file
|
|
@ -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
|
||||
167
backend/tests/workers/test_riasurt_sverdl_harvest.py
Normal file
167
backend/tests/workers/test_riasurt_sverdl_harvest.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Тесты 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)
|
||||
monkeypatch.setattr(
|
||||
riasurt_sverdl_harvest.settings, "enable_riasurt_harvest", True, raising=False
|
||||
)
|
||||
|
||||
res = riasurt_sverdl_harvest.harvest_all_riasurt_sverdl([845274])
|
||||
assert res["mo"] == 5
|
||||
# 5 МО × 1 фича каждая
|
||||
assert res["features"] == 5
|
||||
|
||||
|
||||
def test_harvest_all_disabled_by_default(monkeypatch: Any) -> None:
|
||||
"""Гейт #108: при выключенном флаге harvest_all возвращает early без WMS-вызовов."""
|
||||
db = _FakeDB()
|
||||
client = _patch(monkeypatch, db)
|
||||
monkeypatch.setattr(
|
||||
riasurt_sverdl_harvest.settings, "enable_riasurt_harvest", False, raising=False
|
||||
)
|
||||
|
||||
res = riasurt_sverdl_harvest.harvest_all_riasurt_sverdl([845274])
|
||||
assert res == {"mo": 0, "features": 0}
|
||||
# ни одного WMS-запроса, ни одной записи в БД
|
||||
assert client.bbox_calls == []
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_mo_bboxes_has_5_agglomeration_municipalities() -> None:
|
||||
"""MO_BBOXES содержит ровно 5 МО окраин агломерации."""
|
||||
assert set(riasurt_sverdl_harvest.MO_BBOXES) == {
|
||||
"Берёзовский",
|
||||
"Верхняя Пышма",
|
||||
"Среднеуральск",
|
||||
"Арамиль",
|
||||
"Сысерть",
|
||||
}
|
||||
52
data/sql/167_riasurt_sverdl.sql
Normal file
52
data/sql/167_riasurt_sverdl.sql
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
-- 167_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;
|
||||
Loading…
Add table
Reference in a new issue