gendesign/backend/app/services/generative/catalog.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

141 lines
7.2 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.

"""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",
]