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 1016 additions and 0 deletions
Showing only changes of commit 31e316e04e - Show all commits

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

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",
],
)
celery_app.conf.timezone = "Europe/Moscow"

View file

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

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

View file

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