gendesign/backend/app/services/generative/teap.py
Light1YT df1299175d feat(financial): model ground-floor нежилое (office/commercial) tranche
Carve a documented office share out of GFA before residential (fixes prior
overstatement where all non-parking GFA = residential). total_floor_area
unchanged → СМР already covers office GFA (no double-count): office is pure
additive revenue at +15% premium. Offices нежилое → VAT-able: vat base =
parking_value_added + office_value_added (consistent with #1898). Σ invariant
holds. Additive schema (office_area_sqm, revenue_office_rub) → api-types regen.

Refs #1881
2026-06-25 16:16:30 +05:00

139 lines
7.7 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
__all__ = ["HousingClass", "compute_teap"]