Extend the concept generator so ConceptInput can carry an optional
building_program (list of typed houses from a catalog). When present,
placement lays out EXACTLY that program — for each item, place `count`
sections of the catalog footprint at the item's floors — instead of the
greedy max-FAR coverage-cap sweep. When absent, the existing greedy
behavior is unchanged (byte-for-byte backward-compatible).
- catalog.py: hardcoded HOUSE_TYPES (panel_econom, monolith_comfort,
tower_business, lowrise_comfort, townhouse) — sane-default catalog,
promote to DB later; get_house_type / available_section_types lookups.
- schema: additive BuildingProgramItem {section_type, floors, count} and
ConceptInput.building_program (default None -> greedy). ConceptVariant
gains optional placed_count / requested_count (partial-fit signal).
- placement: shared _Placer (collision/STRtree/setback machine extracted
from greedy sweep, reused — no duplication); place_program +
place_program_variant; branch in place_all_strategies on
building_program. Mixed-floor TEAP via exact per-floor-group aggregation
(GFA = sum(area_i * floors_i), no rounding drift).
- partial fit: when the parcel can't fit all sections, place as many as
fit and report placed_count < requested_count (no hard-422); zero-fit
still raises ParcelGeometryError (-> 422).
- API: validate program section_type keys against the catalog (unknown ->
422) before placement.
- tests: catalog integrity, greedy backward-compat, exact 2-item program +
TEAP reflection, over-packed partial placement, API program path.
- regenerate frontend api-types.ts (OpenAPI codegen gate stays green).
213 lines
9.8 KiB
Python
213 lines
9.8 KiB
Python
"""Stage 3a tests (#1965) — program-driven placement (типовые дома каталога).
|
||
|
||
Covers the three core guarantees of the ``building_program`` path:
|
||
|
||
(a) ``building_program=None`` reproduces today's greedy output unchanged (backward-compat).
|
||
(b) A 2-item program places EXACTLY the requested sections (when they fit) and the TEAP
|
||
reflects them (GFA = Σ(площадь_i × floors_i), apartments tied to placed footprints).
|
||
(c) An over-packed program reports partial placement (placed < requested) WITHOUT raising.
|
||
|
||
Plus structural checks: program footprints respect the collision/setback machinery, the
|
||
per-feature GeoJSON carries the section's own floors + catalog type, and an empty-fit
|
||
program is rejected (degenerate parcel) rather than returning a lying zero variant.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from shapely.geometry import shape
|
||
|
||
from app.schemas.concept import BuildingProgramItem, ConceptInput
|
||
from app.services.generative import catalog, geometry, placement
|
||
from app.services.generative.geometry import ParcelGeometryError
|
||
|
||
# Крупный квартальный участок (~7.9 га buildable) — вмещает десятки секций.
|
||
_BIG_PARCEL = [
|
||
[60.60, 56.830],
|
||
[60.6045, 56.830],
|
||
[60.6045, 56.8328],
|
||
[60.60, 56.8328],
|
||
[60.60, 56.830],
|
||
]
|
||
|
||
# Маленький участок (~3.8 тыс. кв.м buildable) — вмещает лишь ~2 крупные секции.
|
||
_SMALL_PARCEL = [
|
||
[60.600, 56.8300],
|
||
[60.6010, 56.8300],
|
||
[60.6010, 56.8308],
|
||
[60.600, 56.8308],
|
||
[60.600, 56.8300],
|
||
]
|
||
|
||
|
||
def _payload(coords: list[list[float]], **overrides: object) -> ConceptInput:
|
||
base: dict[str, object] = {
|
||
"parcel_geojson": {"type": "Polygon", "coordinates": [coords]},
|
||
"housing_class": "comfort",
|
||
"target_floors": 9,
|
||
"development_type": "mid_rise",
|
||
}
|
||
base.update(overrides)
|
||
return ConceptInput(**base) # type: ignore[arg-type]
|
||
|
||
|
||
# ── (a) backward-compat: building_program=None reproduces greedy output ─────────────
|
||
|
||
|
||
def test_no_program_reproduces_greedy_output_unchanged() -> None:
|
||
# Тот же участок: с building_program=None раскладка ДОЛЖНА совпасть с жадной 1b
|
||
# (три стратегии, те же ТЭП/число секций) — program-ветка не трогает greedy-путь.
|
||
payload_default = _payload(_BIG_PARCEL)
|
||
payload_none = _payload(_BIG_PARCEL, building_program=None)
|
||
|
||
parcel = geometry.parse_parcel(payload_default)
|
||
greedy = placement.place_all_strategies(parcel, payload_default)
|
||
none_branch = placement.place_all_strategies(geometry.parse_parcel(payload_none), payload_none)
|
||
|
||
assert {v.strategy for v in greedy} == {"max_area", "max_insolation", "balanced"}
|
||
assert {v.strategy for v in none_branch} == {"max_area", "max_insolation", "balanced"}
|
||
by_strategy_g = {v.strategy: v for v in greedy}
|
||
by_strategy_n = {v.strategy: v for v in none_branch}
|
||
for name, gv in by_strategy_g.items():
|
||
nv = by_strategy_n[name]
|
||
assert gv.teap == nv.teap
|
||
assert gv.financial == nv.financial
|
||
assert len(gv.buildings_geojson["features"]) == len(nv.buildings_geojson["features"])
|
||
# Greedy-режим не выставляет partial-fit сигнал (остаётся None).
|
||
assert gv.placed_count is None
|
||
assert gv.requested_count is None
|
||
|
||
|
||
# ── (b) 2-item program places EXACTLY the requested sections; TEAP reflects them ─────
|
||
|
||
|
||
def test_two_item_program_places_exactly_requested() -> None:
|
||
program = [
|
||
BuildingProgramItem(section_type="monolith_comfort", floors=14, count=3),
|
||
BuildingProgramItem(section_type="tower_business", floors=20, count=2),
|
||
]
|
||
payload = _payload(_BIG_PARCEL, building_program=program)
|
||
parcel = geometry.parse_parcel(payload)
|
||
placed = placement.place_program(parcel, program)
|
||
|
||
# Все 5 секций влезли в крупный участок — разместилось ровно столько, сколько просили.
|
||
assert placed.requested_count == 5
|
||
assert placed.placed_count == 5
|
||
assert len(placed.sections) == 5
|
||
# Каждая секция несёт свой тип/этажность из программы.
|
||
monolith = [s for s in placed.sections if s.section_type == "monolith_comfort"]
|
||
tower = [s for s in placed.sections if s.section_type == "tower_business"]
|
||
assert len(monolith) == 3 and all(s.floors == 14 for s in monolith)
|
||
assert len(tower) == 2 and all(s.floors == 20 for s in tower)
|
||
|
||
|
||
def test_two_item_program_variant_teap_reflects_program() -> None:
|
||
program = [
|
||
BuildingProgramItem(section_type="monolith_comfort", floors=14, count=3),
|
||
BuildingProgramItem(section_type="tower_business", floors=20, count=2),
|
||
]
|
||
payload = _payload(_BIG_PARCEL, building_program=program)
|
||
parcel = geometry.parse_parcel(payload)
|
||
variant = placement.place_program_variant(parcel, payload)
|
||
assert variant is not None
|
||
|
||
# Partial-fit сигнал заполнен (program-режим): разместилось 5 из 5.
|
||
assert variant.placed_count == 5
|
||
assert variant.requested_count == 5
|
||
|
||
# ТЭП отражает именно размещённую программу:
|
||
# built = Σ площадей пятен каталога (3×monolith 21×18 + 2×tower 18×18).
|
||
mono = catalog.get_house_type("monolith_comfort")
|
||
tower = catalog.get_house_type("tower_business")
|
||
expected_built = 3 * mono.footprint_sqm + 2 * tower.footprint_sqm
|
||
assert variant.teap.built_area_sqm == pytest.approx(expected_built, abs=1.0)
|
||
|
||
# GFA = Σ(площадь_i × floors_i) — ТОЧНО (per-floor-group аггрегация, без дрейфа
|
||
# от округления «средней» этажности).
|
||
expected_gfa = 3 * mono.footprint_sqm * 14 + 2 * tower.footprint_sqm * 20
|
||
assert variant.teap.total_floor_area_sqm == pytest.approx(expected_gfa, abs=1.0)
|
||
|
||
assert variant.teap.apartments_count > 0
|
||
assert variant.financial.revenue_rub > 0
|
||
# Один program-вариант (не три жадные стратегии) идёт через place_all_strategies.
|
||
variants = placement.place_all_strategies(parcel, payload)
|
||
assert len(variants) == 1
|
||
assert variants[0].placed_count == 5
|
||
|
||
|
||
def test_program_geojson_features_carry_own_floors_and_type() -> None:
|
||
program = [
|
||
BuildingProgramItem(section_type="monolith_comfort", floors=14, count=2),
|
||
BuildingProgramItem(section_type="townhouse", floors=3, count=2),
|
||
]
|
||
payload = _payload(_BIG_PARCEL, building_program=program)
|
||
parcel = geometry.parse_parcel(payload)
|
||
variant = placement.place_program_variant(parcel, payload)
|
||
assert variant is not None
|
||
features = variant.buildings_geojson["features"]
|
||
assert isinstance(features, list)
|
||
floors_seen = set()
|
||
types_seen = set()
|
||
for feat in features:
|
||
geom = shape(feat["geometry"])
|
||
assert geom.geom_type == "Polygon" and geom.is_valid
|
||
props = feat["properties"]
|
||
assert props["strategy"] == "program"
|
||
assert props["section_type"] in {"monolith_comfort", "townhouse"}
|
||
floors_seen.add(props["floors"])
|
||
types_seen.add(props["section_type"])
|
||
# Смешанная этажность/типы сохранены пер-секционно в GeoJSON.
|
||
assert floors_seen == {14, 3}
|
||
assert types_seen == {"monolith_comfort", "townhouse"}
|
||
|
||
|
||
def test_program_footprints_non_overlapping_inside_buildable() -> None:
|
||
program = [BuildingProgramItem(section_type="monolith_comfort", floors=14, count=6)]
|
||
payload = _payload(_BIG_PARCEL, building_program=program)
|
||
parcel = geometry.parse_parcel(payload)
|
||
placed = placement.place_program(parcel, program)
|
||
fps = [s.footprint for s in placed.sections]
|
||
assert len(fps) > 1
|
||
for fp in fps:
|
||
assert parcel.buildable_m.buffer(0.01).covers(fp)
|
||
for i, a in enumerate(fps):
|
||
for b in fps[i + 1 :]:
|
||
assert not a.buffer(-0.01).intersects(b.buffer(-0.01))
|
||
|
||
|
||
# ── (c) over-packed program reports partial placement WITHOUT raising ───────────────
|
||
|
||
|
||
def test_overpacked_program_reports_partial_without_raising() -> None:
|
||
# Маленький участок вмещает ~2 крупные секции, просим 20 — НЕ 422, честный N<M.
|
||
program = [BuildingProgramItem(section_type="monolith_comfort", floors=14, count=20)]
|
||
payload = _payload(_SMALL_PARCEL, building_program=program)
|
||
parcel = geometry.parse_parcel(payload)
|
||
placed = placement.place_program(parcel, program)
|
||
assert placed.requested_count == 20
|
||
assert 0 < placed.placed_count < 20 # разместилось N из M
|
||
|
||
# И через полный конвейер: вариант строится, partial-fit сигнал проброшен, без raise.
|
||
variants = placement.place_all_strategies(parcel, payload)
|
||
assert len(variants) == 1
|
||
v = variants[0]
|
||
assert v.requested_count == 20
|
||
assert 0 < v.placed_count < 20
|
||
assert v.teap.apartments_count > 0
|
||
|
||
|
||
def test_program_with_zero_fit_raises_parcel_error() -> None:
|
||
# Участок настолько мал, что НИ ОДНА секция программы не влезает → ParcelGeometryError
|
||
# (API мапит в 422) — лучше отказ, чем лживый нулевой вариант.
|
||
tiny = [
|
||
[60.60, 56.830],
|
||
[60.60015, 56.830],
|
||
[60.60015, 56.83015],
|
||
[60.60, 56.83015],
|
||
[60.60, 56.830],
|
||
]
|
||
program = [BuildingProgramItem(section_type="tower_business", floors=25, count=5)]
|
||
payload = _payload(tiny, building_program=program)
|
||
with pytest.raises(ParcelGeometryError):
|
||
parcel = geometry.parse_parcel(payload)
|
||
placement.place_all_strategies(parcel, payload)
|