From 3beb75d68a153aab06b964e00296a4ca0d73d3d4 Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Fri, 15 May 2026 16:25:06 +0300 Subject: [PATCH] fix(cadastre): skip grid-walk for broken quarter geometries (#168) (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cadastre): skip grid-walk for broken quarter geometries (#168) Pilot v5 (50 quarters) разоблачил что cad_quarters_geom имеет 1735/2408 (72%) ЕКБ rows с broken micro-precision geom (width ~0.01м instead of 200-4000м). Causes: - grid-walk WMS receives bbox ~1mm × 1mm → 500 ServiceException на all 450 req - One quarter hung 2+ min (silent failure in async loop) Real fix: re-import cad_quarters_geom geometry from NSPD (category 36381). Tracked в follow-up issue. Quick fix (this PR): sanity-check quarter_bbox_3857 — return None если width ИЛИ height not in [100m, 10000m]. Bulk_harvest_quarter then skips grid-walk для broken quarters, только snapshot phase запускается (20 per cat). After this fix: - All 50 pilot quarters получают snapshot (parcels/buildings/etc up to 20 each) - Grid-walk runs only для 673/2408 healthy quarters → no 500 cascades, no hangs * test(cadastre): add quarter_bbox_3857 boundary tests (PR #179 bot blocker) - healthy bbox 750m -> returns tuple - width <100m (broken micro-precision) -> None - width >10000m (oversized) -> None - height <100m (symmetry) -> None - row missing (quarter not in cad_quarters_geom) -> None --------- Co-authored-by: lekss361 --- .../app/services/cadastre/grid_geometry.py | 18 ++++++- backend/tests/services/test_cadastre_bulk.py | 51 ++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/backend/app/services/cadastre/grid_geometry.py b/backend/app/services/cadastre/grid_geometry.py index 1c1d8728..40c4cb7c 100644 --- a/backend/app/services/cadastre/grid_geometry.py +++ b/backend/app/services/cadastre/grid_geometry.py @@ -46,12 +46,28 @@ def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, f if not row or row["xmin"] is None: logger.warning("quarter_bbox_3857: квартал %s не найден в cad_quarters_geom", quarter) return None - return ( + bbox = ( float(row["xmin"]), float(row["ymin"]), float(row["xmax"]), float(row["ymax"]), ) + # Sanity check: cad_quarters_geom имеет ~72% rows с broken micro-precision + # geometry (width <0.01m). Это вызывает useless 1px tiles в WMS и 500 errors + # от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для + # broken — snapshot phase даст 20 per cat, что норм для MVP. + width = bbox[2] - bbox[0] + height = bbox[3] - bbox[1] + if width < 100 or height < 100 or width > 10000 or height > 10000: + logger.warning( + "quarter_bbox_3857: квартал %s broken geom — width=%.2fm height=%.2fm " + "(expected 100-10000m). Grid-walk skipped, only snapshot phase.", + quarter, + width, + height, + ) + return None + return bbox def generate_grid_click_points( diff --git a/backend/tests/services/test_cadastre_bulk.py b/backend/tests/services/test_cadastre_bulk.py index 9cd3ddf7..f33513a7 100644 --- a/backend/tests/services/test_cadastre_bulk.py +++ b/backend/tests/services/test_cadastre_bulk.py @@ -18,7 +18,10 @@ from app.services.cadastre.bulk_harvest import ( _safe_numeric, upsert_features, ) -from app.services.cadastre.grid_geometry import generate_grid_click_points +from app.services.cadastre.grid_geometry import ( + generate_grid_click_points, + quarter_bbox_3857, +) # ── Фикстуры ───────────────────────────────────────────────────────────────── @@ -195,6 +198,52 @@ def test_generate_grid_click_points_custom_grid() -> None: assert cell_bbox[3] - cell_bbox[1] == pytest.approx(200.0) +# ── quarter_bbox_3857 sanity check ─────────────────────────────────────────── + + +def _mock_db_with_bbox( + xmin: float | None, ymin: float | None, xmax: float | None, ymax: float | None +) -> MagicMock: + """Mock SQLAlchemy session.execute().mappings().first() возвращает заданный bbox.""" + mock_db = MagicMock() + row = None if xmin is None else {"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax} + mock_db.execute.return_value.mappings.return_value.first.return_value = row + return mock_db + + +def test_quarter_bbox_3857_healthy_geom() -> None: + """Real ЕКБ-квартал ~750м — должен пройти sanity check.""" + db = _mock_db_with_bbox(6735845.0, 8329000.0, 6736595.0, 8329750.0) + bbox = quarter_bbox_3857(db, "66:41:0303161") + assert bbox is not None + assert bbox == (6735845.0, 8329000.0, 6736595.0, 8329750.0) + + +def test_quarter_bbox_3857_skips_broken_micro_geom() -> None: + """width < 100m — broken micro-precision geom (72% rows в cad_quarters_geom).""" + # width=60.7m — реальный broken quarter 66:41:0101005 после ST_Transform + db = _mock_db_with_bbox(60.0, 60.0, 120.7, 120.7) + assert quarter_bbox_3857(db, "66:41:0101005") is None + + +def test_quarter_bbox_3857_skips_oversized_geom() -> None: + """width > 10000m — broken/regional geom (не городской квартал).""" + db = _mock_db_with_bbox(0.0, 0.0, 15000.0, 15000.0) + assert quarter_bbox_3857(db, "66:41:9999999") is None + + +def test_quarter_bbox_3857_skips_zero_height() -> None: + """height < 100m — симметрия по другой оси.""" + db = _mock_db_with_bbox(0.0, 0.0, 500.0, 50.0) + assert quarter_bbox_3857(db, "66:41:0000001") is None + + +def test_quarter_bbox_3857_returns_none_when_quarter_missing() -> None: + """Квартал не найден в cad_quarters_geom → None (xmin IS NULL).""" + db = _mock_db_with_bbox(None, None, None, None) + assert quarter_bbox_3857(db, "66:99:0000000") is None + + # ── upsert_features routing ──────────────────────────────────────────────────