gendesign/backend/tests/services/generative/test_geometry.py
bot-backend 27e21f37d3
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m58s
Deploy / build-worker (push) Successful in 2m58s
Deploy / deploy (push) Successful in 1m32s
fix(generative): MultiPolygon-участки — крупнейший контур в _parse_polygon (#2295) (#2315)
2026-07-03 21:13:00 +00: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)