feat(site-finder): РИАСУРТ Свердл 14-layer harvest client+schema+task for ЕКБ agglomeration (#108) #1712

Merged
lekss361 merged 3 commits from feat/riasurt-sverdl-layers-108 into main 2026-06-17 19:52:59 +00:00
11 changed files with 1051 additions and 0 deletions

View file

@ -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,

View file

@ -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 — при любом сбое/выключенности возвращается детерминированный

View file

@ -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",
]

View 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",
]

View file

@ -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

View file

@ -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",
],
)

View 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)

View 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

View 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

View 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) == {
"Берёзовский",
"Верхняя Пышма",
"Среднеуральск",
"Арамиль",
"Сысерть",
}

View 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;