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
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:
parent
82e0b8b0a3
commit
0a018d201e
3 changed files with 86 additions and 5 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue