feat(sf): прогрев zone_regulation_cache по терзонам ЕКБ (#1106)
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>
This commit is contained in:
parent
53e76738b4
commit
dee7d64ac8
4 changed files with 212 additions and 0 deletions
|
|
@ -350,4 +350,13 @@ def build_beat_schedule() -> dict:
|
|||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# Прогрев кэша ПЗЗ-регламента зон ЕКБ (#1059/C8b): WFS-перечисление терзон → urbanCard →
|
||||
# zone_regulation_cache. 1-е число месяца 06:30 МСК — после planning (06:00). Регламент
|
||||
# меняется только при правке ПЗЗ, ежемесячно достаточно. Наполняет кэш C8b проактивно.
|
||||
schedule["zone-regulation-refresh-monthly"] = {
|
||||
"task": "tasks.zone_regulation_refresh.refresh_zone_regulations",
|
||||
"schedule": _parse_cron("30 6 1 * *"),
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
return schedule
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ celery_app = Celery(
|
|||
"app.workers.tasks.ird_harvest",
|
||||
"app.workers.tasks.opportunity_harvest",
|
||||
"app.workers.tasks.planning_harvest",
|
||||
"app.workers.tasks.zone_regulation_refresh",
|
||||
],
|
||||
)
|
||||
celery_app.conf.timezone = "Europe/Moscow"
|
||||
|
|
|
|||
92
backend/app/workers/tasks/zone_regulation_refresh.py
Normal file
92
backend/app/workers/tasks/zone_regulation_refresh.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""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()
|
||||
110
backend/tests/workers/test_zone_regulation_refresh.py
Normal file
110
backend/tests/workers/test_zone_regulation_refresh.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Тесты refresh_zone_regulations (#1059/C8b) — прогрев кэша регламента зон ЕКБ.
|
||||
|
||||
Сеть/БД не дёргаются: мокаем EKBGeoportalClient + SessionLocal + upsert_zone_regulation.
|
||||
Проверяем дедуп по urban_index, представительную точку, skip пустых/битых, счётчики.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.scrapers.ekb_geoportal_client import ZoneRegulation
|
||||
from app.workers.tasks import zone_regulation_refresh as zrr
|
||||
|
||||
|
||||
class _Feat:
|
||||
def __init__(self, geometry: dict[str, Any] | None, properties: dict[str, Any]) -> None:
|
||||
self.geometry = geometry
|
||||
self.properties = properties
|
||||
|
||||
|
||||
def _poly() -> dict[str, Any]:
|
||||
return {"type": "Polygon", "coordinates": [[[60, 56], [60.1, 56], [60.1, 56.1], [60, 56]]]}
|
||||
|
||||
|
||||
def _reg(idx: str) -> ZoneRegulation:
|
||||
return ZoneRegulation(
|
||||
zone_index=idx,
|
||||
zone_full_name=f"{idx} зона",
|
||||
main_vri=[],
|
||||
conditional_vri=[],
|
||||
auxiliary_vri=[],
|
||||
limit_params=[],
|
||||
)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, feats: list[_Feat]) -> None:
|
||||
self._feats = feats
|
||||
self.resolved: list[tuple[float, float]] = []
|
||||
|
||||
def features_in_bbox(self, layer: str, bbox: Any) -> list[_Feat]:
|
||||
return self._feats
|
||||
|
||||
def zone_regulation_at(self, lon: float, lat: float) -> ZoneRegulation:
|
||||
self.resolved.append((lon, lat))
|
||||
return _reg("Ц-1")
|
||||
|
||||
|
||||
class _DB:
|
||||
def __init__(self) -> None:
|
||||
self.committed = False
|
||||
self.closed = False
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _patch(monkeypatch: Any, feats: list[_Feat]) -> tuple[_FakeClient, list[Any]]:
|
||||
client = _FakeClient(feats)
|
||||
upserts: list[Any] = []
|
||||
monkeypatch.setattr(zrr, "EKBGeoportalClient", lambda *a, **kw: client)
|
||||
monkeypatch.setattr(zrr, "SessionLocal", lambda: _DB())
|
||||
monkeypatch.setattr(
|
||||
zrr, "upsert_zone_regulation", lambda db, reg, **kw: upserts.append(reg) or {}
|
||||
)
|
||||
return client, upserts
|
||||
|
||||
|
||||
def test_dedup_by_urban_index(monkeypatch: Any) -> None:
|
||||
"""Две фичи одного индекса → резолвим/кэшируем зону один раз."""
|
||||
feats = [
|
||||
_Feat(_poly(), {"urban_index": "Ц-1"}),
|
||||
_Feat(_poly(), {"urban_index": "Ц-1"}),
|
||||
_Feat(_poly(), {"urban_index": "Ж-2"}),
|
||||
]
|
||||
client, upserts = _patch(monkeypatch, feats)
|
||||
|
||||
result = zrr.refresh_zone_regulations()
|
||||
|
||||
assert result == {"zones": 2, "cached": 2}
|
||||
assert len(client.resolved) == 2 # уникальных индексов
|
||||
assert len(upserts) == 2
|
||||
|
||||
|
||||
def test_skips_blank_index_and_bad_geometry(monkeypatch: Any) -> None:
|
||||
feats = [
|
||||
_Feat(_poly(), {"urban_index": ""}), # пустой индекс
|
||||
_Feat(None, {"urban_index": "Ц-3"}), # нет геометрии
|
||||
]
|
||||
client, upserts = _patch(monkeypatch, feats)
|
||||
|
||||
result = zrr.refresh_zone_regulations()
|
||||
|
||||
assert result == {"zones": 1, "cached": 0} # Ц-3 в seen, но geom=None → не резолвится
|
||||
assert client.resolved == []
|
||||
assert upserts == []
|
||||
|
||||
|
||||
def test_representative_point_inside_polygon() -> None:
|
||||
lon, lat = zrr._representative_point(_poly()) # type: ignore[misc]
|
||||
assert 60.0 <= lon <= 60.1
|
||||
assert 56.0 <= lat <= 56.1
|
||||
|
||||
|
||||
def test_representative_point_none_on_bad_geom() -> None:
|
||||
assert zrr._representative_point(None) is None
|
||||
assert zrr._representative_point({"type": "Polygon", "coordinates": "garbage"}) is None
|
||||
Loading…
Add table
Reference in a new issue