fix(generative): отклонять спроецированные координаты до pyproj CRSError (#2445-B1)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m8s
CI / backend-tests (pull_request) Successful in 15m0s

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
This commit is contained in:
bot-backend 2026-07-06 00:04:10 +05:00
parent 82e0b8b0a3
commit 0a018d201e
3 changed files with 86 additions and 5 deletions

View file

@ -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]:

View file

@ -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(
{

View file

@ -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],