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-директории).
116 lines
4.8 KiB
Python
116 lines
4.8 KiB
Python
"""Stage 1b tests — greedy placement, STRtree collisions, gaps, strategies.
|
||
|
||
Asserts the structural guarantees of the greedy sweep: footprints stay inside the
|
||
buildable area, respect the inter-section gap (no overlaps), the three strategies
|
||
differ, the coverage cap bounds density, and the result is deterministic.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from shapely.geometry import shape
|
||
|
||
from app.schemas.concept import ConceptInput
|
||
from app.services.generative import geometry, placement
|
||
|
||
_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_all_three_strategies_present() -> None:
|
||
parcel = geometry.parse_parcel(_payload())
|
||
variants = placement.place_all_strategies(parcel, _payload())
|
||
assert {v.strategy for v in variants} == {"max_area", "max_insolation", "balanced"}
|
||
|
||
|
||
def test_footprints_inside_buildable_and_non_overlapping() -> None:
|
||
parcel = geometry.parse_parcel(_payload())
|
||
spec = next(s for s in placement._STRATEGIES if s.name == "max_area")
|
||
footprints = placement._greedy_place(parcel, spec, coverage_cap=0.45)
|
||
assert len(footprints) > 0
|
||
for fp in footprints:
|
||
# Внутри пятна застройки (с допуском на численную погрешность буфера).
|
||
assert parcel.buildable_m.buffer(0.01).covers(fp)
|
||
# Никакие две секции не перекрываются (разрыв gap_m выдержан).
|
||
for i, a in enumerate(footprints):
|
||
for b in footprints[i + 1 :]:
|
||
assert not a.buffer(-0.01).intersects(b.buffer(-0.01))
|
||
|
||
|
||
def test_gap_between_sections_respected() -> None:
|
||
parcel = geometry.parse_parcel(_payload())
|
||
spec = next(s for s in placement._STRATEGIES if s.name == "max_insolation")
|
||
footprints = placement._greedy_place(parcel, spec, coverage_cap=0.45)
|
||
# Минимальное расстояние между любыми двумя секциями >= gap_m (с допуском).
|
||
for i, a in enumerate(footprints):
|
||
for b in footprints[i + 1 :]:
|
||
assert a.distance(b) >= spec.gap_m - 0.5
|
||
|
||
|
||
def test_max_area_denser_than_max_insolation() -> None:
|
||
payload = _payload()
|
||
parcel = geometry.parse_parcel(payload)
|
||
variants = {v.strategy: v for v in placement.place_all_strategies(parcel, payload)}
|
||
# Максимум площади даёт большее пятно/жилую, чем максимум инсоляции.
|
||
assert (
|
||
variants["max_area"].teap.built_area_sqm
|
||
> variants["max_insolation"].teap.built_area_sqm
|
||
)
|
||
assert (
|
||
variants["max_area"].teap.residential_area_sqm
|
||
> variants["max_insolation"].teap.residential_area_sqm
|
||
)
|
||
|
||
|
||
def test_coverage_cap_bounds_built_area() -> None:
|
||
# high_rise cap = 0.50; пятно не должно его превышать (+небольшой запас на 1 секцию).
|
||
payload = _payload(development_type="high_rise")
|
||
parcel = geometry.parse_parcel(payload)
|
||
variants = placement.place_all_strategies(parcel, payload)
|
||
cap = placement._COVERAGE_CAP_BY_TYPE["high_rise"]
|
||
for v in variants:
|
||
coverage = v.teap.built_area_sqm / parcel.buildable_area_sqm
|
||
# +0.05: цикл останавливается ПОСЛЕ секции, перешагнувшей порог.
|
||
assert coverage <= cap + 0.05
|
||
|
||
|
||
def test_buildings_geojson_features_are_valid_polygons() -> None:
|
||
payload = _payload()
|
||
parcel = geometry.parse_parcel(payload)
|
||
variant = placement.place_all_strategies(parcel, payload)[0]
|
||
fc = variant.buildings_geojson
|
||
assert fc["type"] == "FeatureCollection"
|
||
features = fc["features"]
|
||
assert isinstance(features, list) and len(features) > 0
|
||
for feat in features:
|
||
geom = shape(feat["geometry"])
|
||
assert geom.geom_type == "Polygon"
|
||
assert geom.is_valid
|
||
assert feat["properties"]["floors"] >= 1
|
||
|
||
|
||
def test_placement_deterministic() -> None:
|
||
payload = _payload()
|
||
p1 = geometry.parse_parcel(payload)
|
||
p2 = geometry.parse_parcel(payload)
|
||
v1 = placement.place_all_strategies(p1, payload)
|
||
v2 = placement.place_all_strategies(p2, payload)
|
||
for a, b in zip(v1, v2, strict=True):
|
||
assert a.teap.apartments_count == b.teap.apartments_count
|
||
assert a.teap.built_area_sqm == b.teap.built_area_sqm
|
||
assert len(a.buildings_geojson["features"]) == len(b.buildings_geojson["features"])
|