gendesign/backend/app/services/cadastre/grid_geometry.py
lekss361 5da66bbc93
feat(cadastre): bulk_harvest worker + grid-walker + admin API (#168 PR3/5) (#171)
* feat(cadastre): bulk_harvest_quarter Celery task + grid-walker + saga state (#168 PR3/5)

Add bulk cadastre harvest pipeline:
- services/cadastre/bulk_harvest.py: async harvest_quarter() orchestrator (4 phases)
  + 7 upsert helpers (parcels/buildings/constructions/oncs/enks/zouit/quarter_stats)
  using CAST(:x AS jsonb) pattern, begin_nested() SAVEPOINT per grid-walk upsert
- services/cadastre/grid_geometry.py: quarter_bbox_3857 (PostGIS ST_Extent)
  + generate_grid_click_points (15x15 = 225 sub-bbox grid)
- workers/tasks/scrape_cadastre.py: bulk_harvest_quarter_task (acks_late=True,
  dont_autoretry_for NspdBulkWafError), enqueue_cadastre_harvest, cleanup_zombies
- api/v1/admin_cadastre.py: 5 endpoints behind AdminTokenAuth — create/list/get/cancel/resume
- Tests: 13 service unit + 8 API tests

* fixup(cadastre): drop importorskip (PR2 merged) — imports now top-level (#168 PR3)

* fixup(cadastre): tile_size unified param + register slow marker (#168 PR3)

Blocker #1: rename tile_width/tile_height → tile_size in generate_grid_click_points.
Callers in bulk_harvest.py and test_cadastre_bulk.py used tile_size; def had
tile_width+tile_height → TypeError at runtime.

Blocker #2 (10 failures in test_admin_cadastre.py) was side-effect of #1:
TypeError at import chain (app.main → admin_cadastre → bulk_harvest)
broke FastAPI app load → dependency_overrides AttributeError. Now resolved.

Also register `slow` pytest marker in pyproject.toml to suppress
PytestUnknownMarkWarning.

* fixup(cadastre): use importlib.util.find_spec instead of import (#168 PR3)

Root cause Blocker #2: `import app.workers.tasks.scrape_cadastre` at module
top-level REBOUND `app` from FastAPI instance (line 14) to the Python package.
All subsequent `app.dependency_overrides[get_db] = ...` failed with
AttributeError because `app` was now the namespace module.

Fix: probe module availability with importlib.util.find_spec — does not
import or rebind names. FastAPI `app` instance stays intact.

All 28 cadastre tests pass locally.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 13:31:32 +03:00

98 lines
3.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Геометрические утилиты для grid-walk по кадастровому кварталу.
Используется в bulk_harvest.py для вычисления bbox квартала в EPSG:3857
и генерации сетки точек для WMS GetFeatureInfo.
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, float] | None:
"""Получить bbox квартала из cad_quarters_geom в EPSG:3857.
Args:
db: SQLAlchemy session.
quarter: кадастровый номер квартала, например '66:41:0303161'.
Returns:
(xmin, ymin, xmax, ymax) в метрах EPSG:3857, или None если квартал не найден.
"""
row = (
db.execute(
text("""
SELECT
ST_XMin(b) AS xmin,
ST_YMin(b) AS ymin,
ST_XMax(b) AS xmax,
ST_YMax(b) AS ymax
FROM (
SELECT ST_Extent(ST_Transform(geom, 3857)) AS b
FROM cad_quarters_geom
WHERE cad_number = :q
) sub
"""),
{"q": quarter},
)
.mappings()
.first()
)
if not row or row["xmin"] is None:
logger.warning("quarter_bbox_3857: квартал %s не найден в cad_quarters_geom", quarter)
return None
return (
float(row["xmin"]),
float(row["ymin"]),
float(row["xmax"]),
float(row["ymax"]),
)
def generate_grid_click_points(
bbox: tuple[float, float, float, float],
grid_size: int = 15,
tile_size: int = 512,
) -> list[tuple[tuple[float, float, float, float], tuple[int, int]]]:
"""Генерировать grid_size × grid_size ячеек для bbox квартала.
Каждая ячейка — свой sub-bbox + центральный пиксель (click_xy) для
WMS GetFeatureInfo. 15×15 = 225 запросов обеспечивают хорошее покрытие
при шаге ~50м для типичного квартала ЕКБ (~750м × ~750м).
Args:
bbox: (xmin, ymin, xmax, ymax) в EPSG:3857.
grid_size: количество ячеек по каждой оси (15 → 225 запросов).
tile_size: размер виртуального WMS тайла в пикселях (квадратный).
Returns:
Список (sub_bbox, click_xy) — sub_bbox в EPSG:3857, click_xy в пикселях.
"""
xmin, ymin, xmax, ymax = bbox
x_step = (xmax - xmin) / grid_size
y_step = (ymax - ymin) / grid_size
result: list[tuple[tuple[float, float, float, float], tuple[int, int]]] = []
for i in range(grid_size):
for j in range(grid_size):
cell_xmin = xmin + i * x_step
cell_ymin = ymin + j * y_step
cell_xmax = cell_xmin + x_step
cell_ymax = cell_ymin + y_step
# Клик в центр ячейки
click_x = tile_size // 2
click_y = tile_size // 2
result.append(
(
(cell_xmin, cell_ymin, cell_xmax, cell_ymax),
(click_x, click_y),
)
)
return result