Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
199 lines
8 KiB
Python
199 lines
8 KiB
Python
"""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)
|