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).
211 lines
12 KiB
Python
211 lines
12 KiB
Python
"""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) типично 3–8% для городских МКД (1-й этаж из
|
||
# 9–25). Берём КОНСЕРВАТИВНЫЕ значения нижней границы диапазона:
|
||
# 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"]
|