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).
141 lines
7.2 KiB
Python
141 lines
7.2 KiB
Python
"""Generative Design — Stage 3a (#1965): каталог типовых домов (house-type catalog).
|
||
|
||
Контракт Stage 3a: вместо max-FAR жадной раскладки (Stage 1b) пользователь выбирает
|
||
ТИПОВЫЕ дома и говорит, сколько секций каждого типа поставить. Этот модуль — справочник
|
||
таких типов: для каждого ``section_type`` он несёт габариты пятна секции (ширина × глубина,
|
||
метры), дефолтную этажность и подходящий класс жилья.
|
||
|
||
ИСТОЧНИК: это РАЗУМНЫЙ ДЕФОЛТНЫЙ каталог (sane-default), а не выгрузка из БД. Габариты —
|
||
типовые размеры секций массового жилья РФ (панель/монолит/башня/малоэтажка), округлённые
|
||
до реалистичных значений. Каталог намеренно захардкожен в коде на Stage 3a: миграции БД
|
||
сейчас нет (см. эпик #1953). Когда понадобится редактируемый застройщиком каталог — его
|
||
ПРОДВИГАЮТ в БД-таблицу с тем же контрактом (``section_type`` → footprint/floors/class),
|
||
а этот модуль станет seed'ом/фолбэком. До тех пор — single source of truth по типам.
|
||
|
||
Детерминированно, без LLM / внешних API / БД.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from typing import Literal
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Класс жилья — то же Literal-множество, что у ConceptInput / compute_teap (single
|
||
# source of truth по допустимым значениям; рассинхрон тут = ошибка типов в mypy-strict).
|
||
HousingClass = Literal["econom", "comfort", "business"]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class HouseType:
|
||
"""Один типовой дом каталога (тип секции МКД / малоэтажки).
|
||
|
||
section_type — стабильный машинный КЛЮЧ типа (латиница, snake_case); это и есть
|
||
значение, которое фронт кладёт в ``BuildingProgramItem.section_type``.
|
||
label_ru — человекочитаемый русский лейбл для UI (Stage 3b его показывает).
|
||
footprint_w_m — ширина пятна секции, метры.
|
||
footprint_d_m — глубина пятна секции, метры.
|
||
default_floors — дефолтная этажность типа (UI подставляет, пользователь может менять
|
||
в пределах контракта BuildingProgramItem [1, 40]).
|
||
housing_class — подходящий класс жилья (драйвит ТЭП/финмодель ниже по конвейеру).
|
||
"""
|
||
|
||
section_type: str
|
||
label_ru: str
|
||
footprint_w_m: float
|
||
footprint_d_m: float
|
||
default_floors: int
|
||
housing_class: HousingClass
|
||
|
||
@property
|
||
def footprint_sqm(self) -> float:
|
||
"""Площадь пятна секции, кв.м (ширина × глубина)."""
|
||
return self.footprint_w_m * self.footprint_d_m
|
||
|
||
|
||
# ── Каталог типовых домов (sane-default, см. модульный docstring про источник) ──────
|
||
# Покрываем основные форматы массового жилья РФ:
|
||
# * панель-эконом — длинная неглубокая секция, средняя этажность, эконом-класс;
|
||
# * монолит-комфорт— чуть шире/глубже, комфорт-класс, типовая «свечка» 14 этажей;
|
||
# * башня-бизнес — компактное квадратное пятно, высотная (точечная) застройка;
|
||
# * малоэтажка-комфорт — широкая невысокая секция (3 этажа), низкоплотная застройка;
|
||
# * таунхаус — узкое неглубокое пятно блокированной застройки, 3 этажа.
|
||
# Габариты — реалистичные типовые размеры; этажность — характерная для формата.
|
||
HOUSE_TYPES: tuple[HouseType, ...] = (
|
||
HouseType(
|
||
section_type="panel_econom",
|
||
label_ru="Панельная секция (эконом)",
|
||
footprint_w_m=24.0,
|
||
footprint_d_m=15.0,
|
||
default_floors=9,
|
||
housing_class="econom",
|
||
),
|
||
HouseType(
|
||
section_type="monolith_comfort",
|
||
label_ru="Монолитная секция (комфорт)",
|
||
footprint_w_m=21.0,
|
||
footprint_d_m=18.0,
|
||
default_floors=14,
|
||
housing_class="comfort",
|
||
),
|
||
HouseType(
|
||
section_type="tower_business",
|
||
label_ru="Башня (бизнес)",
|
||
footprint_w_m=18.0,
|
||
footprint_d_m=18.0,
|
||
default_floors=25,
|
||
housing_class="business",
|
||
),
|
||
HouseType(
|
||
section_type="lowrise_comfort",
|
||
label_ru="Малоэтажная секция (комфорт)",
|
||
footprint_w_m=30.0,
|
||
footprint_d_m=14.0,
|
||
default_floors=3,
|
||
housing_class="comfort",
|
||
),
|
||
HouseType(
|
||
section_type="townhouse",
|
||
label_ru="Таунхаус",
|
||
footprint_w_m=12.0,
|
||
footprint_d_m=10.0,
|
||
default_floors=3,
|
||
housing_class="comfort",
|
||
),
|
||
)
|
||
|
||
# Индекс по ключу для O(1)-лукапа. Построен один раз при импорте; ключи уникальны
|
||
# (assert ниже ловит дубликат типа на старте, а не молча затирает запись).
|
||
_BY_KEY: dict[str, HouseType] = {ht.section_type: ht for ht in HOUSE_TYPES}
|
||
assert len(_BY_KEY) == len(HOUSE_TYPES), "duplicate section_type key in HOUSE_TYPES"
|
||
|
||
|
||
def get_house_type(section_type: str) -> HouseType:
|
||
"""Найти тип дома по ключу ``section_type``. Бросает :class:`KeyError`, если нет.
|
||
|
||
Вызывающий слой (placement) обязан валидировать ключи программы заранее (см.
|
||
:func:`available_section_types`) — неизвестный ключ здесь это программная ошибка,
|
||
а не пользовательский ввод, поэтому KeyError, а не тихий None.
|
||
"""
|
||
try:
|
||
return _BY_KEY[section_type]
|
||
except KeyError as exc:
|
||
raise KeyError(
|
||
f"unknown house type {section_type!r}; " f"available: {', '.join(sorted(_BY_KEY))}"
|
||
) from exc
|
||
|
||
|
||
def available_section_types() -> frozenset[str]:
|
||
"""Множество допустимых ключей ``section_type`` каталога (для валидации программы)."""
|
||
return frozenset(_BY_KEY)
|
||
|
||
|
||
__all__ = [
|
||
"HOUSE_TYPES",
|
||
"HouseType",
|
||
"HousingClass",
|
||
"available_section_types",
|
||
"get_house_type",
|
||
]
|