gendesign/backend/app/services/generative/teap.py
Light1YT d38d5d43c5 feat(generative): LIVE financial recompute from massing program (#1965 Stage 2a)
Stage 2a of epic #1953: backend service + endpoint for live economic recompute
driven by the interactive 3D massing (Stage 2b debounced sliders).

- teap.py: add pure synthesize_teap_from_program(total_footprint_sqm, floors,
  site_area_sqm, housing_class, sections) — builds a TEAP from the SCALAR
  aggregate footprint × floors, mirroring synthesize_teap_from_buildability and
  reusing the same shared norm constants (_OFFICE_SHARE_OF_GFA / _EFFICIENCY_BY_CLASS
  / _AVG_APARTMENT_SQM / _PARKING_PER_APARTMENT) — single source of truth.
- schemas/concept.py: add MassingProgram (program contract, optional pre-resolved
  market_price_per_sqm + parcel_centroid_wkt) and MassingRecomputeOutput (teap + financial).
- api/v1/concepts.py: add POST /api/v1/concepts/recompute — synthesize TEAP → run
  the existing pure compute_financial. FAST path uses body market_price_per_sqm
  (no DB); else _lookup_market_price by centroid via run_in_threadpool; else class norm.
- tests: synthesize_teap_from_program (gfa math, parity with compute_teap, class
  efficiency, sections no-op) + endpoint (200, coherent output, price passthrough
  skips DB, DB fallback, class-norm default, floors validation).
2026-06-28 00:27:25 +05:00

211 lines
12 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 1c: ТЭП (technical-economic indicators).
From the Stage 1b placement (rectangular section footprints + floor count) we derive
the ``TEAP`` contract block with *real* numbers:
* ``built_area_sqm`` — пятно застройки (сумма площадей footprint-ов).
* ``total_floor_area_sqm`` — общая поэтажная площадь (GFA) = пятно * этажность.
* ``office_area_sqm`` — нежилое (встроенно-пристроенный 1-й этаж: коммерция/
офисы) = GFA * нормативная доля нежилого (зависит от класса жилья). Вырезается
из GFA ПЕРЕД расчётом жилой → жилая ужимается на эту площадь (total GFA не меняется).
* ``residential_area_sqm`` — продаваемая жилая = (GFA нежилое) * коэффициент
эффективности (вычет МОП/лестниц/тех.помещений; зависит от класса жилья).
* ``apartments_count`` — жилая / средняя площадь квартиры (зависит от класса).
* ``density`` — плотность застройки = FAR = GFA / площадь участка.
* ``parking_spaces`` — машиноместа по упрощённой норме (мест на квартиру).
Все коэффициенты — упрощённые нормативные proxy для MVP (см. константы ниже).
Детерминированно, без LLM / внешних API / БД.
"""
from __future__ import annotations
import logging
import math
from typing import Literal
from shapely.geometry import Polygon
from app.schemas.concept import TEAP
logger = logging.getLogger(__name__)
HousingClass = Literal["econom", "comfort", "business"]
# ── Коэффициент эффективности площади (residential / GFA), доля ────────────────
# Доля продаваемой жилой в общей поэтажной (остальное — МОП, лестницы, тех.этаж).
# Бизнес-класс «тратит» больше на МОП/лобби -> ниже эффективность.
_EFFICIENCY_BY_CLASS: dict[HousingClass, float] = {
"econom": 0.82,
"comfort": 0.78,
"business": 0.72,
}
# ── Средняя площадь квартиры (кв.м) по классу — выше класс -> крупнее лот ──────
_AVG_APARTMENT_SQM: dict[HousingClass, float] = {
"econom": 42.0,
"comfort": 55.0,
"business": 78.0,
}
# ── Норма парковки (машиномест на квартиру) по классу ─────────────────────────
_PARKING_PER_APARTMENT: dict[HousingClass, float] = {
"econom": 0.8,
"comfort": 1.0,
"business": 1.5,
}
# ── Доля нежилого (коммерция/офисы 1-го этажа) в GFA по классу ─────────────────
# НОРМАТИВ (не догадка): городские МКД РФ почти всегда несут встроенно-пристроенные
# нежилые помещения 1-го этажа (магазины/услуги/офисы). По обзорам застройки РФ их
# доля в общей поэтажной площади (GFA) типично 38% для городских МКД (1-й этаж из
# 925). Берём КОНСЕРВАТИВНЫЕ значения нижней границы диапазона:
# econom 0.0 — массовый эконом часто строят БЕЗ коммерции (спальные районы,
# "квартиры на 1-м этаже"); нулём не завышаем выручку.
# comfort 0.05 — комфорт почти всегда имеет коммерческий стрит-ритейл 1-го этажа.
# business 0.05 — бизнес: представительный 1-й этаж (лобби+коммерция), но та же
# умеренная доля (бОльшая часть нижних уровней — лобби/МОП, а не
# продаваемая коммерция) — консервативно не задираем.
# Нежилое ВЫРЕЗАЕТСЯ из GFA до расчёта жилой (жилая ужимается ровно на эту площадь),
# total GFA при этом не меняется — это и есть фикс реализма (раньше ВСЯ непаркинговая
# GFA считалась жилой, чуть завышая жилую площадь). Источник доли — отраслевой обзор,
# НЕ проектный факт → caveat в финмодели честно это помечает.
_OFFICE_SHARE_OF_GFA: dict[HousingClass, float] = {
"econom": 0.0,
"comfort": 0.05,
"business": 0.05,
}
def compute_teap(
*,
footprints: list[Polygon],
floors: int,
site_area_sqm: float,
housing_class: HousingClass,
) -> TEAP:
"""Свести footprints + этажность в :class:`TEAP`.
Args:
footprints: метрические пятна секций (кв.м берётся из ``.area``).
floors: этажность (общая для всех секций варианта).
site_area_sqm: площадь участка для плотности (FAR).
housing_class: класс жилья — задаёт эффективность/средний лот/парковку.
"""
built_area = float(sum(fp.area for fp in footprints))
total_floor_area = built_area * max(0, floors)
# Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA ДО расчёта жилой: жилая
# считается только по ОСТАВШЕЙСЯ (непаркинговой, нежилой-исключённой) GFA. total
# GFA не меняется — нежилое остаётся в нём (СМР по нему уже покрыт в финмодели).
office_share = _OFFICE_SHARE_OF_GFA[housing_class]
office_area = total_floor_area * office_share
residential_gfa = total_floor_area - office_area
efficiency = _EFFICIENCY_BY_CLASS[housing_class]
residential_area = residential_gfa * efficiency
avg_apartment = _AVG_APARTMENT_SQM[housing_class]
apartments_count = math.floor(residential_area / avg_apartment) if avg_apartment else 0
# Плотность застройки = FAR (GFA / площадь участка). Защита от деления на ноль.
density = total_floor_area / site_area_sqm if site_area_sqm > 0 else 0.0
parking_norm = _PARKING_PER_APARTMENT[housing_class]
parking_spaces = math.ceil(apartments_count * parking_norm)
teap = TEAP(
built_area_sqm=round(built_area, 1),
total_floor_area_sqm=round(total_floor_area, 1),
office_area_sqm=round(office_area, 1),
residential_area_sqm=round(residential_area, 1),
apartments_count=apartments_count,
density=round(density, 3),
parking_spaces=parking_spaces,
)
logger.info(
"TEAP: built=%.0f GFA=%.0f office=%.0f resid=%.0f apts=%d FAR=%.2f parking=%d",
teap.built_area_sqm,
teap.total_floor_area_sqm,
teap.office_area_sqm,
teap.residential_area_sqm,
teap.apartments_count,
teap.density,
teap.parking_spaces,
)
return teap
def synthesize_teap_from_program(
*,
total_footprint_sqm: float,
floors: int,
site_area_sqm: float,
housing_class: HousingClass,
sections: int = 1,
) -> TEAP:
"""Свести АГРЕГИРОВАННУЮ массинг-программу (скалярное пятно × этажность) в :class:`TEAP`.
Stage 2a (#1965): LIVE-пересчёт экономики из интерактивного 3D-массинга. Фронтовый
``computeModel`` отдаёт уже СВЁРНУТУЮ программу — суммарное пятно застройки (кв.м) и
этажность, без покомпонентной геометрии секций. Здесь мы строим из этого тот же
:class:`TEAP`, что и :func:`compute_teap`/``synthesize_teap_from_buildability``, чтобы
прогнать его через готовый :func:`compute_financial`.
Зеркалит :func:`synthesize_teap_from_buildability` ОДИН-В-ОДИН, только источник
площадей — СКАЛЯРНОЕ пятно, а не ``max_far × area``:
* ``built`` (пятно) = ``total_footprint_sqm`` (как есть, сумма по секциям).
* ``GFA`` (total_floor) = ``total_footprint_sqm × floors``.
* ``office`` (нежилое) = ``GFA × _OFFICE_SHARE_OF_GFA[class]`` — вырезается из GFA
до расчёта жилой (жилая ужимается ровно на эту площадь, total GFA не меняется).
* ``residential`` = ``(GFA office) × _EFFICIENCY_BY_CLASS[class]``.
* ``apartments`` = ``floor(residential / _AVG_APARTMENT_SQM[class])``.
* ``parking`` = ``ceil(apartments × _PARKING_PER_APARTMENT[class])``.
* ``density`` = ``GFA / site_area_sqm`` (FAR), защита от деления на ноль.
Чистая функция — без БД / LLM / внешних API. Те же нормативные константы, что и
остальные синтезаторы (single source of truth, без новых магических чисел).
Args:
total_footprint_sqm: суммарное пятно застройки всех секций, кв.м (скаляр).
floors: этажность (общая для программы); вне диапазона → клампится к >= 0.
site_area_sqm: площадь участка для плотности (FAR).
housing_class: класс жилья — задаёт эффективность/средний лот/парковку/нежилое.
sections: число секций — метаданные программы; на ТЭП НЕ влияет (площади уже
свёрнуты во ``total_footprint_sqm``). Принимается для симметрии с контрактом
фронта и будущей пер-секционной логики; здесь намеренно не используется.
"""
del sections # метаданные программы; площади уже агрегированы — на ТЭП не влияют
built_area = max(0.0, total_footprint_sqm)
total_floor_area = built_area * max(0, floors)
# Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA ДО расчёта жилой — точно как
# compute_teap / synthesize_teap_from_buildability (single source of truth по долям).
office_share = _OFFICE_SHARE_OF_GFA[housing_class]
office_area = total_floor_area * office_share
residential_gfa = total_floor_area - office_area
efficiency = _EFFICIENCY_BY_CLASS[housing_class]
residential_area = residential_gfa * efficiency
avg_apartment = _AVG_APARTMENT_SQM[housing_class]
apartments_count = math.floor(residential_area / avg_apartment) if avg_apartment else 0
density = total_floor_area / site_area_sqm if site_area_sqm > 0 else 0.0
parking_norm = _PARKING_PER_APARTMENT[housing_class]
parking_spaces = math.ceil(apartments_count * parking_norm)
return TEAP(
built_area_sqm=round(built_area, 1),
total_floor_area_sqm=round(total_floor_area, 1),
office_area_sqm=round(office_area, 1),
residential_area_sqm=round(residential_area, 1),
apartments_count=apartments_count,
density=round(density, 3),
parking_spaces=parking_spaces,
)
__all__ = ["HousingClass", "compute_teap", "synthesize_teap_from_program"]