financial_estimate (эпик #1881) фаерит только при разрезолвленном НСПД-регламенте (max_far), а он резолвится ЛЕНИВО при анализе участка → zone_regulation_cache всего 33 зоны → у большинства участков financial_estimate=None («—» в кокпите). Backfill проактивно кэширует ВСЕ террзоны ЕКБ (~100-120) → ЛЮБОй участок в известной зоне сразу получает регламент и финмодель. - backfill_ekb_zone_regulations (zone_regulation.py): WFS-перечисление террзон ЕКБ (features_in_bbox 'territorial_zone') → dedup по urban_index (предпочёт фичу с геометрией) → representative_point (внутри полигона, не centroid) → zone_regulation_at → upsert. ИДЕМПОТЕНТНО (cached skip перед fetch), per-zone try/except (один сбой не рушит batch), rate_delay вежливость к геопорталу, limit для частичного прогона. - Celery task backfill_zone_regulations + регистрация в celery_app include + admin POST /api/v1/admin/scrape/zone-regulations/backfill (паттерн objective sync-our). - Ленивый refresh_zone_regulations / get_or_fetch / upsert НЕ тронуты (additive путь). - +9 тестов (dedup, idempotency cached-skip, per-zone error isolation, centroid-inside, limit). mypy/ruff clean. Прод-прогон backfill — отдельный шаг после deploy (как Objective sync), не на deploy. Code-review поймал незарегистрированный task (был бы NotRegistered) — исправлено. Refs #1881
420 lines
20 KiB
Python
420 lines
20 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
|
||
import time
|
||
from typing import Any
|
||
|
||
from shapely.geometry import shape
|
||
from sqlalchemy import text
|
||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.services.scrapers.ekb_geoportal_client import (
|
||
EKBFeature,
|
||
EKBGeoportalClient,
|
||
ZoneRegulation,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# bbox агломерации ЕКБ (minlon, minlat, maxlon, maxlat) EPSG:4326 — границы для WFS-перечисления
|
||
# терзон. Слой territorial_zone геопортала — ЕКБ-only, поэтому bbox это просто bounds-обёртка
|
||
# (а не фильтр города). Шире чем нужно — лучше захватить лишнее, дедуп по zone_index снимет повтор.
|
||
_EKB_CITY_BBOX: tuple[float, float, float, float] = (60.0, 56.6, 61.1, 57.1)
|
||
|
||
# ── Числовой экстракт предельных параметров (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*этаж\w*)?",
|
||
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)
|
||
|
||
|
||
# ── Backfill: проактивный прогрев ВСЕХ терзон ЕКБ (эпик #1881) ────────────────
|
||
|
||
|
||
def _representative_point(geometry: dict[str, Any] | None) -> tuple[float, float] | None:
|
||
"""Точка (lon, lat) гарантированно ВНУТРИ полигона зоны.
|
||
|
||
``representative_point`` (не ``centroid``) гарантирует попадание внутрь даже у вогнутых
|
||
полигонов → searchByGeom/zone_regulation_at стабильно находит терзону. None — битая
|
||
геометрия (фичу пропускаем).
|
||
"""
|
||
if not geometry:
|
||
return None
|
||
try:
|
||
pt = shape(geometry).representative_point()
|
||
return (float(pt.x), float(pt.y))
|
||
except Exception as exc: # битая геометрия фичи — пропускаем зону, не валим batch
|
||
logger.warning("backfill_ekb_zone_regulations: bad geometry, skip: %s", exc)
|
||
return None
|
||
|
||
|
||
def _dedup_zones_by_index(features: list[EKBFeature]) -> dict[str, EKBFeature]:
|
||
"""Сгруппировать фичи по ``urban_index``, выбрав одну фичу С ВАЛИДНОЙ геометрией на индекс.
|
||
|
||
Регламент идентичен для всех полигонов одной терзоны → резолвим индекс один раз.
|
||
Предпочитаем фичу с непустой геометрией (первая встретившаяся побеждает); фича без
|
||
геометрии берётся только если иной для индекса нет (всё равно отсеется в backfill).
|
||
"""
|
||
by_index: dict[str, EKBFeature] = {}
|
||
for feat in features:
|
||
idx = feat.properties.get("urban_index")
|
||
if not idx:
|
||
continue
|
||
key = str(idx)
|
||
existing = by_index.get(key)
|
||
if existing is None:
|
||
by_index[key] = feat
|
||
elif existing.geometry is None and feat.geometry is not None:
|
||
by_index[key] = feat # апгрейд до фичи с валидной геометрией
|
||
return by_index
|
||
|
||
|
||
def backfill_ekb_zone_regulations(
|
||
db: Session,
|
||
*,
|
||
client: EKBGeoportalClient | None = None,
|
||
bbox: tuple[float, float, float, float] = _EKB_CITY_BBOX,
|
||
limit: int | None = None,
|
||
rate_delay_s: float = 1.0,
|
||
city: str = "ekb",
|
||
) -> dict[str, int]:
|
||
"""Закэшировать регламент ВСЕХ терзон ЕКБ в ``zone_regulation_cache`` (эпик #1881).
|
||
|
||
Перечисляет терзоны через WFS, дедуплицирует по ``urban_index`` и для каждого УНИКАЛЬНОГО
|
||
индекса, которого ещё нет в кэше, резолвит регламент (центроид полигона → urbanCard) и
|
||
апсёртит. Идемпотентно: уже закэшированные зоны пропускаются (cached skip), повторный
|
||
прогон ничего не дофетчит. Per-zone try/except — сбой одной зоны не валит batch.
|
||
|
||
Args:
|
||
db: SQLAlchemy-сессия (sync).
|
||
client: WFS-клиент геопортала; None → дефолтный ``EKBGeoportalClient``.
|
||
bbox: (minlon, minlat, maxlon, maxlat) EPSG:4326 — bounds для WFS-перечисления.
|
||
limit: максимум УНИКАЛЬНЫХ незакэшированных зон к фетчу (None = все). Для теста/частичного
|
||
прогона.
|
||
rate_delay_s: пауза (сек) между live-fetch'ами зон — вежливость к геопорталу.
|
||
city: ключ кэша (по умолчанию 'ekb').
|
||
|
||
Returns:
|
||
{'enumerated_features', 'unique_zones', 'cached_before', 'fetched', 'upserted', 'failed'}.
|
||
"""
|
||
client = client or EKBGeoportalClient()
|
||
features = client.features_in_bbox("territorial_zone", bbox)
|
||
by_index = _dedup_zones_by_index(features)
|
||
logger.info(
|
||
"backfill_ekb_zone_regulations: фич=%d уникальных_зон=%d",
|
||
len(features),
|
||
len(by_index),
|
||
)
|
||
|
||
cached_before = 0
|
||
fetched = 0
|
||
upserted = 0
|
||
failed = 0
|
||
fetched_this_run = 0 # сколько зон уже сходили в live-fetch (для rate_delay между ними)
|
||
|
||
for zone_index, feat in by_index.items():
|
||
# Идемпотентность: уже в кэше → не дёргаем геопортал.
|
||
if get_cached_zone_regulation(db, zone_index, city=city) is not None:
|
||
cached_before += 1
|
||
continue
|
||
if limit is not None and fetched >= limit:
|
||
break
|
||
|
||
try:
|
||
point = _representative_point(feat.geometry)
|
||
if point is None:
|
||
continue
|
||
# Вежливость к геопорталу: пауза ПЕРЕД каждым fetch кроме первого.
|
||
if fetched_this_run > 0 and rate_delay_s > 0:
|
||
time.sleep(rate_delay_s)
|
||
fetched_this_run += 1
|
||
lon, lat = point
|
||
reg = client.zone_regulation_at(lon, lat)
|
||
fetched += 1
|
||
if reg is None or not reg.zone_index:
|
||
continue
|
||
if upsert_zone_regulation(db, reg, city=city) is not None:
|
||
db.commit()
|
||
upserted += 1
|
||
except Exception as exc:
|
||
# Per-zone изоляция: один сбойный фетч/апсёрт не должен ронять весь прогон.
|
||
logger.warning(
|
||
"backfill_ekb_zone_regulations: зона idx=%s failed: %s", zone_index, exc
|
||
)
|
||
failed += 1
|
||
continue
|
||
|
||
result = {
|
||
"enumerated_features": len(features),
|
||
"unique_zones": len(by_index),
|
||
"cached_before": cached_before,
|
||
"fetched": fetched,
|
||
"upserted": upserted,
|
||
"failed": failed,
|
||
}
|
||
logger.info("backfill_ekb_zone_regulations: %s", result)
|
||
return result
|
||
|
||
|
||
__all__ = [
|
||
"backfill_ekb_zone_regulations",
|
||
"get_cached_zone_regulation",
|
||
"get_or_fetch_zone_regulation",
|
||
"parse_limit_params",
|
||
"upsert_zone_regulation",
|
||
]
|