gendesign/backend/tests/services/generative/test_program_placement.py
Light1YT 94cf1f6217 feat(concept): house-type catalog + program-driven placement (#1965 Stage 3a)
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).
2026-06-28 01:29:04 +05:00

213 lines
9.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 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)