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).
245 lines
17 KiB
Python
245 lines
17 KiB
Python
from typing import Annotated, Any, Literal
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class BuildingProgramItem(BaseModel):
|
||
"""Stage 3a (#1965, эпик #1953) — один пункт программы застройки (типовой дом × N).
|
||
|
||
Пользователь набирает программу из ТИПОВЫХ домов каталога
|
||
(``app.services.generative.catalog.HOUSE_TYPES``) вместо max-FAR жадной раскладки:
|
||
«поставь ``count`` секций типа ``section_type`` этажностью ``floors``». Габариты
|
||
пятна секции берутся из каталога по ключу ``section_type`` (контракт несёт только
|
||
ключ, не геометрию — единый справочник на бэке).
|
||
"""
|
||
|
||
# Ключ типа дома из каталога (``HouseType.section_type``). Валидируется на слое
|
||
# размещения по ``catalog.available_section_types`` — неизвестный ключ → 422.
|
||
section_type: str = Field(..., description="Catalog house-type key (HOUSE_TYPES)")
|
||
# Этажность этой группы секций; диапазон шире, чем у target_floors (до 40 — башни).
|
||
floors: int = Field(..., ge=1, le=40, description="Floors for this house-type group")
|
||
# Сколько секций этого типа разместить.
|
||
count: int = Field(..., ge=1, le=50, description="Number of sections to place")
|
||
|
||
|
||
class ConceptInput(BaseModel):
|
||
"""Stage 1a — input contract. Frozen interface for frontend codegen."""
|
||
|
||
parcel_geojson: dict[str, Any] = Field(
|
||
..., description="GeoJSON Polygon of the parcel (WGS84 / EPSG:4326)"
|
||
)
|
||
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
||
target_floors: int = Field(9, ge=1, le=30)
|
||
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
|
||
land_cost_rub: float | None = Field(
|
||
None, ge=0, description="Optional land cost for financial model"
|
||
)
|
||
# Stage 3a (#1965): ОПЦИОНАЛЬНАЯ программа застройки из типовых домов каталога.
|
||
# ADDITIVE поле (default None). None → существующая жадная раскладка (Stage 1b,
|
||
# byte-for-byte backward-compat). Задано → раскладка кладёт РОВНО эту программу
|
||
# (place_program): для каждого пункта ставит count секций каталожного пятна с его
|
||
# floors, вместо coverage-cap sweep. См. app.services.generative.placement.
|
||
building_program: list[BuildingProgramItem] | None = Field(
|
||
None,
|
||
description="Optional typed house program; None → greedy max-FAR placement",
|
||
)
|
||
|
||
|
||
class TEAP(BaseModel):
|
||
"""Технико-экономические показатели."""
|
||
|
||
built_area_sqm: float
|
||
total_floor_area_sqm: float
|
||
# Нежилое (коммерция/офисы 1-го этажа) = GFA × нормативная доля по классу
|
||
# (см. ``_OFFICE_SHARE_OF_GFA`` в teap.py). Вырезается из GFA до расчёта жилой —
|
||
# жилая ужимается ровно на эту площадь, total_floor_area_sqm не меняется. Additive
|
||
# поле (default 0.0) → backward-compat: 0.0 = нежилое не моделировалось/не нужно.
|
||
office_area_sqm: float = 0.0
|
||
residential_area_sqm: float
|
||
apartments_count: int
|
||
density: float
|
||
parking_spaces: int
|
||
|
||
|
||
class FinancialModel(BaseModel):
|
||
"""Static developer P&L + monthly-DCF investment metrics (PR-1/PR-3, эпик #1881).
|
||
|
||
Full static cost cascade + VAT + profit tax → net profit + ROI (PR-1). On top
|
||
of that static P&L, PR-3 lays the cascade onto a **monthly cashflow schedule**
|
||
(default phase norms — ПИР → СМР → продажи) and runs a real DCF: NPV, IRR
|
||
(bisection over the monthly cashflow) and *undiscounted* cumulative payback.
|
||
See ``compute_financial`` docstring for the schedule assumptions and the
|
||
налоги/рассрочка phasing choices.
|
||
|
||
``irr`` is a **real** annualised DCF IRR when ``irr_is_proxy is False``; for a
|
||
degenerate cashflow with no sign change (e.g. always-loss) the bisection cannot
|
||
bracket a root, so we fall back to the PR-1 annualised-ROI proxy and flag
|
||
``irr_is_proxy=True``. VAT is a documented simplification (see docstring), not
|
||
exact НК РФ mechanics.
|
||
|
||
``levered_irr`` — IRR на собственные средства (equity cashflow при LTC): тот же
|
||
DCF-IRR + roi-proxy fallback, но по equity-потоку графика долга. При положительном
|
||
леверидже выше unlevered ``irr``; вырожденные случаи → 0.0 + ``levered_irr_is_proxy``.
|
||
|
||
The legacy summary fields (``revenue_rub`` / ``cost_rub`` /
|
||
``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
|
||
expose the full cascade, the net P&L and the DCF metrics.
|
||
"""
|
||
|
||
# ── Legacy summary (backward-compat) ───────────────────────────────────────
|
||
revenue_rub: float
|
||
cost_rub: float
|
||
gross_margin_rub: float
|
||
irr: float
|
||
|
||
# ── Revenue breakdown ──────────────────────────────────────────────────────
|
||
revenue_residential_rub: float
|
||
revenue_parking_rub: float
|
||
# Выручка нежилого (коммерция/офисы 1-го этажа) = office_area_sqm × цена нежилого
|
||
# (см. ``_OFFICE_SALE_PRICE_PER_SQM`` в financial.py). Чисто ADDITIVE выручка:
|
||
# СМР нежилого уже сидит в ``construction_rub`` (нежилая GFA входит в total_floor_area
|
||
# → construction = total_floor_area × ставка уже её покрывает; double-count'а нет).
|
||
# Нежилое ОБЛАГАЕТСЯ НДС (в отличие от льготы жилья ст.149 НК) — его value-added
|
||
# входит в базу НДС. Default 0.0 → backward-compat (нежилое не моделировалось).
|
||
revenue_office_rub: float = 0.0
|
||
|
||
# ── Cost cascade ───────────────────────────────────────────────────────────
|
||
construction_rub: float
|
||
pir_rub: float
|
||
networks_rub: float
|
||
developer_services_rub: float
|
||
contingency_rub: float
|
||
marketing_rub: float
|
||
land_rub: float
|
||
|
||
# ── БДР / taxes → net profit ───────────────────────────────────────────────
|
||
vat_rub: float
|
||
profit_before_tax_rub: float
|
||
profit_tax_rub: float
|
||
net_profit_rub: float
|
||
|
||
# ── Metrics ────────────────────────────────────────────────────────────────
|
||
roi: float # net profit / total cost
|
||
margin_pct: float # net profit / revenue
|
||
irr_is_proxy: bool = True # frontend caveat: True → IRR is the ROI-proxy, not DCF
|
||
|
||
# ── DCF / investment metrics (PR-3, эпик #1881) ────────────────────────────
|
||
# NPV дисконтированного помесячного cashflow по ставке ``discount_rate_used``.
|
||
npv_rub: float = 0.0
|
||
# Окупаемость (мес) по НЕдисконтированному накопительному cashflow, линейная
|
||
# интерполяция дробного месяца. ``None`` — проект не окупается на горизонте.
|
||
payback_months: float | None = None
|
||
# Годовая ставка дисконтирования, фактически применённая в NPV (норматив).
|
||
discount_rate_used: float = 0.0
|
||
# График фаз/продаж — типовые нормативные ДОПУЩЕНИЯ (не график конкретного
|
||
# проекта). True → UI/PDF показывают caveat «график — типовое допущение». RANK 1:
|
||
# False, когда окно продаж построено по реальной рыночной скорости абсорбции
|
||
# района (а не дефолт-30-мес).
|
||
schedule_is_default: bool = True
|
||
# RANK 1: реализованное окно продаж (sales_end − sales_start), мес — для UI/PDF.
|
||
# Драйвится рыночной абсорбцией района, когда ``schedule_is_default is False``;
|
||
# иначе дефолт-норматив (с эскроу-привязкой к завершению стройки). ``None`` —
|
||
# финмодель не строилась/окно неизвестно.
|
||
sales_duration_months: float | None = None
|
||
|
||
# ── Financing overlay (PR-5, эпик #1881) ───────────────────────────────────
|
||
# OVERLAY поверх unlevered cashflow: кредит покрывает кассовый разрыв, проценты
|
||
# капитализируются (compound). Unlevered NPV/IRR (headline) НЕ затронуты.
|
||
financing_enabled: bool = False # True когда overlay посчитан
|
||
annual_rate_used: float = 0.0 # _CREDIT_RATE_ANNUAL (норматив, отдельно от дисконта)
|
||
peak_debt_rub: float = 0.0 # «задолженность по кредиту» Excel — пик долга
|
||
total_interest_rub: float = 0.0 # «поток по финдеятельности» Excel — Σ процентов
|
||
net_profit_after_financing_rub: float = 0.0 # net_profit − total_interest
|
||
# honest-флаг: 100% покрытие разрыва, compound %, эскроу не моделируется точно.
|
||
financing_is_simplified: bool = True
|
||
# Levered (equity) IRR: доходность на собственные средства (equity cashflow при LTC).
|
||
# При положительном леверидже > unlevered ``irr``. Считается DCF-IRR'ом equity-потока
|
||
# того же графика долга, с тем же roi-proxy fallback'ом, что и unlevered IRR.
|
||
levered_irr: float = 0.0 # IRR на собственные средства (equity cashflow при LTC)
|
||
# honest-флаг: True → levered_irr — roi-proxy (вырожденный equity-поток / нет equity
|
||
# под риском при LTC>=1 / финансирование выключено), не настоящий DCF-IRR.
|
||
levered_irr_is_proxy: bool = True # True → levered_irr — roi-proxy, не DCF
|
||
|
||
# ── Price calibration (PR-2, эпик #1881) ───────────────────────────────────
|
||
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо
|
||
# калиброванная по рынку (Objective / district reference), либо норматив класса.
|
||
# ``price_source`` honest-flag для UI/PDF: не выдавать норматив за рынок.
|
||
price_per_sqm_used: float
|
||
price_is_calibrated: bool = False
|
||
# Значения: "objective_district_median" | "district_reference" | "class_norm".
|
||
price_source: str = "class_norm"
|
||
|
||
|
||
class ConceptVariant(BaseModel):
|
||
strategy: Literal["max_area", "max_insolation", "balanced"]
|
||
buildings_geojson: dict[str, Any]
|
||
teap: TEAP
|
||
financial: FinancialModel
|
||
# Stage 3a (#1965) — честный сигнал частичного размещения для program-режима.
|
||
# Когда задан ``ConceptInput.building_program``: ``requested_count`` — сколько секций
|
||
# просили (Σ count по программе), ``placed_count`` — сколько реально влезло в участок.
|
||
# placed < requested → участок мал, разместилось N из M (фронт Stage 3b показывает
|
||
# «разместилось N из M», без hard-422). Оба None в greedy-режиме (программа не задана)
|
||
# → ADDITIVE, backward-compat: старый ответ концепции не меняется. Annotated+`= None`:
|
||
# рантайм-дефолт виден mypy (без pydantic-плагина), Field несёт только OpenAPI-описание.
|
||
placed_count: Annotated[
|
||
int | None, Field(description="Stage 3a: sections actually placed (program mode only)")
|
||
] = None
|
||
requested_count: Annotated[
|
||
int | None, Field(description="Stage 3a: sections requested by program (program mode only)")
|
||
] = None
|
||
|
||
|
||
class ConceptOutput(BaseModel):
|
||
variants: list[ConceptVariant]
|
||
|
||
|
||
class MassingProgram(BaseModel):
|
||
"""Stage 2a (#1965) — агрегированная массинг-программа для LIVE-пересчёта экономики.
|
||
|
||
Контракт POST ``/api/v1/concepts/recompute``: вход — уже СВЁРНУТАЯ программа из
|
||
фронтового ``computeModel`` интерактивного 3D-массинга (суммарное пятно × этажность),
|
||
без покомпонентной геометрии секций. Эндпоинт синтезирует из неё :class:`TEAP` и
|
||
прогоняет готовый ``compute_financial`` → пересчитанная экономика для дебаунс-слайдеров
|
||
Stage 2b.
|
||
|
||
Класс жилья / тип застройки — те же Literal-множества, что у :class:`ConceptInput`
|
||
и ``compute_financial`` (single source of truth по допустимым значениям).
|
||
"""
|
||
|
||
total_footprint_sqm: float = Field(
|
||
..., ge=0, description="Суммарное пятно застройки всех секций, кв.м (скаляр)"
|
||
)
|
||
floors: int = Field(..., ge=1, le=40, description="Этажность программы")
|
||
sections: int = Field(1, ge=1, le=6, description="Число секций (метаданные программы)")
|
||
site_area_sqm: float = Field(..., ge=0, description="Площадь участка для плотности (FAR)")
|
||
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
||
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
|
||
land_cost_rub: float | None = Field(
|
||
None, ge=0, description="Стоимость участка для финмодели (опционально)"
|
||
)
|
||
# Предрезолвленная рыночная цена жилья, руб/кв.м — позволяет hot-эндпоинту ПРОПУСТИТЬ
|
||
# per-keystroke DB-lookup (фронт резолвит цену один раз и шлёт её в каждом запросе).
|
||
# None → эндпоинт сам сделает _lookup_market_price по центроиду (медленный путь).
|
||
market_price_per_sqm: float | None = Field(
|
||
None, ge=0, description="Предрезолвленная рыночная цена жилья, руб/кв.м (skip DB lookup)"
|
||
)
|
||
# ПОДЛИННЫЙ источник предрезолвленной цены, который фронт получил из financial_estimate
|
||
# (objective_geo_radius / objective_district_median / district_reference / class_norm).
|
||
# Honest-флаг для UI/PDF: НЕ подменяем его захардкоженным лейблом. Применяется ТОЛЬКО
|
||
# вместе с market_price_per_sqm; None → дефолтный лейбл (см. эндпоинт recompute_massing).
|
||
price_source: str | None = Field(
|
||
None, description="Подлинный источник предрезолвленной цены (forward из financial_estimate)"
|
||
)
|
||
# Центроид участка (WKT-точка WGS84) для DB-fallback цены, когда market_price_per_sqm
|
||
# не передан. None → пропускаем lookup и берём норматив класса (нет геопривязки).
|
||
parcel_centroid_wkt: str | None = Field(
|
||
None, description="WKT-точка центроида участка (WGS84) для DB-резолва цены при fallback"
|
||
)
|
||
|
||
|
||
class MassingRecomputeOutput(BaseModel):
|
||
"""Stage 2a (#1965) — результат LIVE-пересчёта: синтезированный ТЭП + финмодель."""
|
||
|
||
teap: TEAP
|
||
financial: FinancialModel
|