gendesign/backend/tests/services/generative/test_geometry.py
bot-backend 22526aa331
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
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 2m7s
CI / backend-tests (pull_request) Successful in 14m41s
fix(generative): parse MultiPolygon parcels by largest contour (#2295)
_parse_polygon бросал ParcelGeometryError на MultiPolygon → POST /concepts
отдавал 422 на многоконтурных участках (интерактивный §7 «Пересчитать»
не работал; таких участков в ЕГРН немало — подвид «Многоконтурный»).
Нормализуем в одном месте: крупнейший контур max(geoms, key=area) —
паттерн, который буфер-стадия ниже уже использует; сравнение площадей
в градусах на этом этапе монотонно (один масштаб-фактор на участок),
итоговые площади считаются после репроекции. Пустой MultiPolygon и
прочие не-Polygon типы падают как раньше. Polygon-путь байт-в-байт
(тест эквивалентности single-contour MultiPolygon == Polygon, 1e-9).

Refs #2295
2026-07-04 01:56:58 +05:00

181 lines
7 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)
# Второй, заведомо меньший контур (~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)