gendesign/backend/tests/services/generative/test_placement.py
Light1YT 8242e51b61
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
feat(generative): exporters (dxf/pdf) + generative test suite (#54 #55 #56)
Дополняет движок: exporters/{dxf,pdf}.py (ezdxf + WeasyPrint lazy) + 5 тест-модулей
(geometry/placement/teap_financial/exporters/api). Не вошли в предыдущий commit
(untracked-директории).
2026-06-13 21:33:20 +05:00

116 lines
4.8 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 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"])