gendesign/backend/tests/services/generative/test_geometry.py
bot-backend 0a018d201e
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
fix(generative): отклонять спроецированные координаты до pyproj CRSError (#2445-B1)
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
2026-07-06 00:04:10 +05:00

199 lines
8 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.

"""Stage 1a tests — parcel parsing, setback buffer, placement grid.
Deterministic geometry: a known WGS84 rectangle near ЕКБ is parsed into metres, the
setback shrinks it, and the grid covers the buildable area. No network / no DB.
"""
from __future__ import annotations
import math
import pytest
from shapely.geometry import Polygon
from app.schemas.concept import ConceptInput
from app.services.generative import geometry
from app.services.generative.geometry import ParcelGeometryError, build_placement_grid
# ~450 m x 310 m rectangle near Екатеринбург (WGS84). Area ~ 0.86 ha.
_PARCEL_COORDS = [
[60.60, 56.830],
[60.6045, 56.830],
[60.6045, 56.8328],
[60.60, 56.8328],
[60.60, 56.830],
]
def _payload(**overrides: object) -> ConceptInput:
base: dict[str, object] = {
"parcel_geojson": {"type": "Polygon", "coordinates": [_PARCEL_COORDS]},
"housing_class": "comfort",
"target_floors": 9,
"development_type": "mid_rise",
}
base.update(overrides)
return ConceptInput(**base) # type: ignore[arg-type]
def test_parse_parcel_reprojects_to_metres() -> None:
parcel = geometry.parse_parcel(_payload())
# Площадь в кв.м должна быть на масштабе квартала (десятки тысяч кв.м), не градусов.
assert 50_000 < parcel.site_area_sqm < 150_000
# Buildable меньше участка ровно за счёт отступа.
assert parcel.buildable_area_sqm < parcel.site_area_sqm
assert parcel.setback_m == geometry.DEFAULT_SETBACK_M
def test_setback_shrinks_area_by_expected_band() -> None:
setback = 6.0
parcel = geometry.parse_parcel(_payload(), setback_m=setback)
# Грубая нижняя граница убыли: периметр * setback (внутренний буфер).
perimeter = parcel.polygon_m.length
expected_loss = perimeter * setback
actual_loss = parcel.site_area_sqm - parcel.buildable_area_sqm
# Внутренний буфер срезает примерно полосу шириной setback по периметру (±50%).
assert 0.5 * expected_loss < actual_loss < 1.5 * expected_loss
def test_grid_cells_lie_inside_buildable() -> None:
parcel = geometry.parse_parcel(_payload(), grid_step_m=6.0)
assert len(parcel.grid) > 0
for cell in parcel.grid:
assert parcel.buildable_m.covers(cell.as_polygon().centroid)
def test_parse_is_deterministic() -> None:
a = geometry.parse_parcel(_payload())
b = geometry.parse_parcel(_payload())
assert a.site_area_sqm == b.site_area_sqm
assert a.buildable_area_sqm == b.buildable_area_sqm
assert len(a.grid) == len(b.grid)
assert [(c.cx, c.cy) for c in a.grid] == [(c.cx, c.cy) for c in b.grid]
def test_feature_geojson_is_accepted() -> None:
# GeoJSON Feature (а не голая geometry) тоже парсится.
payload = _payload(
parcel_geojson={
"type": "Feature",
"properties": {},
"geometry": {"type": "Polygon", "coordinates": [_PARCEL_COORDS]},
}
)
parcel = geometry.parse_parcel(payload)
assert parcel.site_area_sqm > 0
def test_tiny_parcel_rejected_after_setback() -> None:
# ~16 m x 16 m: отступ 6 м с каждой стороны схлопывает пятно застройки.
tiny = [
[60.60, 56.830],
[60.60015, 56.830],
[60.60015, 56.83015],
[60.60, 56.83015],
[60.60, 56.830],
]
payload = _payload(parcel_geojson={"type": "Polygon", "coordinates": [tiny]})
with pytest.raises(ParcelGeometryError):
geometry.parse_parcel(payload)
def test_non_polygon_rejected() -> None:
payload = _payload(parcel_geojson={"type": "Point", "coordinates": [60.60, 56.83]})
with pytest.raises(ParcelGeometryError):
geometry.parse_parcel(payload)
def test_linestring_rejected() -> None:
# LineString не имеет площади — прежняя ошибка сохраняется.
payload = _payload(
parcel_geojson={
"type": "LineString",
"coordinates": [[60.60, 56.830], [60.6045, 56.8328]],
}
)
with pytest.raises(ParcelGeometryError):
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],
[60.6112, 56.840],
[60.6112, 56.8404],
[60.610, 56.8404],
[60.610, 56.840],
]
def test_multipolygon_picks_largest_contour() -> None:
# Многоконтурный участок: основной прямоугольник + мелкое вкрапление поодаль.
# Берётся крупнейший контур → площадь == площади одиночного основного полигона.
multi = _payload(
parcel_geojson={
"type": "MultiPolygon",
"coordinates": [[_PARCEL_COORDS], [_SMALL_CONTOUR]],
}
)
single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]})
parcel_multi = geometry.parse_parcel(multi)
parcel_single = geometry.parse_parcel(single)
# Результат многоконтурного == основному контуру (крупнейший выбран).
assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-6)
# Крупнейший контур на масштабе квартала, мелкое вкрапление отброшено.
assert parcel_multi.site_area_sqm > 50_000
def test_multipolygon_single_contour_equals_polygon() -> None:
# MultiPolygon с одним контуром эквивалентен голому Polygon.
multi = _payload(
parcel_geojson={
"type": "MultiPolygon",
"coordinates": [[_PARCEL_COORDS]],
}
)
single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]})
parcel_multi = geometry.parse_parcel(multi)
parcel_single = geometry.parse_parcel(single)
assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-9)
assert math.isclose(
parcel_multi.buildable_area_sqm,
parcel_single.buildable_area_sqm,
rel_tol=1e-9,
)
def test_empty_multipolygon_rejected() -> None:
payload = _payload(parcel_geojson={"type": "MultiPolygon", "coordinates": []})
with pytest.raises(ParcelGeometryError):
geometry.parse_parcel(payload)
def test_build_placement_grid_anchors_are_step_aligned() -> None:
# Простой метрический квадрат 30x30 м, шаг 10 -> 3x3 = 9 ячеек.
square = Polygon([(0, 0), (30, 0), (30, 30), (0, 30)])
cells = build_placement_grid(square, 10.0)
assert len(cells) == 9
# Центры на полушаге от кратных шагу.
for cell in cells:
assert math.isclose((cell.cx - 5.0) % 10.0, 0.0, abs_tol=1e-6)
assert math.isclose((cell.cy - 5.0) % 10.0, 0.0, abs_tol=1e-6)