"""Унифицированный NSPD API client — foundation для G1/G2/G3/E1/#93. Issue #94: при исследовании НСПД через debug browser найдено что REST `/api/geoportal/v2/search/geoportal?query={cad}` возвращает GeoJSON + полные properties (ВРИ, категория земель, кадастровая стоимость, адрес) за один запрос. Плюс WMS endpoints для тематических слоёв (ПЗЗ, ЗОУИТ, территориальные зоны, risk zones). Этот клиент покрывает 4 базовых метода API: - search_by_cad(cad) → SearchResult — поиск по cad-номеру - get_feature_info(layer_id, lon, lat) → list[Feature] — feature data на точке - get_features_in_bbox(layer_id, bbox) → list[Feature] — bbox bulk через GetFeatureInfo (WFS GetCapabilities → 404, workaround) - list_layers(theme_id) → list[Layer] — каталог слоёв в теме Reuse: `fetch_geoportal` из `nspd_lite.py` (WAF-compatible urllib + rate limit). Расширения сверх nspd_lite: - Typed wrappers (NSPDFeature, NSPDLayer, NSPDSearchResult) - WMS GetFeatureInfo / GetMap helpers (нет в nspd_lite) - Coordinate transform EPSG:3857 ↔ 4326 (NSPD WMS работает в Mercator) WMS endpoints (per #94 issue body, TIER 1-6 каталог слоёв): - TIER 1 критичные: 36048 ЗУ, 36071 кварталы, 36049 здания, 36328 сооружения, 875838 территориальные зоны (G1), 879243 красные линии - TIER 2 ЗОУИТ (G3): 37577 ОКН, 37578 энергетики/связи, 37580 природные, 37579 охраняемых объектов, 37581 иные - TIER 3 риски: 872202 подтопление, 872205 затопление, 872203 заболачивание, 872210 обвально-осыпные, 872211 абразия, etc. """ from __future__ import annotations import asyncio import datetime as _dt import json import logging import math import time import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass from typing import Any from app.services.scrapers.nspd_denorm import classify_engineering_kind from app.services.scrapers.nspd_lite import ( _SSL_CTX, HEADERS, NspdLiteError, NspdLiteWafError, fetch_geoportal, ) logger = logging.getLogger(__name__) # WMS base — отдельный путь от geoportal/v2/search NSPD_WMS_BASE = "https://nspd.gov.ru/api/aeggis/v4" # Layers catalog endpoint NSPD_LAYERS_TREE = "https://nspd.gov.ru/api/geoportal/v1/layers-theme-tree" # Themes list NSPD_THEMES = "https://nspd.gov.ru/api/geoportal/v1/layers-theme" # Стандартные theme ID THEME_PKK = 1 THEME_ARN = 665 # ── Layer ID catalog (TIER 1-6 per #94) ────────────────────────────────────── # Не enum, а module-level dict — для удобства интроспекции и тестов. # Использовать как: LAYERS["territorial_zones"] = 875838. LAYERS: dict[str, int] = { # TIER 1 — критичные для МКД (Gate-факторы) "parcels": 36048, # Земельные участки ЕГРН "quarters": 36071, # Кадастровые кварталы "buildings": 36049, # Здания ЕГРН "engineering_structures": 36328, # Сооружения ЕГРН "territorial_zones": 875838, # ПЗЗ (G1 #28) "red_lines": 879243, # Красные линии # TIER 2 — ЗОУИТ (G3 #30) "zouit_okn": 37577, # ОКН "zouit_engineering": 37578, # энергетики/связи/транспорта "zouit_natural": 37580, # природные (водоохранные, СЗО) "zouit_protected": 37579, # охраняемых объектов (СЗЗ предприятий) "zouit_other": 37581, # иные (публичный сервитут, ОЭЗ) # TIER 3 — Risk / Negative "risk_flooding_underground": 872202, # подтопление "risk_flooding": 872205, # затопление "risk_swampification": 872203, # заболачивание "risk_landslide": 872210, # обвально-осыпные "risk_abrasion": 872211, # абразия "risk_erosion_water": 872153, # водная эрозия "risk_erosion_linear": 872155, "risk_erosion_wind": 872164, "risk_desertification": 872182, "risk_clutter": 872206, # захламление "risk_burns": 872222, # гари # TIER 4 — Opportunity / nature "protected_areas": 875845, # ООПТ "coast_lines_river": 875832, "coast_lines_sea": 875835, "forestries": 875866, "auction_parcels": 37299, # ЗУ на аукционе — opportunity "scheme_parcels": 37294, # ЗУ по схеме расположения "free_parcels": 37298, # ЗУ свободные от прав "future_parcels": 36473, # ЗУ по проекту межевания "krt_territories": 37430, # территории ККР (комплексное развитие) — opportunity (#1086) "okn_territory": 875840, "cadastral_cost_heatmap": 37236, "cadastral_cost_per_m2_heatmap": 37758, } # Layers where grid-walk (get_features_in_bbox_grid) must be used instead of # the legacy single-pixel WMS probe (get_features_in_bbox). These are area/ # linear layers (territorial zones, red lines, engineering structures, ЗОУИТ, # risks) that span large areas and are under-returned by a single GetFeatureInfo # call. Point/polygon EGRN layers (parcels, buildings) stay on legacy for now. _GRID_WALK_LAYERS: frozenset[str] = frozenset( { "territorial_zones", "red_lines", "engineering_structures", # ЗОУИТ (TIER 2) "zouit_okn", "zouit_engineering", "zouit_natural", "zouit_protected", "zouit_other", # Risks (TIER 3) "risk_flooding_underground", "risk_flooding", "risk_swampification", "risk_landslide", "risk_abrasion", "risk_erosion_water", "risk_erosion_linear", "risk_erosion_wind", "risk_desertification", "risk_clutter", "risk_burns", } ) # ── РИАСУРТ Свердл (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 # SSL context — reuse `_SSL_CTX` из nspd_lite (one source of truth для WAF- # compatible TLS settings; нет дублирования если что-то поменяется). # ── Typed responses ────────────────────────────────────────────────────────── @dataclass(frozen=True, slots=True) class NSPDFeature: """Парсенный GeoJSON Feature из NSPD API. `geometry` — raw GeoJSON dict (Polygon/LineString/Point в EPSG:3857). `properties` — все returned attributes (cad_num, ВРИ, кост и т.д.). """ feature_id: str | None geometry: dict[str, Any] | None properties: dict[str, Any] crs: str = "EPSG:3857" # NSPD default; для search /v2 фактически 3857 @classmethod def from_raw(cls, raw: dict[str, Any]) -> NSPDFeature: """Парсим GeoJSON Feature dict в typed NSPDFeature.""" props = raw.get("properties") or {} return cls( feature_id=str(raw.get("id")) if raw.get("id") is not None else None, geometry=raw.get("geometry"), properties=props, ) @dataclass(frozen=True, slots=True) class NSPDSearchResult: """Aggregated результат поиска по cad — typed wrapper над feature list.""" cad_num: str features: list[NSPDFeature] raw: dict[str, Any] # original response для debugging / future-proofing @property def first(self) -> NSPDFeature | None: """Удобный shortcut: features[0] или None.""" return self.features[0] if self.features else None @property def is_empty(self) -> bool: return not self.features @dataclass(frozen=True, slots=True) class NSPDLayer: """Метаданные layer'а из layers-theme-tree response.""" layer_id: int title: str layer_type: str | None # 'wms' | 'wfs' | etc metadata: dict[str, Any] @dataclass(frozen=True, slots=True) class QuarterDump: """Comprehensive snapshot of NSPD data в пределах одного quarter. Foundation для PKK harvest pipeline (#94 follow-up): - `quarter` — собственно квартал (geometry в EPSG:3857) - per-layer feature lists в пределах квартала (bbox-filtered) Один dump = достаточно данных чтобы analyze_parcel мог отдать ВСЕ Gate- факторы (G1 ПЗЗ, G3 ЗОУИТ), neighbors, инженерку — без новых NSPD-вызовов в request-цикле. Беём дешевле для пользователя (1 HTTP вместо N). Layers распределены по 3 phases: - core: parcels + buildings + territorial_zones + red_lines + engineering - zouit: 5 ЗОУИТ layers (G3) - risks: 11 risk-zone layers (TIER 3) Default = только core, чтобы не сжигать rate-limit на 17 запросов. """ quarter_cad: str quarter: NSPDFeature | None # сам polygon квартала (может быть None если NSPD пуст) parcels: list[NSPDFeature] buildings: list[NSPDFeature] territorial_zones: list[NSPDFeature] # ПЗЗ зоны покрывающие/пересекающие red_lines: list[NSPDFeature] engineering_structures: list[NSPDFeature] zouit: dict[str, list[NSPDFeature]] # {"okn": [...], "engineering": [...], ...} risks: dict[str, list[NSPDFeature]] # {"flooding": [...], "landslide": [...], ...} # TIER 4 opportunity layers (issue #94 PR2). # {"auction_parcels": [...], "scheme_parcels": [...], "free_parcels": [...], ...} opportunity: dict[str, list[NSPDFeature]] # tuple, не list — frozen dataclass + immutable contents (audit/debug snapshot) layers_fetched: tuple[str, ...] bbox_3857: tuple[float, float, float, float] | None # bbox квартала fetched_at_utc: str # ISO timestamp когда сделали запрос (для freshness check) @property def total_features(self) -> int: """Сумма всех features во всех layers — для smoke check.""" return ( len(self.parcels) + len(self.buildings) + len(self.territorial_zones) + len(self.red_lines) + len(self.engineering_structures) + sum(len(v) for v in self.zouit.values()) + sum(len(v) for v in self.risks.values()) + sum(len(v) for v in self.opportunity.values()) ) # ── HTTP helper ────────────────────────────────────────────────────────────── def _http_get_json(url: str, *, timeout: int = 15, rate_ms: int = 0) -> Any: """Внутренний GET-JSON helper с WAF-handling, paralleling nspd_lite. Используется для endpoints которые не покрыты `fetch_geoportal` (WMS, layers-tree). Reuse HEADERS + SSL context из nspd_lite — общая защита от WAF блокировки по TLS fingerprint. """ if rate_ms > 0: time.sleep(rate_ms / 1000.0) req = urllib.request.Request(url, headers=HEADERS) try: with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r: body = r.read().decode("utf-8", "ignore") return json.loads(body) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", "ignore")[:300] if e.fp else "" if e.code in (403, 429): raise NspdLiteWafError(f"HTTP {e.code} (WAF/rate-limit): {body}") from e raise NspdLiteError(f"HTTP {e.code}: {body}") from e except urllib.error.URLError as e: raise NspdLiteError(f"Network error: {e}") from e # ── Coordinate transforms ──────────────────────────────────────────────────── def lonlat_to_3857(lon: float, lat: float) -> tuple[float, float]: """WGS84 lon/lat → EPSG:3857 Web Mercator (метры). Стандартная формула; стандартная для всех Web mapping. NSPD WMS принимает bbox в 3857. """ x = lon * 20037508.34 / 180.0 y = math.log(math.tan((90.0 + lat) * math.pi / 360.0)) / (math.pi / 180.0) y = y * 20037508.34 / 180.0 return (x, y) def bbox_around_point_m( lon: float, lat: float, buffer_m: int, ) -> tuple[float, float, float, float]: """Микро-bbox вокруг точки в EPSG:3857 — для WMS GetFeatureInfo. Returns (xmin, ymin, xmax, ymax). buffer_m по m в каждую сторону. """ x, y = lonlat_to_3857(lon, lat) return (x - buffer_m, y - buffer_m, x + buffer_m, y + buffer_m) # ── Public client API ──────────────────────────────────────────────────────── class NSPDClient: """Foundation-level NSPD client. 4 core methods. Stateless — все методы могут вызываться независимо. Используй один instance на process (rate-limit shared между вызовами через time.sleep). """ def __init__(self, rate_ms: int = DEFAULT_RATE_MS, timeout: int = 15) -> None: self.rate_ms = rate_ms self.timeout = timeout # ── 1. search_by_cad ──────────────────────────────────────────────────── def search_by_cad(self, cad_num: str, thematic_id: int = 1) -> NSPDSearchResult: """REST поиск /v2/search/geoportal — 1 запрос = geometry + ВРИ + кост. Args: cad_num: канонический формат — `NN:NN:NNNNNN[:NN[:NN]]`, цифры через `:`. Примеры: - 3-сегментный квартал: `'66:41:0204016'` - 4-сегментный участок: `'66:41:0204016:10'` - 5-сегментное здание: `'66:41:0204016:10:1'` Спецсимволы / пробелы корректно URL-encode'нутся (нет SQL/URL injection), но НСПД молча вернёт empty result. Caller обязан сам валидировать формат если нужна defensive обработка (см. `app.services.site_finder.cadastre_fetch.validate_cad_format`). thematic_id: 1 ЗУ / 2 квартал / 5 здание / 7 территориальная зона. Default 1 — соответствует use-case G2 ВРИ. Returns: NSPDSearchResult с typed features. `result.is_empty` → cad не найден. Raises: NspdLiteWafError при 403/429 — caller должен сделать backoff. NspdLiteError при прочих 4xx/5xx. Закрывает: G2 #29 ВРИ, поддержка on-demand #93/#51. """ raw = fetch_geoportal( cad_num, thematic_id=thematic_id, timeout=self.timeout, rate_ms=self.rate_ms, ) # Response shape: {"data": {"type": "FeatureCollection", "features": [...]}} data = raw.get("data") or {} feats = data.get("features") or [] features = [NSPDFeature.from_raw(f) for f in feats] return NSPDSearchResult(cad_num=cad_num, features=features, raw=raw) # ── 2. get_feature_info ───────────────────────────────────────────────── def get_feature_info( self, layer_id: int, lon: float, lat: float, buffer_m: int = 100, feature_count: int = 10, ) -> list[NSPDFeature]: """WMS GetFeatureInfo на точке — какие feature'ы layer'а покрывают (lon, lat). Использует микро-bbox вокруг точки в EPSG:3857 (WMS требует bbox + I/J пиксель). Tile 256x256, I=128, J=128 — центр. Args: feature_count: макс. число фич в ответе (WMS FEATURE_COUNT). Default 10 (паритет с порталом nspd.gov.ru). Без него NSPD по умолчанию отдаёт ТОЛЬКО 1 фичу на точку — а на ЕКБ в одной точке часто пересекаются несколько зон одного слоя (напр. 37581 «иные ЗОУИТ» вернул 2). Для overlay-ингеста (#1067) нужно >1. Returns features (может быть 0+, до feature_count). Use cases: - ВРИ конкретного участка (layer_id=875838 territorial_zones) - ЗОУИТ на участке (layer_id=37577..37581) - Risk zones overlap (872xxx) """ xmin, ymin, xmax, ymax = bbox_around_point_m(lon, lat, buffer_m) params = { "SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetFeatureInfo", "FORMAT": "image/png", "TRANSPARENT": "true", "QUERY_LAYERS": str(layer_id), "LAYERS": str(layer_id), "INFO_FORMAT": "application/json", # FEATURE_COUNT: NSPD по умолчанию отдаёт 1 фичу/точку → теряем # перекрывающиеся зоны. Портал шлёт 10. Без этого overlay-ингест неполон. "FEATURE_COUNT": str(feature_count), # STYLES обязателен (пустой), иначе INFO_FORMAT игнорируется per NSPD WAF "STYLES": "", "I": "128", "J": "128", "WIDTH": "256", "HEIGHT": "256", "CRS": "EPSG:3857", "BBOX": f"{xmin},{ymin},{xmax},{ymax}", } url = f"{NSPD_WMS_BASE}/{layer_id}/wms?{urllib.parse.urlencode(params)}" data = _http_get_json(url, timeout=self.timeout, rate_ms=self.rate_ms) feats = (data or {}).get("features") or [] return [NSPDFeature.from_raw(f) for f in feats] # ── 3. get_features_in_bbox ───────────────────────────────────────────── def get_features_in_bbox( self, layer_id: int, bbox_3857: tuple[float, float, float, float], *, width: int = 4096, height: int = 4096, ) -> list[NSPDFeature]: """WMS GetFeatureInfo на одном центральном пикселе bbox. DEPRECATED: возвращает 0-3 features под одним пикселем (I=W/2, J=H/2). НЕ является bulk fetch несмотря на исходный docstring — WMS GetFeatureInfo по стандарту OGC возвращает объекты строго под одной pixel-точкой, а не во всём bbox. Для получения всех объектов в bbox используй `get_features_in_bbox_grid`. See: fixes/Bug_NSPD_WMS_NotBulk_2026_May14.md Args: bbox_3857: (xmin, ymin, xmax, ymax) в EPSG:3857 метрах. width/height: размер виртуального tile. Returns: list[NSPDFeature]; пусто если ничего не найдено. """ xmin, ymin, xmax, ymax = bbox_3857 params = { "SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetFeatureInfo", "FORMAT": "image/png", "TRANSPARENT": "true", "QUERY_LAYERS": str(layer_id), "LAYERS": str(layer_id), "INFO_FORMAT": "application/json", "STYLES": "", "I": str(width // 2), "J": str(height // 2), "WIDTH": str(width), "HEIGHT": str(height), "CRS": "EPSG:3857", "BBOX": f"{xmin},{ymin},{xmax},{ymax}", } url = f"{NSPD_WMS_BASE}/{layer_id}/wms?{urllib.parse.urlencode(params)}" data = _http_get_json(url, timeout=self.timeout, rate_ms=self.rate_ms) feats = (data or {}).get("features") or [] return [NSPDFeature.from_raw(f) for f in feats] # ── 3b. get_features_in_bbox_grid ─────────────────────────────────────── def get_features_in_bbox_grid( self, layer_id: int, bbox: tuple[float, float, float, float], *, grid_n: int = 7, step_m: float = 50.0, tile_size: int = 512, ) -> list[NSPDFeature]: """Bulk-аппроксимация bbox через grid-walk WMS GetFeatureInfo. Разбивает bbox на grid_n × grid_n равных ячеек. В каждой ячейке вызывает WMS GetFeatureInfo в центральном пикселе. Дедуплицирует результаты по feature_id / cad_num / reg_numb_border — возвращает список уникальных NSPDFeature. Делегирует HTTP через NSPDBulkClient.wms_feature_info (async httpx с semaphore и retry), запуская asyncio event loop синхронно через asyncio.run(). Предназначен для вызова из синхронного кода (Celery task, FastAPI sync handler). Concurrency: NSPDBulkClient._sem (per-instance, capacity=3) ограничивает параллельные запросы. При grid_n=7 (49 ячеек) — все 49 ячеек запускаются одним gather; семафор пропускает не более 3 одновременно. Thread-safety: каждый вызов get_features_in_bbox_grid создаёт новый event loop через asyncio.run() — безопасно из разных Celery workers (process- уровень изоляции). Args: layer_id: NSPD layer ID (например 36328 сооружения, 37578 ЗОУИТ). bbox: (xmin, ymin, xmax, ymax) в EPSG:3857 (метры). grid_n: размер сетки по каждой оси. 7 → 49 запросов (~coarse), 15 → 225 запросов (~fine). По умолчанию 7 для первичного scan. step_m: минимальный шаг ячейки в метрах. Если bbox меньше grid_n*step_m — grid_n уменьшается автоматически чтобы ячейки не становились меньше step_m. tile_size: размер виртуального WMS тайла (пиксели). Returns: Дедуплицированный list[NSPDFeature]. Может быть пуст если в bbox нет объектов данного layer'а. Note: Не делает live HTTP вызовы если вызван с mock NSPDBulkClient. Rate-limit управляется семафором NSPDBulkClient._sem (per-instance, capacity=3) + asyncio.sleep(0.05) jitter — не через self.rate_ms. """ # Импортируем здесь чтобы избежать circular import: # nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain) from app.scrapers.nspd_bulk_client import NSPDBulkClient xmin, ymin, xmax, ymax = bbox width_m = xmax - xmin height_m = ymax - ymin # Авто-коррекция grid_n если bbox мал для шага step_m effective_n = min( grid_n, max(1, int(width_m / step_m)), max(1, int(height_m / step_m)), ) if effective_n < grid_n: logger.info( "get_features_in_bbox_grid layer=%d: bbox %.0fx%.0fm < grid_n=%d×step_m=%.0f" " — уменьшаем grid до %d×%d", layer_id, width_m, height_m, grid_n, step_m, effective_n, effective_n, ) x_step = width_m / effective_n y_step = height_m / effective_n # Генерируем список (sub_bbox, click_xy) ячеек cells: list[tuple[tuple[float, float, float, float], tuple[int, int]]] = [] click_px = tile_size // 2 for i in range(effective_n): for j in range(effective_n): cell_xmin = xmin + i * x_step cell_ymin = ymin + j * y_step cell_xmax = cell_xmin + x_step cell_ymax = cell_ymin + y_step cells.append(((cell_xmin, cell_ymin, cell_xmax, cell_ymax), (click_px, click_px))) async def _run_grid() -> list[NSPDFeature]: async with NSPDBulkClient() as client: tasks = [ client.wms_feature_info(layer_id, sub_bbox, click_xy, tile_size, tile_size) for sub_bbox, click_xy in cells ] results = await asyncio.gather(*tasks, return_exceptions=True) features: list[NSPDFeature] = [] for r in results: if isinstance(r, Exception): logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r) continue for bulk_feat in r: raw = { "id": bulk_feat.id, "geometry": bulk_feat.geometry, "properties": bulk_feat.properties, } features.append(NSPDFeature.from_raw(raw)) return features raw_features = asyncio.run(_run_grid()) # Дедупликация — приоритет ключей: feature_id > cad_num > reg_numb_border seen: set[str] = set() deduped: list[NSPDFeature] = [] for feat in raw_features: props = feat.properties dedup_key = ( feat.feature_id or props.get("cad_num") or props.get("cad_number") or props.get("reg_numb_border") or props.get("label") ) if dedup_key is not None: if dedup_key in seen: continue seen.add(dedup_key) deduped.append(feat) logger.info( "get_features_in_bbox_grid layer=%d grid=%dx%d cells=%d raw=%d deduped=%d", layer_id, effective_n, effective_n, len(cells), len(raw_features), len(deduped), ) 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]: """Полный каталог слоёв в теме (PKK / ARN). Use case: admin UI «выбрать layer для bulk-импорта», discovery новых слоёв при изменении NSPD каталога. """ url = f"{NSPD_LAYERS_TREE}?themeId={theme_id}" data = _http_get_json(url, timeout=self.timeout, rate_ms=self.rate_ms) # Shape defense: NSPD иногда оборачивает в {"data": [...]} — паттерн уже # ловили в Bug_Nspd_Geo_Str_Object_No_Get_Fixed. Без проверки walker # молча вернул бы [], а caller увидел бы пустой catalog. if ( isinstance(data, dict) and "data" in data and not data.get("children") and "layers" not in data ): data = data.get("data") or [] # Реальная форма layers-theme-tree: {"layers": [...flat...], "tree": ...}, # где каждая запись = {"layerId": N, "title", "layerName", "layerType"}. # Раньше walker искал id/children и для этой (актуальной) формы молча # возвращал [] — слои не в children-дереве, а в плоском layers[] # (см. research/NSPD_IRD_Layers_Automation_Recon_Jun06). Сначала пробуем # плоский layers[], иначе fallback на nested-tree walker (id/children). if isinstance(data, dict) and isinstance(data.get("layers"), list): raw_entries: list[dict[str, Any]] = data["layers"] elif isinstance(data, list | dict): raw_entries = _walk_layer_tree(data) else: logger.warning( "list_layers: unexpected NSPD response type %s for theme_id=%s", type(data).__name__, theme_id, ) return [] layers: list[NSPDLayer] = [] for entry in raw_entries: # актуальная форма использует layerId; старая nested-tree — id raw_id = entry.get("layerId", entry.get("id")) if raw_id is None: continue layers.append( NSPDLayer( layer_id=int(raw_id), title=str(entry.get("title", "")), layer_type=entry.get("layerType") or entry.get("layer_type"), metadata=entry, ) ) return layers # ── 5. search_by_quarter (PKK harvest orchestrator) ───────────────────── # Layer groupings для search_by_quarter. Назван "phase" т.к. в Celery # task может включаться выборочно (rate-limit budget). QUARTER_CORE_LAYERS: dict[str, str] = { # noqa: RUF012 — class-level constant "parcels": "parcels", "buildings": "buildings", "territorial_zones": "territorial_zones", "red_lines": "red_lines", "engineering_structures": "engineering_structures", } QUARTER_ZOUIT_LAYERS: dict[str, str] = { # noqa: RUF012 "okn": "zouit_okn", "engineering": "zouit_engineering", "natural": "zouit_natural", "protected": "zouit_protected", "other": "zouit_other", } QUARTER_RISK_LAYERS: dict[str, str] = { # noqa: RUF012 "flooding_underground": "risk_flooding_underground", "flooding": "risk_flooding", "swampification": "risk_swampification", "landslide": "risk_landslide", "abrasion": "risk_abrasion", "erosion_water": "risk_erosion_water", "erosion_linear": "risk_erosion_linear", "erosion_wind": "risk_erosion_wind", "desertification": "risk_desertification", "clutter": "risk_clutter", "burns": "risk_burns", } # TIER 4 — Opportunity layers (issue #94 PR2). # short_name → LAYERS dict key (for get_features_in_bbox lookup). # Features stored in features_json с layer = "opportunity_". QUARTER_OPPORTUNITY_LAYERS: dict[str, str] = { # noqa: RUF012 "auction_parcels": "auction_parcels", # 37299 — аукционные ЗУ "scheme_parcels": "scheme_parcels", # 37294 — схемы расположения ЗУ "free_parcels": "free_parcels", # 37298 — свободные ЗУ "future_parcels": "future_parcels", # 36473 — планируемые ЗУ "oopt": "protected_areas", # 875845 — ООПТ } def search_by_quarter( self, quarter_cad: str, *, include_zouit: bool = True, include_risks: bool = False, include_opportunity: bool = False, ) -> QuarterDump: """Harvest всех NSPD-данных для квартала: 1 vacuum, N layers. Шаги: 1. `search_by_cad(quarter_cad, thematic_id=2)` — получить полигон квартала 2. Compute bbox в EPSG:3857 из quarter geometry (или None если NSPD пуст) 3. Для каждого core layer → `get_features_in_bbox(layer_id, bbox)` 4. Если include_zouit — то же для 5 ЗОУИТ layers 5. Если include_risks — то же для 11 risk layers Стоимость HTTP: - core only: 1 (search) + 5 (core layers) = 6 запросов - +zouit: +5 = 11 запросов - +risks: +11 = 22 запроса При rate_ms=600 один dump = ~3.6с (core) / ~6.6с (+zouit) / ~13с (всё). Args: quarter_cad: 3-сегментный cad-номер квартала, e.g. '66:41:0204016'. include_zouit: Включать TIER 2 ЗОУИТ layers (G3). Default True. include_risks: Включать TIER 3 risk zones. Default False (rate-limit budget; для отдельного D-N risk score можно включить). include_opportunity: Включать TIER 4 opportunity layers (auction_parcels, scheme_parcels, free_parcels, future_parcels, oopt). Default False. +5 HTTP запросов при включении. Returns: QuarterDump с per-layer feature lists. Если NSPD пуст / quarter не найден — `quarter=None`, `bbox_3857=None`, все feature lists пустые (no bulk-fetch без bounds — нет смысла). При этом dict- поля `zouit` / `risks` / `opportunity` всё равно populated с пустыми lists для каждого включённого short_name (структура контракта стабильна). `layers_fetched` в этом случае содержит только `('search',)`. Raises: NspdLiteWafError при 403/429 на любом из layer запросов — caller должен делать backoff. Partial-success НЕ возвращается; вся операция атомарна (failure → exception). Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors, E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity. """ # 1. Quarter geometry через REST search quarter_search = self.search_by_cad(quarter_cad, thematic_id=2) quarter_feat: NSPDFeature | None = quarter_search.first # 2. Compute bbox в 3857. Если NSPD ничего не нашёл — bbox=None, # все layer-запросы возвращаем как empty (нет смысла bulk-fetch'ить # без bounds). bbox: tuple[float, float, float, float] | None = None if quarter_feat and quarter_feat.geometry: bbox = _geojson_bbox_3857(quarter_feat.geometry) layers_fetched: list[str] = ["search"] def _fetch_layer(name_in_dump: str, layer_key: str) -> list[NSPDFeature]: """Helper: безопасно получить features для одного layer. Dispatch: - area/linear layers (_GRID_WALK_LAYERS) → grid-walk (get_features_in_bbox_grid, grid_n=7, step_m=50) - point/polygon EGRN layers (parcels, buildings, ons, enk) → legacy single-pixel WMS (get_features_in_bbox) Engineering structures features дополнительно обогащаются properties["classified_kind"] через classify_engineering_kind. """ if bbox is None: return [] layer_id = LAYERS.get(layer_key) if layer_id is None: logger.warning("search_by_quarter: unknown layer key %s", layer_key) return [] layers_fetched.append(name_in_dump) if layer_key in _GRID_WALK_LAYERS: features = self.get_features_in_bbox_grid(layer_id, bbox, grid_n=7, step_m=50.0) # Обогатить engineering_structures classified_kind if layer_key == "engineering_structures": for feat in features: feat.properties["classified_kind"] = classify_engineering_kind( feat.properties ) logger.info( "search_by_quarter layer=%s method=grid_walk count=%d quarter=%s", name_in_dump, len(features), quarter_cad, ) return features features_legacy = self.get_features_in_bbox(layer_id, bbox) logger.info( "search_by_quarter layer=%s method=legacy count=%d quarter=%s", name_in_dump, len(features_legacy), quarter_cad, ) return features_legacy # 3. Core layers parcels = _fetch_layer("parcels", "parcels") buildings = _fetch_layer("buildings", "buildings") territorial_zones = _fetch_layer("territorial_zones", "territorial_zones") red_lines = _fetch_layer("red_lines", "red_lines") engineering_structures = _fetch_layer("engineering_structures", "engineering_structures") # 4. ЗОУИТ (G3) zouit: dict[str, list[NSPDFeature]] = {} if include_zouit: for short_name, layer_key in self.QUARTER_ZOUIT_LAYERS.items(): zouit[short_name] = _fetch_layer(f"zouit_{short_name}", layer_key) # 5. Risks (TIER 3) risks: dict[str, list[NSPDFeature]] = {} if include_risks: for short_name, layer_key in self.QUARTER_RISK_LAYERS.items(): risks[short_name] = _fetch_layer(f"risk_{short_name}", layer_key) # 6. Opportunity layers (TIER 4, issue #94 PR2) opportunity: dict[str, list[NSPDFeature]] = {} if include_opportunity: for short_name, layer_key in self.QUARTER_OPPORTUNITY_LAYERS.items(): opportunity[short_name] = _fetch_layer(f"opportunity_{short_name}", layer_key) return QuarterDump( quarter_cad=quarter_cad, quarter=quarter_feat, parcels=parcels, buildings=buildings, territorial_zones=territorial_zones, red_lines=red_lines, engineering_structures=engineering_structures, zouit=zouit, risks=risks, opportunity=opportunity, layers_fetched=tuple(layers_fetched), bbox_3857=bbox, fetched_at_utc=_dt.datetime.now(_dt.UTC).isoformat(), ) # ── Geometry helpers (module-level — для unit-test'ов) ────────────────────── def _geojson_bbox_3857( geometry: dict[str, Any], ) -> tuple[float, float, float, float] | None: """Compute bbox в EPSG:3857 из GeoJSON geometry. NSPD search /v2 возвращает coordinates в EPSG:3857 (Web Mercator metres) — напрямую compute min/max по координатам. Если geometry уже в WGS84 (нестандартный сценарий) — caller должен трансформировать заранее. Поддерживает Polygon/MultiPolygon/LineString/Point. Возвращает None для пустой/некорректной geometry. """ coords = geometry.get("coordinates") geom_type = geometry.get("type") if not coords or not geom_type: return None def _walk(node: Any) -> list[tuple[float, float]]: """Рекурсивный extract координат до получения list of (x, y) tuples.""" if isinstance(node, int | float): return [] if ( isinstance(node, list) and len(node) >= 2 and all(isinstance(v, int | float) for v in node[:2]) ): return [(float(node[0]), float(node[1]))] if isinstance(node, list): out: list[tuple[float, float]] = [] for sub in node: out.extend(_walk(sub)) return out return [] pts = _walk(coords) if not pts: return None xs = [p[0] for p in pts] ys = [p[1] for p in pts] return (min(xs), min(ys), max(xs), max(ys)) def _walk_layer_tree(node: Any) -> list[dict[str, Any]]: """Рекурсивный walker для NSPD layers-theme-tree. Yields leaf nodes.""" out: list[dict[str, Any]] = [] if isinstance(node, dict): # Leaf если есть id + нет children (или children пустой) children = node.get("children") or [] if "id" in node and not children: out.append(node) elif "id" in node and children: # Branch с id — может быть и сам layer, и группа. NSPD сейчас # treats group nodes как "не имеющий wms-endpoint" — но включаем # на случай если они оба leaves (хитрая branch). out.append(node) for ch in children: out.extend(_walk_layer_tree(ch)) elif isinstance(node, list): for item in node: out.extend(_walk_layer_tree(item)) return out __all__ = [ "LAYERS", "NSPD_THEMES", "NSPD_WMS_BASE", "RIASURT_SVERDL_LAYERS", "THEME_ARN", "THEME_PKK", "NSPDClient", "NSPDFeature", "NSPDLayer", "NSPDSearchResult", "NspdLiteError", "NspdLiteWafError", "QuarterDump", "bbox_around_point_m", "lonlat_to_3857", "riasurt_layer_topic", ]