gendesign/backend/tests/services/generative/test_placement.py
Light1YT 83a063f0ee
Some checks failed
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m12s
CI / openapi-codegen-check (pull_request) Successful in 2m4s
CI / backend-tests (pull_request) Failing after 14m21s
feat(section7): editable section footprint + корпус plural/program hint (#1953)
PR-B (follow-up к unify-concept). Закрывает «вписать пятно» из round-3:
- BuildingProgramItem получает опциональные footprint_w_m/footprint_d_m
  (float|None, ge=4 le=120, default None → каталог; ADDITIVE, byte-for-byte
  backward-compat). place_program кладёт ручное пятно когда заданы ОБА габарита,
  иначе каталожное. api-types.ts регенерирован под новую схему.
- ConceptProgramForm: пятно секции теперь редактируемое (ширина × глубина);
  пусто → возврат к каталогу; Σ пятно и ориентир КСИТ считаются от эффективного
  габарита. building_program несёт footprint в Section7Concept.
- ConceptVariantsResult: грамматика «корпус» (1 корпус / 2–4 корпуса / 5+
  корпусов) в headline / подписи карты / partial-fit; program-вариант получает
  свой хинт вместо STRATEGY_HINTS[balanced].
- Тесты place_program: footprint-override и partial-footprint fallback.
2026-06-30 13:59:14 +05:00

152 lines
6.6 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 BuildingProgramItem, ConceptInput
from app.services.generative import catalog, 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_program_footprint_override_changes_section_area() -> None:
# Stage 3c: ручное пятно (footprint_w_m/_d_m) кладётся вместо каталожного.
parcel = geometry.parse_parcel(_payload())
section_type = next(iter(catalog.available_section_types()))
house = catalog.get_house_type(section_type)
override = BuildingProgramItem(
section_type=section_type,
floors=10,
count=2,
footprint_w_m=10.0,
footprint_d_m=10.0,
)
placed = placement.place_program(parcel, [override])
assert placed.placed_count >= 1
# Каждая размещённая секция несёт пятно override (10×10 = 100 м²), не каталог.
for sec in placed.sections:
assert abs(sec.footprint.area - 100.0) < 1.0
# Без override — пятно из каталога (контроль: байт-в-байт прежнее поведение).
base = BuildingProgramItem(section_type=section_type, floors=10, count=2)
placed_base = placement.place_program(parcel, [base])
catalog_area = house.footprint_w_m * house.footprint_d_m
for sec in placed_base.sections:
assert abs(sec.footprint.area - catalog_area) < 1.0
def test_program_partial_footprint_falls_back_to_catalog() -> None:
# Только ширина задана (без глубины) → трактуем как «не задано», берём каталог.
parcel = geometry.parse_parcel(_payload())
section_type = next(iter(catalog.available_section_types()))
house = catalog.get_house_type(section_type)
item = BuildingProgramItem(section_type=section_type, floors=10, count=1, footprint_w_m=10.0)
placed = placement.place_program(parcel, [item])
catalog_area = house.footprint_w_m * house.footprint_d_m
for sec in placed.sections:
assert abs(sec.footprint.area - catalog_area) < 1.0
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"])