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