From 0a018d201ebd3fdaca2588c02e3181ee51a36ac8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 6 Jul 2026 00:04:10 +0500 Subject: [PATCH] =?UTF-8?q?fix(generative):=20=D0=BE=D1=82=D0=BA=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=D1=8F=D1=82=D1=8C=20=D1=81=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D1=86=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BE=D1=80=D0=B4=D0=B8=D0=BD=D0=B0=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B4=D0=BE=20pyproj=20CRSError=20(#2445-B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parcel_geojson со спроецированными координатами (МСК-66/UTM метры) проходил синтаксическую валидацию Polygon, затем крашил _metric_transformers на CRS.from_proj4 с lat_0 вне ±90° → необработанный CRSError → opaque 500. Добавлен WGS84 bbox-check (_assert_wgs84_bounds) в parse_parcel ДО проекции (root cause, понятное сообщение) + защитный CRSError → ParcelGeometryError wrap в _metric_transformers. Существующий маппинг ParcelGeometryError → 422 в concepts.py срабатывает как задумано. Refs #2445 --- backend/app/services/generative/geometry.py | 46 +++++++++++++++++-- .../services/generative/test_api_concepts.py | 27 +++++++++++ .../services/generative/test_geometry.py | 18 ++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/backend/app/services/generative/geometry.py b/backend/app/services/generative/geometry.py index 8a9df8f8..930ce619 100644 --- a/backend/app/services/generative/geometry.py +++ b/backend/app/services/generative/geometry.py @@ -30,6 +30,7 @@ from dataclasses import dataclass from typing import Any from pyproj import CRS, Transformer +from pyproj.exceptions import CRSError from shapely.geometry import MultiPolygon, Polygon, mapping, shape from shapely.geometry.base import BaseGeometry from shapely.ops import transform as shapely_transform @@ -53,6 +54,12 @@ MIN_BUILDABLE_AREA_SQM: float = 50.0 # удержать время в бюджете (<=10 c/вариант). MVP-упрощение. MAX_GRID_CELLS: int = 20_000 +# Валидные диапазоны WGS84 lon/lat (градусы). Используются, чтобы отловить участок, +# присланный в проекции (метры, напр. МСК-66/UTM), ДО того как _metric_transformers +# соберёт AEQD с lat_0/lon_0 далеко за пределами градусов -> pyproj.CRSError. +_LON_RANGE: tuple[float, float] = (-180.0, 180.0) +_LAT_RANGE: tuple[float, float] = (-90.0, 90.0) + # WGS84 (вход контракта). _WGS84 = CRS.from_epsg(4326) @@ -170,6 +177,27 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon: return polygon +def _assert_wgs84_bounds(polygon: Polygon) -> None: + """Проверить, что bbox полигона лежит в валидных диапазонах WGS84 lon/lat. + + Root-cause фикс: фронтовый баг / плохой геокодер / demo-payload иногда шлёт + ``parcel_geojson`` в проекции (метры МСК-66/UTM, напр. [500000, 6200000]) вместо + WGS84 lon/lat. Такой полигон синтаксически валиден (это просто Polygon), но + дальше он бы дошёл до :func:`_metric_transformers`, где centroid.y (~6_200_000) + подставится в ``+lat_0=`` -> pyproj.CRS.from_proj4 упадёт CRSError (opaque 500). + Ловим здесь, ДО построения проекции, с понятным сообщением (422). + """ + minx, miny, maxx, maxy = polygon.bounds + lon_ok = _LON_RANGE[0] <= minx and maxx <= _LON_RANGE[1] + lat_ok = _LAT_RANGE[0] <= miny and maxy <= _LAT_RANGE[1] + if not (lon_ok and lat_ok): + raise ParcelGeometryError( + "parcel_geojson coordinates out of WGS84 lon/lat range " + f"(lon∈{_LON_RANGE}, lat∈{_LAT_RANGE}, got bounds={polygon.bounds}) — " + "coordinates appear to be projected (e.g. МСК-66/UTM metres), not WGS84 lon/lat" + ) + + def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transformer]: """Построить пару трансформеров WGS84<->метрический AEQD вокруг центроида участка. @@ -177,10 +205,16 @@ def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transform координат и точен на масштабе квартала — не нужен выбор UTM-зоны. """ centroid = polygon_wgs84.centroid - metric_crs = CRS.from_proj4( - f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} " - "+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs" - ) + try: + metric_crs = CRS.from_proj4( + f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} " + "+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs" + ) + except CRSError as exc: + # Belt-and-suspenders: _assert_wgs84_bounds должен был отловить это раньше, + # но на случай иной координатной патологии не даём CRSError утечь наружу + # непойманным 500 — это невалидная геометрия участка, т.е. 422. + raise ParcelGeometryError(f"cannot build metric CRS for parcel centroid: {exc}") from exc to_metric = Transformer.from_crs(_WGS84, metric_crs, always_xy=True) to_wgs84 = Transformer.from_crs(metric_crs, _WGS84, always_xy=True) return to_metric, to_wgs84 @@ -253,9 +287,11 @@ def parse_parcel( """Stage 1a: ConceptInput -> :class:`Parcel` (метрика + buildable + grid). Raises: - ParcelGeometryError: полигон невалиден или пятно застройки вырождается. + ParcelGeometryError: полигон невалиден, координаты не в WGS84 lon/lat, или + пятно застройки вырождается. """ polygon_wgs84 = _parse_polygon(payload.parcel_geojson) + _assert_wgs84_bounds(polygon_wgs84) to_metric, to_wgs84 = _metric_transformers(polygon_wgs84) def _fwd(xs: Any, ys: Any) -> tuple[Any, Any]: diff --git a/backend/tests/services/generative/test_api_concepts.py b/backend/tests/services/generative/test_api_concepts.py index 950ebe44..7bac0fcb 100644 --- a/backend/tests/services/generative/test_api_concepts.py +++ b/backend/tests/services/generative/test_api_concepts.py @@ -116,6 +116,33 @@ def test_concepts_degenerate_parcel_returns_422() -> None: assert response.status_code == 422 +def test_concepts_projected_coordinates_returns_422_not_500() -> None: + # Регресс эпик #2445 B1: parcel_geojson в проекции (МСК-66/UTM-like метры) вместо + # WGS84 lon/lat раньше падал неперехваченным pyproj.CRSError -> opaque 500. Должен + # быть чистый 422 (ParcelGeometryError), как и для прочих невалидных геометрий. + projected = { + "type": "Polygon", + "coordinates": [ + [ + [500_000.0, 6_200_000.0], + [500_450.0, 6_200_000.0], + [500_450.0, 6_200_310.0], + [500_000.0, 6_200_310.0], + [500_000.0, 6_200_000.0], + ] + ], + } + response = _post( + { + "parcel_geojson": projected, + "housing_class": "comfort", + "target_floors": 9, + "development_type": "mid_rise", + } + ) + assert response.status_code == 422, response.text + + def test_concepts_response_matches_contract_keys() -> None: response = _post( { diff --git a/backend/tests/services/generative/test_geometry.py b/backend/tests/services/generative/test_geometry.py index 877c8292..b2dc3af0 100644 --- a/backend/tests/services/generative/test_geometry.py +++ b/backend/tests/services/generative/test_geometry.py @@ -117,6 +117,24 @@ def test_linestring_rejected() -> None: geometry.parse_parcel(payload) +def test_projected_coordinates_rejected_with_clear_message() -> None: + # Регресс эпик #2445 B1: координаты в проекции (МСК-66/UTM-like метры, напр. + # [500000, 6200000]) вместо WGS84 lon/lat — синтаксически валидный Polygon, но + # centroid.y (~6_200_000) далеко за пределами ±90° -> раньше падало неперехваченным + # pyproj.exceptions.CRSError внутри _metric_transformers (opaque 500). Теперь + # ловится ДО построения проекции с понятным ParcelGeometryError (-> 422). + projected = [ + [500_000.0, 6_200_000.0], + [500_450.0, 6_200_000.0], + [500_450.0, 6_200_310.0], + [500_000.0, 6_200_310.0], + [500_000.0, 6_200_000.0], + ] + payload = _payload(parcel_geojson={"type": "Polygon", "coordinates": [projected]}) + with pytest.raises(ParcelGeometryError, match="WGS84"): + geometry.parse_parcel(payload) + + # Второй, заведомо меньший контур (~90 m x 45 m) поодаль от основного участка. _SMALL_CONTOUR = [ [60.610, 56.840], -- 2.45.3