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.
152 lines
6.6 KiB
Python
152 lines
6.6 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 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"])
|