Some checks failed
CI / frontend-tests (push) Has been skipped
CI / changes (push) Successful in 10s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m39s
CI / backend-tests (pull_request) Successful in 7m39s
Deploy / deploy (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / changes (push) Successful in 6s
Дополняет движок: exporters/{dxf,pdf}.py (ezdxf + WeasyPrint lazy) + 5 тест-модулей
(geometry/placement/teap_financial/exporters/api). Не вошли в предыдущий commit
(untracked-директории).
118 lines
4.4 KiB
Python
118 lines
4.4 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_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)
|