229 lines
12 KiB
Python
229 lines
12 KiB
Python
"""Оптимизатор программы застройки — топ-N вариантов (класс × этажность) по NPV (#2181).
|
||
|
||
Когда финмодель участка (``financial_estimate`` в ``/analyze``) даёт ОТРИЦАТЕЛЬНЫЙ
|
||
вердикт (NPV < 0 или ROI < 0), девелоперу мало «не выгодно» — важно честно показать,
|
||
существует ли ВООБЩЕ прибыльная раскладка на этом участке при других параметрах
|
||
(класс жилья + этажность), и если нет — какие варианты «наименее убыточные».
|
||
|
||
Этот модуль перебирает небольшую сетку (класс × этажность), прогоняет каждый комбо
|
||
через ту же :func:`compute_financial`, что и основная финмодель, и возвращает топ-N по
|
||
NPV. Всё детерминированно и **быстро** (≈15 мс на 12 комбо → считается СИНХРОННО в теле
|
||
запроса, без Celery), и **чисто** — без БД / LLM / внешних API. Данные участка
|
||
(площадь, предельные параметры зоны, откалиброванная цена, стоимость земли, абсорбция)
|
||
прокидывает caller — ровно те же входы, что использованы для основного
|
||
``financial_estimate`` (см. ``synthesize_parcel_financial``).
|
||
|
||
КРИТИЧНО — ЕДИНАЯ ЦЕНА ВО ВСЕХ КОМБО
|
||
------------------------------------
|
||
Когда рыночная цена известна, ОДНА И ТА ЖЕ откалиброванная ``market_price_per_sqm`` идёт
|
||
во все комбо (класс двигает ТОЛЬКО себестоимость/эффективность/паркинг/ср.лот, но НЕ
|
||
цену продажи). Иначе «business» ложно выиграл бы за счёт норматива продажи 210k против
|
||
110k у эконома — сравнивали бы цены, а не экономику застройки. Норматив класса
|
||
(``_SALE_PRICE_PER_SQM``) применяется ТОЛЬКО когда рынка нет — то же поведение, что в
|
||
:func:`synthesize_parcel_financial`.
|
||
|
||
HONEST CAVEAT
|
||
-------------
|
||
Как и основная финмодель — это оценка по МАКСИМАЛЬНОЙ застройке зоны (синтез ТЭП из
|
||
предельных параметров градрегламента), а НЕ по реальной проектной раскладке. Caveat
|
||
(``_FINANCIAL_CAVEAT``) кладётся в результат и обязан показываться в UI/PDF.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any, get_args
|
||
|
||
from app.services.generative.financial import HousingClass, compute_financial
|
||
from app.services.site_finder.parcel_financial import (
|
||
_FINANCIAL_CAVEAT,
|
||
_infer_development_type,
|
||
synthesize_teap_from_buildability,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Классы жилья для перебора — весь набор (econom / comfort / business). Берём из
|
||
# Literal-типа HousingClass, чтобы сетка не разошлась с финмоделью при добавлении класса.
|
||
_CLASSES_GRID: tuple[HousingClass, ...] = get_args(HousingClass)
|
||
|
||
# Опорная сетка этажности (кламп по max_floors_allowed зоны). Значения — типовые
|
||
# высотные пороги РФ (5 — среднеэтажка, 9 — «хрущёвочный» потолок, 14 — высотка,
|
||
# 25 — башня); двигают GFA и development_type (порог spot/mid_rise/high_rise в
|
||
# _infer_development_type → длительность СМР в DCF-графике).
|
||
_FLOORS_GRID: tuple[int, ...] = (5, 9, 14, 25)
|
||
|
||
_DEFAULT_TOP_N: int = 3
|
||
|
||
|
||
def _build_floors_grid(max_floors_allowed: int | None) -> list[int]:
|
||
"""Сетка этажности, клампнутая по предельной этажности зоны.
|
||
|
||
* ``max_floors_allowed`` не задан (``None``) → весь опорный набор (зона неизвестна,
|
||
перебираем всё).
|
||
* задан → оставляем только этажи ``<= max_floors_allowed``.
|
||
* после клампа пусто (предел ниже минимального опорного, напр. 3) → берём сам
|
||
``max_floors_allowed`` (единственная физически допустимая этажность).
|
||
"""
|
||
if max_floors_allowed is None:
|
||
return list(_FLOORS_GRID)
|
||
clamped = [f for f in _FLOORS_GRID if f <= max_floors_allowed]
|
||
if not clamped:
|
||
# Предел ниже минимального опорного (напр. 3 этажа) — считаем по самому пределу.
|
||
return [max_floors_allowed]
|
||
return clamped
|
||
|
||
|
||
def optimize_program(
|
||
*,
|
||
area_m2: float | None,
|
||
max_far: float | None,
|
||
max_building_pct: float | None,
|
||
max_floors_allowed: int | None,
|
||
land_cost_rub: float | None,
|
||
market_price_per_sqm: float | None,
|
||
price_source: str,
|
||
velocity_sqm_per_month: float | None,
|
||
top_n: int = _DEFAULT_TOP_N,
|
||
) -> dict[str, Any] | None:
|
||
"""Перебрать сетку (класс × этажность) → топ-N вариантов программы по NPV.
|
||
|
||
Прогоняет каждый комбо через :func:`compute_financial` (та же финмодель, что и
|
||
основной ``financial_estimate``) и ранжирует по NPV убыв. (тай-брейк — ROI убыв.).
|
||
|
||
Args:
|
||
area_m2: площадь участка, кв.м. ``None``/<=0 → ``None`` (нечего считать).
|
||
max_far: КСИТ/max_far зоны (руб/кв.м GFA). Задаёт GFA при синтезе ТЭП. Если
|
||
``None``, GFA выводится из ``max_building_pct × floors`` (по каждому комбо
|
||
своя этажность из сетки).
|
||
max_building_pct: % застройки зоны. Нужен вместе с этажностью, если нет ``max_far``.
|
||
max_floors_allowed: предельная этажность зоны — кламп сетки этажности. ``None`` →
|
||
весь опорный набор.
|
||
land_cost_rub: стоимость участка (обычно кадастровая) → land_cost. ``None`` → 0.
|
||
market_price_per_sqm: ЕДИНАЯ откалиброванная рыночная цена продажи жилья во ВСЕ
|
||
комбо. ``None`` → каждый комбо использует норматив своего класса
|
||
(``_SALE_PRICE_PER_SQM``) — то же поведение, что в основной финмодели.
|
||
price_source: honest-метка источника цены (из ``select_calibrated_price``).
|
||
Игнорируется финмоделью, если ``market_price_per_sqm is None`` (форсится
|
||
``class_norm``).
|
||
velocity_sqm_per_month: темп абсорбции района (м²/мес) — окно продаж DCF. ``None``
|
||
→ дефолт-30-мес график (``schedule_is_default``).
|
||
top_n: сколько лучших вариантов вернуть (по умолчанию 3).
|
||
|
||
Returns:
|
||
``dict`` с ключами ``variants`` (список топ-N), ``any_viable`` (хоть один вариант
|
||
с NPV > 0), ``grid_size`` (сколько комбо реально посчиталось), ``caveat``. Либо
|
||
``None``, если считать нельзя (нет площади / ни один комбо не дал ТЭП).
|
||
"""
|
||
if area_m2 is None or area_m2 <= 0:
|
||
return None
|
||
|
||
floors_grid = _build_floors_grid(max_floors_allowed)
|
||
|
||
variants: list[dict[str, Any]] = []
|
||
for housing_class in _CLASSES_GRID:
|
||
for floors in floors_grid:
|
||
variant = _evaluate_combo(
|
||
housing_class=housing_class,
|
||
floors=floors,
|
||
area_m2=area_m2,
|
||
max_far=max_far,
|
||
max_building_pct=max_building_pct,
|
||
land_cost_rub=land_cost_rub,
|
||
market_price_per_sqm=market_price_per_sqm,
|
||
price_source=price_source,
|
||
velocity_sqm_per_month=velocity_sqm_per_month,
|
||
)
|
||
if variant is not None:
|
||
variants.append(variant)
|
||
|
||
if not variants:
|
||
# Ни один комбо не дал ТЭП (нет ни far, ни pct+floors) — считать нечего.
|
||
return None
|
||
|
||
# Ранжир по NPV убыв.; тай-брейк — ROI убыв. (при равном NPV предпочитаем
|
||
# эффективность капитала). При all-negative топ-N — «наименее убыточные».
|
||
variants.sort(key=lambda v: (v["npv_rub"], v["roi"]), reverse=True)
|
||
|
||
any_viable = any(v["npv_rub"] > 0 for v in variants)
|
||
|
||
return {
|
||
"variants": variants[:top_n],
|
||
"any_viable": any_viable,
|
||
"grid_size": len(variants),
|
||
# HEAVY caveat — тот же, что у основной финмодели (оценка по МАКС. застройке
|
||
# зоны, НЕ по реальной раскладке). Обязан показываться в UI/PDF.
|
||
"caveat": _FINANCIAL_CAVEAT,
|
||
}
|
||
|
||
|
||
def _evaluate_combo(
|
||
*,
|
||
housing_class: HousingClass,
|
||
floors: int,
|
||
area_m2: float,
|
||
max_far: float | None,
|
||
max_building_pct: float | None,
|
||
land_cost_rub: float | None,
|
||
market_price_per_sqm: float | None,
|
||
price_source: str,
|
||
velocity_sqm_per_month: float | None,
|
||
) -> dict[str, Any] | None:
|
||
"""Посчитать один комбо (класс × этажность) → компактный вариант программы.
|
||
|
||
Сбой одного комбо (например, ТЭП не вывелся) НЕ валит весь грид — логируется и
|
||
возвращается ``None`` (caller скипает). Это тот же «пояс безопасности», что у
|
||
``financial_estimate`` в analyze-эндпоинте.
|
||
"""
|
||
try:
|
||
# development_type по ЭТАЖНОСТИ комбо (не по зоне) — этаж двигает график СМР в DCF.
|
||
development_type = _infer_development_type(floors)
|
||
|
||
teap = synthesize_teap_from_buildability(
|
||
area_m2=area_m2,
|
||
max_far=max_far,
|
||
max_building_pct=max_building_pct,
|
||
max_floors=floors,
|
||
housing_class=housing_class,
|
||
)
|
||
if teap is None:
|
||
return None
|
||
|
||
fin = compute_financial(
|
||
teap=teap,
|
||
housing_class=housing_class,
|
||
land_cost_rub=land_cost_rub,
|
||
# ЕДИНАЯ цена во все комбо (когда рынок есть) — класс НЕ двигает цену продажи.
|
||
market_price_per_sqm=market_price_per_sqm,
|
||
price_source=price_source,
|
||
development_type=development_type,
|
||
market_velocity_sqm_per_month=velocity_sqm_per_month,
|
||
)
|
||
|
||
return {
|
||
"housing_class": housing_class,
|
||
"floors": floors,
|
||
"development_type": development_type,
|
||
"npv_rub": fin.npv_rub,
|
||
"irr": fin.irr,
|
||
"roi": fin.roi,
|
||
"net_profit_rub": fin.net_profit_rub,
|
||
"residential_sqm": teap.residential_area_sqm,
|
||
"apartments_count": teap.apartments_count,
|
||
# honest-поля источника/графика цены — те же флаги, что в основной финмодели.
|
||
"price_per_sqm_used": fin.price_per_sqm_used,
|
||
"price_source": fin.price_source,
|
||
"schedule_is_default": fin.schedule_is_default,
|
||
}
|
||
except Exception:
|
||
# Один сбойный комбо не валит грид — логируем и скипаем (не глотаем молча).
|
||
logger.exception(
|
||
"program_optimizer combo failed: class=%s floors=%d area=%.0f",
|
||
housing_class,
|
||
floors,
|
||
area_m2,
|
||
)
|
||
return None
|
||
|
||
|
||
__all__ = ["optimize_program"]
|