283 lines
13 KiB
Python
283 lines
13 KiB
Python
"""Кэш ПЗЗ-градрегламента по индексу зоны + числовой экстракт предельных параметров (#1067 C8b).
|
||
|
||
Регламент ПЗЗ идентичен для всех участков одной терзоны, а зон на город ~100 → резолвим из
|
||
ЕКБ-геопортала urbanCard ОДИН раз на (city, zone_index) и кэшируем в ``zone_regulation_cache``
|
||
(м.133). Предельные параметры urbanCard отдаёт СВОБОДНЫМ ТЕКСТОМ (``otherDescription``) — тут же
|
||
их числовой экстракт (regex) в ключевые поля застройки для capacity-оценки.
|
||
|
||
Слои:
|
||
* :func:`parse_limit_params` — чистый экстрактор (regex, без IO; основной тест-таргет).
|
||
* :func:`upsert_zone_regulation` / :func:`get_cached_zone_regulation` — запись/чтение кэша.
|
||
* :func:`get_or_fetch_zone_regulation` — get-or-fetch (cache miss → urbanCard → upsert).
|
||
|
||
Текст предельных параметров (live-verified 2026-06-06, vault
|
||
research/EKB_Geoportal_PZZ_Content_API_Jun06) — пример (зона Ц-1):
|
||
'минимальная площадь земельного участка - 1500 кв. м;\\n
|
||
максимальный процент застройки ... – 100%;\\n
|
||
коэффициент строительного использования земельного участка – 4;\\n
|
||
процент застройки подземного пространства – 100%;\\n ...'
|
||
Каждый параметр часто повторяется per-ВРИ (среднеэтажная 1500 / многоэтажная 3000) и сплошь
|
||
встречается «не подлежат установлению» (числа нет → НЕ матчим). Поэтому params_numeric хранит
|
||
СПИСКИ значений, а headline-скаляры — most-permissive агрегат (capacity best-case).
|
||
|
||
Graceful: при отсутствии таблицы/ошибке БД чтение возвращает None, analyze не падает (wiring D9b).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.services.scrapers.ekb_geoportal_client import (
|
||
EKBGeoportalClient,
|
||
ZoneRegulation,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Числовой экстракт предельных параметров (regex) ──────────────────────────
|
||
# Числовой токен: тысячные разделены пробелом/nbsp ('50 000'), десятичная — запятая ('2,4').
|
||
# Сначала grouped-тысячи, иначе простое число. `.` по умолчанию НЕ переходит \n — параметры
|
||
# разделены \n, поэтому non-greedy `[^\n]*?` не перепрыгнет на число следующего параметра.
|
||
_NUM = r"(\d{1,3}(?:[ ]\d{3})+(?:[.,]\d+)?|\d+(?:[.,]\d+)?)"
|
||
# Тире в реальном тексте варьируется: дефис, en-dash, em-dash.
|
||
_DASH = r"[-–—]"
|
||
|
||
# мин. площадь ЗУ — 'минимальная площадь земельного участка - 1500 кв. м'
|
||
_RE_MIN_AREA = re.compile(
|
||
r"минимальн\w*\s+площад\w*\s+земельн\w*\s+участка\s*" + _DASH + r"\s*" + _NUM + r"\s*кв",
|
||
re.IGNORECASE,
|
||
)
|
||
# макс. % застройки — 'максимальный процент застройки ... – 100%'
|
||
_RE_BUILD_PCT = re.compile(
|
||
r"максимальн\w*\s+процент\s+застройки[^\n]*?" + _DASH + r"\s*" + _NUM + r"\s*%",
|
||
re.IGNORECASE,
|
||
)
|
||
# КСИ — 'коэффициент строительного использования земельного участка ... – 4' / '– 2,4'
|
||
_RE_FAR = re.compile(
|
||
r"коэффициент\s+строительного\s+использования\s+земельн\w*\s+участка[^\n]*?"
|
||
+ _DASH
|
||
+ r"\s*"
|
||
+ _NUM,
|
||
re.IGNORECASE,
|
||
)
|
||
# подземная застройка — 'процент застройки подземного пространства – 100%'
|
||
_RE_UNDERGROUND = re.compile(
|
||
r"процент\s+застройки\s+подземного\s+пространства\s*" + _DASH + r"\s*" + _NUM + r"\s*%",
|
||
re.IGNORECASE,
|
||
)
|
||
# этажность — 'предельная этажность ... – 5 этажей' / 'предельное количество этажей ... – 5'
|
||
_RE_FLOORS = re.compile(
|
||
r"(?:предельн\w*\s+этажност\w*|предельное\s+количество\s+этажей)[^\n]*?"
|
||
+ _DASH
|
||
+ r"\s*"
|
||
+ _NUM
|
||
+ r"\s*этаж",
|
||
re.IGNORECASE,
|
||
)
|
||
# высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)
|
||
_RE_HEIGHT = re.compile(
|
||
r"предельн\w*\s+высот\w*[^\n]*?" + _DASH + r"\s*" + _NUM + r"\s*м",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
# param-ключ → (compiled regex, агрегатор headline-скаляра по списку значений).
|
||
# min_parcel_area — НАИМЕНЬШИЙ минимум (самый разрешительный); прочие — max (capacity best-case).
|
||
_PARAM_SPECS: tuple[tuple[str, re.Pattern[str], Any], ...] = (
|
||
("max_far", _RE_FAR, max),
|
||
("max_building_pct", _RE_BUILD_PCT, max),
|
||
("underground_pct", _RE_UNDERGROUND, max),
|
||
("min_parcel_area_m2", _RE_MIN_AREA, min),
|
||
("max_floors", _RE_FLOORS, max),
|
||
("max_height_m", _RE_HEIGHT, max),
|
||
)
|
||
|
||
|
||
def _to_number(raw: str) -> float:
|
||
"""Нормализовать захваченный токен в float: убрать пробелы/nbsp тысяч, запятая → точка."""
|
||
s = raw.replace(" ", "").replace(" ", "").replace(",", ".")
|
||
return float(s)
|
||
|
||
|
||
def parse_limit_params(texts: list[str] | None) -> dict[str, list[float]]:
|
||
"""Числовой экстракт предельных параметров из сырых ``otherDescription``-строк.
|
||
|
||
Args:
|
||
texts: список свободно-текстовых блоков предельных параметров (limit_params).
|
||
|
||
Returns:
|
||
``{param_key: [значения...]}`` только для параметров, где найдено ≥1 число.
|
||
Значения в порядке появления, дубликаты сохраняются (per-ВРИ повторы осмысленны).
|
||
Параметры: ``max_far`` (КСИ), ``max_building_pct``, ``underground_pct``,
|
||
``min_parcel_area_m2``, ``max_floors``, ``max_height_m``.
|
||
"""
|
||
out: dict[str, list[float]] = {}
|
||
blob = "\n".join(t for t in (texts or []) if t)
|
||
if not blob:
|
||
return out
|
||
for key, rx, _agg in _PARAM_SPECS:
|
||
vals = [_to_number(m) for m in rx.findall(blob)]
|
||
if vals:
|
||
out[key] = vals
|
||
return out
|
||
|
||
|
||
def _headline_scalars(params_numeric: dict[str, list[float]]) -> dict[str, float | int | None]:
|
||
"""Агрегировать списки значений в headline-скаляры (most-permissive для capacity)."""
|
||
scalars: dict[str, float | int | None] = {}
|
||
for key, _rx, agg in _PARAM_SPECS:
|
||
vals = params_numeric.get(key) or []
|
||
if not vals:
|
||
scalars[key] = None
|
||
continue
|
||
v = agg(vals)
|
||
scalars[key] = int(v) if key == "max_floors" else float(v)
|
||
return scalars
|
||
|
||
|
||
# ── Кэш-таблица zone_regulation_cache (м.133) ────────────────────────────────
|
||
|
||
_UPSERT_SQL = text(
|
||
"""
|
||
INSERT INTO zone_regulation_cache (
|
||
city, zone_index, zone_full_name,
|
||
main_vri, conditional_vri, auxiliary_vri,
|
||
limit_params_raw, params_numeric,
|
||
max_far, max_building_pct, underground_pct,
|
||
min_parcel_area_m2, max_floors, max_height_m,
|
||
source, fetched_at
|
||
) VALUES (
|
||
:city, :zone_index, :zone_full_name,
|
||
CAST(:main_vri AS jsonb), CAST(:conditional_vri AS jsonb), CAST(:auxiliary_vri AS jsonb),
|
||
CAST(:limit_params_raw AS jsonb), CAST(:params_numeric AS jsonb),
|
||
:max_far, :max_building_pct, :underground_pct,
|
||
:min_parcel_area_m2, :max_floors, :max_height_m,
|
||
:source, now()
|
||
)
|
||
ON CONFLICT (city, zone_index) DO UPDATE SET
|
||
zone_full_name = EXCLUDED.zone_full_name,
|
||
main_vri = EXCLUDED.main_vri,
|
||
conditional_vri = EXCLUDED.conditional_vri,
|
||
auxiliary_vri = EXCLUDED.auxiliary_vri,
|
||
limit_params_raw = EXCLUDED.limit_params_raw,
|
||
params_numeric = EXCLUDED.params_numeric,
|
||
max_far = EXCLUDED.max_far,
|
||
max_building_pct = EXCLUDED.max_building_pct,
|
||
underground_pct = EXCLUDED.underground_pct,
|
||
min_parcel_area_m2 = EXCLUDED.min_parcel_area_m2,
|
||
max_floors = EXCLUDED.max_floors,
|
||
max_height_m = EXCLUDED.max_height_m,
|
||
source = EXCLUDED.source,
|
||
fetched_at = now()
|
||
"""
|
||
)
|
||
|
||
_SELECT_SQL = text(
|
||
"""
|
||
SELECT city, zone_index, zone_full_name,
|
||
main_vri, conditional_vri, auxiliary_vri,
|
||
limit_params_raw, params_numeric,
|
||
max_far, max_building_pct, underground_pct,
|
||
min_parcel_area_m2, max_floors, max_height_m,
|
||
source, fetched_at
|
||
FROM zone_regulation_cache
|
||
WHERE city = :city AND zone_index = :zone_index
|
||
"""
|
||
)
|
||
|
||
|
||
def upsert_zone_regulation(
|
||
db: Session, reg: ZoneRegulation, *, city: str = "ekb", source: str = "ekb_geoportal"
|
||
) -> dict[str, list[float]] | None:
|
||
"""Записать регламент зоны в кэш (UPSERT по (city, zone_index)) + числовой экстракт.
|
||
|
||
Returns:
|
||
params_numeric (числовой экстракт) при успехе; None если у регламента нет zone_index
|
||
или таблица недоступна (graceful).
|
||
"""
|
||
if not reg.zone_index:
|
||
return None
|
||
params_numeric = parse_limit_params(reg.limit_params)
|
||
scalars = _headline_scalars(params_numeric)
|
||
payload = {
|
||
"city": city,
|
||
"zone_index": reg.zone_index,
|
||
"zone_full_name": reg.zone_full_name,
|
||
"main_vri": json.dumps(reg.main_vri, ensure_ascii=False),
|
||
"conditional_vri": json.dumps(reg.conditional_vri, ensure_ascii=False),
|
||
"auxiliary_vri": json.dumps(reg.auxiliary_vri, ensure_ascii=False),
|
||
"limit_params_raw": json.dumps(reg.limit_params, ensure_ascii=False),
|
||
"params_numeric": json.dumps(params_numeric, ensure_ascii=False),
|
||
"source": source,
|
||
**scalars,
|
||
}
|
||
try:
|
||
with db.begin_nested():
|
||
db.execute(_UPSERT_SQL, payload)
|
||
except (OperationalError, ProgrammingError) as exc:
|
||
logger.warning("upsert_zone_regulation: zone_regulation_cache недоступна, skip: %s", exc)
|
||
return None
|
||
return params_numeric
|
||
|
||
|
||
def get_cached_zone_regulation(
|
||
db: Session, zone_index: str | None, *, city: str = "ekb"
|
||
) -> dict[str, Any] | None:
|
||
"""Прочитать кэшированный регламент зоны. None при отсутствии/недоступности таблицы."""
|
||
if not zone_index:
|
||
return None
|
||
try:
|
||
row = db.execute(_SELECT_SQL, {"city": city, "zone_index": zone_index}).mappings().first()
|
||
except (OperationalError, ProgrammingError) as exc:
|
||
logger.warning("get_cached_zone_regulation: cache недоступна, skip: %s", exc)
|
||
return None
|
||
return dict(row) if row else None
|
||
|
||
|
||
def get_or_fetch_zone_regulation(
|
||
db: Session,
|
||
lon: float,
|
||
lat: float,
|
||
*,
|
||
city: str = "ekb",
|
||
client: EKBGeoportalClient | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""Get-or-fetch регламент зоны в точке: кэш → (miss) urbanCard → upsert → кэш.
|
||
|
||
Сначала индекс зоны по точке (WFS, дёшево); если он уже в кэше — отдаём кэш без urbanCard.
|
||
Иначе полный резолв через urbanCard + запись. None если в точке нет терзоны/регламента.
|
||
"""
|
||
client = client or EKBGeoportalClient()
|
||
try:
|
||
zone_index = client.zone_index_at(lon, lat)
|
||
except Exception as exc:
|
||
logger.warning("get_or_fetch_zone_regulation: zone_index_at failed: %s", exc)
|
||
return None
|
||
if zone_index:
|
||
cached = get_cached_zone_regulation(db, zone_index, city=city)
|
||
if cached is not None:
|
||
return cached
|
||
try:
|
||
reg = client.zone_regulation_at(lon, lat)
|
||
except Exception as exc:
|
||
logger.warning("get_or_fetch_zone_regulation: zone_regulation_at failed: %s", exc)
|
||
return None
|
||
if reg is None or not reg.zone_index:
|
||
return None
|
||
upsert_zone_regulation(db, reg, city=city)
|
||
db.commit()
|
||
return get_cached_zone_regulation(db, reg.zone_index, city=city)
|
||
|
||
|
||
__all__ = [
|
||
"get_cached_zone_regulation",
|
||
"get_or_fetch_zone_regulation",
|
||
"parse_limit_params",
|
||
"upsert_zone_regulation",
|
||
]
|