Расширение ekb_geoportal_client.py — urbanCard pipeline для ПЗЗ-регламента (#1067 C8): searchByGeom → urbanCard → parse_zone_regulation → ZoneRegulation (zone_index + 3 списка ВРИ 540н + предельные параметры). Метод zone_regulation_at(lon,lat). Supplement к WFS (C7), аддитивно. 11 тестов. Wiring в C8b cache-таблицу. Refs #1067, #1059. Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
300 lines
15 KiB
Python
300 lines
15 KiB
Python
"""ЕКБ муниципальный геопортал (ИСОГД / Geometa) — WFS-клиент (#1067 C7).
|
||
|
||
Геопортал Екатеринбурга (`геопортал.екатеринбург.рф`) — открытый GeoServer на ~1149 слоёв,
|
||
без авторизации, нормальный TLS. Единый источник «что построят / землепользование» для ЕКБ:
|
||
ПЗЗ-регламент, функц.зоны генплана, КРТ, ППТ/ПМТ, ЗОУИТ — то, чего НСПД (только геометрия +
|
||
reg-номер) и городские документы по-отдельности не дают структурно.
|
||
|
||
Primary интерфейс — **стандартный OGC WFS GetFeature** (`/api/gis/ows/3/portal/wfs`):
|
||
OGC-стандарт стабильнее reverse-engineered `urbanCard`-API и покрывает все слои одним вызовом.
|
||
|
||
⚠️ WMS GetFeatureInfo на этом GeoServer багует на reproject («too close to a pole») —
|
||
использовать ТОЛЬКО WFS GetFeature.
|
||
|
||
CRS: WFS отдаёт `geoloc` (геометрия) в EPSG:4326 (lon/lat). В CQL `POINT(lon lat)` порядок
|
||
X=lon, Y=lat. `geoloc_orig` (МСК-66) НЕ запрашиваем.
|
||
|
||
Источник разведки (live-verified 2026-06-06): vault
|
||
research/EKB_Geoportal_WFS_Construction_Intel_Jun06.
|
||
Рич ПЗЗ-карточка (3 списка ВРИ + предельные параметры) — через urbanCard (#1067 C8):
|
||
searchByGeom → urbanCard → parse_zone_regulation (см. research/EKB_Geoportal_PZZ_Content_API_Jun06).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Punycode-хост геопортала (= геопортал.екатеринбург.рф).
|
||
# В коде только punycode — кириллица в URL ломает часть HTTP-стеков/логов.
|
||
EKB_GEOPORTAL_BASE = "https://xn--80afgznagjs.xn--80acgfbsl1azdqr.xn--p1ai"
|
||
EKB_WFS_URL = f"{EKB_GEOPORTAL_BASE}/api/gis/ows/3/portal/wfs"
|
||
|
||
# Reverse-eng JSON-API карточки территории (рич ПЗЗ-регламент). Менее стабилен чем WFS (OGC) —
|
||
# поэтому supplement к WFS, а не primary. Подтверждено live 2026-06-06 (vault
|
||
# research/EKB_Geoportal_PZZ_Content_API_Jun06): searchByGeom (точка → ключи объектов по слоям),
|
||
# urbanCard (ключи → полный градрегламент: 3 списка ВРИ 540н + предельные параметры).
|
||
EKB_SEARCH_BY_GEOM_URL = f"{EKB_GEOPORTAL_BASE}/api/gis/spatialObject/searchByGeom"
|
||
EKB_URBAN_CARD_URL = f"{EKB_GEOPORTAL_BASE}/api/urbanCard/portal"
|
||
# layerKeys searchByGeom (внутренние числовые id портала, НЕ WFS typeNames):
|
||
# 17041 терзона ПЗЗ · 10019 участок ЕГРН · 16619 ЗОУИТ · 10079 кадастр.деление. Ответ:
|
||
# {layerKey: [{typeAlias, key, ...}]} — typeAlias UrbanTerZone/GknParcel/SpecialZone.
|
||
_SEARCH_LAYER_KEYS: tuple[int, ...] = (10079, 17041, 10019, 16619)
|
||
|
||
# Геометрическая колонка GeoServer-слоёв портала (таргет CQL-фильтра).
|
||
GEOM_COLUMN = "geoloc"
|
||
|
||
# Читаемый ключ → GeoServer typeName. Проверены live WFS GetFeature (vault recon 2026-06-06).
|
||
# Полный каталог — 1149 typeNames; тут только релевантные ИРД/supply-слои.
|
||
WFS_LAYERS: dict[str, str] = {
|
||
# Зонирование / регламент (ИРД п.2 ПЗЗ, capacity)
|
||
"territorial_zone": "portal:portal_geo_urban_ter_zone_exist", # ПЗЗ терзоны (urban_index)
|
||
"subzone": "portal:portal_geo_subzone_exist", # ПЗЗ подзоны
|
||
"functional_zone": "portal:portal_geo_urban_10_functional_zone", # функц.зоны ГП (#1058)
|
||
"genplan": "portal:portal_geo_dmd_genplan", # генплан (документ-слой)
|
||
# Future supply / что построят
|
||
"krt": "portal:portal_geo_oks_krt", # КРТ объекты/границы (ИРД #1060)
|
||
"ppt": "portal:portal_geo_dmd_prj_plan", # проекты планировки территории
|
||
"pmt": "portal:portal_geo_dmd_prj_mej", # проекты межевания территории
|
||
"investment_zone": "portal:portal_geo_urban_10_investment_zone_polygon", # инвест-зоны
|
||
"draft_parcel": "portal:portal_geo_art_zu_draft", # проектируемые/черновые ЗУ
|
||
# Ограничения
|
||
"zouit": "portal:portal_geo_gknspecial_zone", # ЗОУИТ
|
||
}
|
||
|
||
DEFAULT_TIMEOUT = 20
|
||
|
||
|
||
# ── HTTP helper (module-level — мокается в тестах) ───────────────────────────
|
||
|
||
|
||
def _http_get_json(url: str, params: dict[str, str], *, timeout: int = DEFAULT_TIMEOUT) -> Any:
|
||
"""GET WFS-эндпоинта → JSON. Вынесен на module-level для monkeypatch в тестах.
|
||
|
||
Геопортал ЕКБ — нормальный TLS, без auth: обычный httpx без спец-обвязки (в отличие
|
||
от НСПД с нац-CA / WAF). Raises httpx.HTTPStatusError на 4xx/5xx (caller решает).
|
||
"""
|
||
resp = httpx.get(url, params=params, timeout=timeout)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
def _http_post_json(url: str, body: dict[str, Any], *, timeout: int = DEFAULT_TIMEOUT) -> Any:
|
||
"""POST JSON → JSON (для searchByGeom / urbanCard). Module-level для monkeypatch в тестах."""
|
||
resp = httpx.post(url, json=body, timeout=timeout)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
# ── Typed response ───────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class EKBFeature:
|
||
"""Парсенный WFS GeoJSON Feature геопортала ЕКБ.
|
||
|
||
`geometry` — raw GeoJSON dict в EPSG:4326 (lon/lat). `properties` — все атрибуты слоя
|
||
(для терзоны: urban_index/full_name/urban_kind_obj; для КРТ: num_oks_krt/status/type/area;
|
||
для ППТ: doc_full_name/doc_status/dmd_actual_year).
|
||
"""
|
||
|
||
feature_id: str | None
|
||
geometry: dict[str, Any] | None
|
||
properties: dict[str, Any]
|
||
crs: str = "EPSG:4326"
|
||
|
||
@classmethod
|
||
def from_raw(cls, raw: dict[str, Any]) -> EKBFeature:
|
||
return cls(
|
||
feature_id=str(raw.get("id")) if raw.get("id") is not None else None,
|
||
geometry=raw.get("geometry"),
|
||
properties=raw.get("properties") or {},
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ZoneRegulation:
|
||
"""Градрегламент ПЗЗ-зоны из urbanCard (#1067 C8) — ИРД-content для #1059.
|
||
|
||
`zone_index` — индекс зоны ('Ц-1'); `zone_full_name` — полное имя
|
||
('Ц-1 Общественно-деловая зона городского центра'). ВРИ — строки «код 540н + текст»
|
||
(напр. '2.6 Среднеэтажная жилая застройка'). `limit_params` — предельные параметры
|
||
свободным текстом (min площадь ЗУ / max % застройки / этажность / отступы — нуждается в
|
||
regex/LLM-экстракте в числовые поля, #1067 далее).
|
||
"""
|
||
|
||
zone_index: str | None
|
||
zone_full_name: str | None
|
||
main_vri: list[str]
|
||
conditional_vri: list[str]
|
||
auxiliary_vri: list[str]
|
||
limit_params: list[str]
|
||
|
||
|
||
def _vri_names(items: Any) -> list[str]:
|
||
"""Извлечь непустые .name из списка *TypesPermittedUse."""
|
||
out: list[str] = []
|
||
for it in items or []:
|
||
name = (it or {}).get("name")
|
||
if name:
|
||
out.append(str(name).strip())
|
||
return out
|
||
|
||
|
||
def parse_zone_regulation(card: dict[str, Any]) -> ZoneRegulation | None:
|
||
"""Парс ответа urbanCard → ZoneRegulation (берёт первый pzzList).
|
||
|
||
None если в карточке нет pzzList (участок без ПЗЗ-регламента). Shape подтверждён live
|
||
2026-06-06 (vault research/EKB_Geoportal_PZZ_Content_API_Jun06).
|
||
"""
|
||
pzz_list = (card or {}).get("pzzList") or []
|
||
if not pzz_list:
|
||
return None
|
||
pzz = pzz_list[0] or {}
|
||
full = pzz.get("urbanTerZone")
|
||
full_name = str(full).strip() if full else None
|
||
index = full_name.split()[0] if full_name else None
|
||
limit_params: list[str] = []
|
||
for limit in pzz.get("limitTypesPermittedUse") or []:
|
||
for par in (limit or {}).get("typePermittedUsePars") or []:
|
||
desc = (par or {}).get("otherDescription")
|
||
if desc and str(desc).strip():
|
||
limit_params.append(str(desc).strip())
|
||
return ZoneRegulation(
|
||
zone_index=index,
|
||
zone_full_name=full_name,
|
||
main_vri=_vri_names(pzz.get("mainTypesPermittedUse")),
|
||
conditional_vri=_vri_names(pzz.get("conditionTypesPermittedUse")),
|
||
auxiliary_vri=_vri_names(pzz.get("additionTypesPermittedUse")),
|
||
limit_params=limit_params,
|
||
)
|
||
|
||
|
||
# ── Client ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class EKBGeoportalClient:
|
||
"""WFS-клиент геопортала ЕКБ. Stateless; один instance на процесс.
|
||
|
||
Методы возвращают list[EKBFeature]; пусто если по фильтру ничего не найдено.
|
||
"""
|
||
|
||
def __init__(self, timeout: int = DEFAULT_TIMEOUT) -> None:
|
||
self.timeout = timeout
|
||
|
||
def _resolve_type_name(self, layer: str) -> str:
|
||
"""Читаемый ключ WFS_LAYERS → typeName; либо сырой typeName (`portal:...`) as-is."""
|
||
return WFS_LAYERS.get(layer, layer)
|
||
|
||
def _get_feature(self, type_name: str, cql_filter: str) -> list[EKBFeature]:
|
||
"""WFS 2.0.0 GetFeature с CQL_FILTER → typed features (EPSG:4326)."""
|
||
params = {
|
||
"service": "WFS",
|
||
"version": "2.0.0",
|
||
"request": "GetFeature",
|
||
"typeNames": type_name,
|
||
"outputFormat": "application/json",
|
||
"srsName": "EPSG:4326",
|
||
"CQL_FILTER": cql_filter,
|
||
}
|
||
data = _http_get_json(EKB_WFS_URL, params, timeout=self.timeout)
|
||
feats = (data or {}).get("features") or []
|
||
return [EKBFeature.from_raw(f) for f in feats]
|
||
|
||
def features_at_point(self, layer: str, lon: float, lat: float) -> list[EKBFeature]:
|
||
"""Фичи слоя, покрывающие точку (lon, lat) — CQL INTERSECTS.
|
||
|
||
Порядок координат в CQL POINT — X=lon, Y=lat (EPSG:4326).
|
||
|
||
Args:
|
||
layer: ключ WFS_LAYERS ('territorial_zone', 'krt', ...) или сырой typeName.
|
||
"""
|
||
type_name = self._resolve_type_name(layer)
|
||
cql = f"INTERSECTS({GEOM_COLUMN}, POINT({lon} {lat}))"
|
||
return self._get_feature(type_name, cql)
|
||
|
||
def features_in_bbox(
|
||
self, layer: str, bbox_4326: tuple[float, float, float, float]
|
||
) -> list[EKBFeature]:
|
||
"""Фичи слоя в bbox (minlon, minlat, maxlon, maxlat) EPSG:4326 — CQL BBOX."""
|
||
minx, miny, maxx, maxy = bbox_4326
|
||
type_name = self._resolve_type_name(layer)
|
||
cql = f"BBOX({GEOM_COLUMN}, {minx}, {miny}, {maxx}, {maxy}, 'EPSG:4326')"
|
||
return self._get_feature(type_name, cql)
|
||
|
||
def zone_index_at(self, lon: float, lat: float) -> str | None:
|
||
"""Удобный shortcut: индекс ПЗЗ-терзоны ('Ц-1', 'Ж-2') в точке, либо None.
|
||
|
||
Закрывает crosswalk «координата → индекс зоны ЕКБ» для резолва ИРД-content
|
||
(#1059): НСПД даёт геометрию зоны без индекса, геопортал — индекс по точке.
|
||
"""
|
||
feats = self.features_at_point("territorial_zone", lon, lat)
|
||
if not feats:
|
||
return None
|
||
idx = feats[0].properties.get("urban_index")
|
||
return str(idx) if idx is not None else None
|
||
|
||
# ── urbanCard (рич ПЗЗ-регламент, supplement к WFS) ─────────────────────
|
||
|
||
def _search_keys(self, lon: float, lat: float) -> dict[str, int]:
|
||
"""searchByGeom: точка → {typeAlias: key} (UrbanTerZone / GknParcel / SpecialZone)."""
|
||
body = {
|
||
"project": "portal",
|
||
"geoJsonGeometries": [json.dumps({"type": "Point", "coordinates": [lon, lat]})],
|
||
"layerKeys": list(_SEARCH_LAYER_KEYS),
|
||
"countByLayers": [],
|
||
}
|
||
data = _http_post_json(EKB_SEARCH_BY_GEOM_URL, body, timeout=self.timeout)
|
||
keys: dict[str, int] = {}
|
||
if isinstance(data, dict):
|
||
for objs in data.values():
|
||
for o in objs or []:
|
||
alias = (o or {}).get("typeAlias")
|
||
key = (o or {}).get("key")
|
||
if alias and key is not None:
|
||
keys[str(alias)] = int(key)
|
||
return keys
|
||
|
||
def _urban_card(self, terzone_key: int | None, parcel_key: int | None) -> dict[str, Any]:
|
||
"""urbanCard: ключи терзоны/участка → полная карточка территории (с pzzList)."""
|
||
body = {
|
||
"construction": [],
|
||
"adrCapitalBuilding": [],
|
||
"pzz": [terzone_key] if terzone_key is not None else [],
|
||
"parcel": [parcel_key] if parcel_key is not None else [],
|
||
"urbanOtherAreaPZZ": [],
|
||
"urbanProtAreaPZZ": [],
|
||
"urbanSanGapPZZ": [],
|
||
}
|
||
return _http_post_json(EKB_URBAN_CARD_URL, body, timeout=self.timeout)
|
||
|
||
def zone_regulation_at(self, lon: float, lat: float) -> ZoneRegulation | None:
|
||
"""Полный градрегламент ПЗЗ-зоны в точке (#1059 content): индекс + 3 списка ВРИ +
|
||
предельные параметры. None если в точке нет терзоны/регламента.
|
||
|
||
Pipeline: searchByGeom (точка → UrbanTerZone+GknParcel ключи) → urbanCard → parse.
|
||
Кэшировать вызывающему по zone_index (регламент идентичен для всех участков зоны).
|
||
"""
|
||
keys = self._search_keys(lon, lat)
|
||
terzone_key = keys.get("UrbanTerZone")
|
||
if terzone_key is None:
|
||
return None
|
||
card = self._urban_card(terzone_key, keys.get("GknParcel"))
|
||
return parse_zone_regulation(card)
|
||
|
||
|
||
__all__ = [
|
||
"EKB_GEOPORTAL_BASE",
|
||
"EKB_WFS_URL",
|
||
"WFS_LAYERS",
|
||
"EKBFeature",
|
||
"EKBGeoportalClient",
|
||
"ZoneRegulation",
|
||
"parse_zone_regulation",
|
||
]
|