zone_regulation_refresh.py — Celery refresh_zone_regulations: WFS-перечисление терзон ЕКБ → дедуп по urban_index → shapely.representative_point → zone_regulation_at (urbanCard) → upsert в zone_regulation_cache (м.133). Проактивный прогрев кэша → analyze отдаёт ird.zone_regulation из кэша без live-urbanCard в hot-пути (снимает latency #1102). beat monthly (1-е 06:30). Без миграции. Зависит от #1103. 4 теста. Refs #1059. Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
92 lines
4.6 KiB
Python
92 lines
4.6 KiB
Python
"""Celery task: прогрев кэша ПЗЗ-регламента зон ЕКБ → ``zone_regulation_cache`` (#1059 / C8b).
|
|
|
|
Регламент ПЗЗ идентичен для всех участков одной терзоны (~100 зон на ЕКБ). C8b (#1099) умеет
|
|
резолвить регламент по точке (`zone_regulation_at` → urbanCard) и кэшировать по `zone_index`, но
|
|
кэш наполняется лениво — первый запрос участка в незакэшированной зоне платит за live-urbanCard.
|
|
|
|
Эта задача наполняет кэш ПРОАКТИВНО: WFS-перечисление всех терзон ЕКБ → по одной репрезентативной
|
|
точке на уникальный `urban_index` → `zone_regulation_at` → `upsert_zone_regulation`. После прогона
|
|
analyze отдаёт `ird.zone_regulation` из кэша (быстро), без live-геопортала в hot-пути.
|
|
|
|
Дедуп по `urban_index`: регламент одинаков для всех полигонов зоны → резолвим индекс один раз.
|
|
Репрезентативная точка (`shapely.representative_point`) гарантированно внутри полигона (в отличие
|
|
от centroid для вогнутых) → searchByGeom находит терзону.
|
|
|
|
Mirror conventions (planning_harvest.py / backend.md): SessionLocal()+try/finally; per-zone
|
|
try/except (сбой одной зоны не валит прогон); commit в конце. Расписание — ежемесячно (регламент
|
|
меняется только при правке ПЗЗ).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from shapely.geometry import shape
|
|
|
|
from app.core.db import SessionLocal
|
|
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
|
|
from app.services.site_finder.zone_regulation import upsert_zone_regulation
|
|
from app.workers.celery_app import celery_app
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# bbox агломерации ЕКБ (minlon, minlat, maxlon, maxlat) EPSG:4326 — как в planning_harvest.
|
|
EKB_AGGLOMERATION_BBOX: tuple[float, float, float, float] = (60.0, 56.6, 61.1, 57.1)
|
|
|
|
|
|
def _representative_point(geometry: dict[str, Any] | None) -> tuple[float, float] | None:
|
|
"""Точка (lon, lat) гарантированно внутри полигона зоны. None при невалидной геометрии."""
|
|
if not geometry:
|
|
return None
|
|
try:
|
|
pt = shape(geometry).representative_point()
|
|
return (float(pt.x), float(pt.y))
|
|
except Exception as exc: # битая геометрия фичи — пропускаем зону
|
|
logger.warning("zone_regulation_refresh: bad geometry, skip: %s", exc)
|
|
return None
|
|
|
|
|
|
@celery_app.task(name="tasks.zone_regulation_refresh.refresh_zone_regulations")
|
|
def refresh_zone_regulations(
|
|
bbox: tuple[float, float, float, float] | None = None,
|
|
) -> dict[str, int]:
|
|
"""Прогрев кэша регламента по терзонам ЕКБ.
|
|
|
|
Args:
|
|
bbox: (minlon, minlat, maxlon, maxlat) EPSG:4326. None → bbox агломерации ЕКБ.
|
|
|
|
Returns:
|
|
{'zones': N, 'cached': M} — уникальных индексов зон / успешно закэшировано регламентов.
|
|
"""
|
|
target_bbox = bbox if bbox is not None else EKB_AGGLOMERATION_BBOX
|
|
client = EKBGeoportalClient()
|
|
db = SessionLocal()
|
|
seen: set[str] = set()
|
|
n_cached = 0
|
|
try:
|
|
feats = client.features_in_bbox("territorial_zone", target_bbox)
|
|
logger.info("zone_regulation_refresh: терзон в bbox=%d", len(feats))
|
|
for feat in feats:
|
|
idx = feat.properties.get("urban_index")
|
|
if not idx or str(idx) in seen:
|
|
continue
|
|
seen.add(str(idx))
|
|
point = _representative_point(feat.geometry)
|
|
if point is None:
|
|
continue
|
|
lon, lat = point
|
|
try:
|
|
reg = client.zone_regulation_at(lon, lat)
|
|
except Exception as exc:
|
|
logger.warning("zone_regulation_refresh: resolve failed idx=%s: %s", idx, exc)
|
|
continue
|
|
if reg is None or not reg.zone_index:
|
|
continue
|
|
if upsert_zone_regulation(db, reg) is not None:
|
|
n_cached += 1
|
|
db.commit()
|
|
logger.info("zone_regulation_refresh: зон=%d закэшировано=%d", len(seen), n_cached)
|
|
return {"zones": len(seen), "cached": n_cached}
|
|
finally:
|
|
db.close()
|