gendesign/backend/app/services/site_finder/program_optimizer.py
Light1YT 71c7e6b5b5
Some checks failed
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m14s
CI / openapi-codegen-check (pull_request) Successful in 2m9s
CI / backend-tests (pull_request) Failing after 14m35s
feat(site-finder): оптимизатор программ — «Как участок сходится» при отрицательном вердикте (#2181)
При npv<0 или roi<0 /analyze отдаёт program_alternatives: топ-3 варианта
программы (класс × этажность), просчитанных той же compute_financial,
+ честный any_viable=false когда ни один не в плюсе.

Backend:
- новый program_optimizer.optimize_program: pure, синхронно (≈15мс на грид
  3 класса × [5,9,14,25] эт с клампом по регламенту зоны), без Celery/БД
- единая откалиброванная цена во все комбо (иначе «бизнес» ложно выигрывает
  за счёт норматива 210k); норматив класса — только когда рынка нет
- select_calibrated_price helper (DRY выбора цены, байт-в-байт прежнее
  поведение synthesize_parcel_financial)
- сбой комбо/оптимизатора не роняет analyze (пояс как у financial_estimate)

Frontend:
- SectionAlternatives «Как участок сходится» после §7: карточки топ-3
  (NPV крупно ±млн, ROI/IRR, жилая/квартиры, цена+источник), бейдж
  «лучший», honest-caveat; render-guard на позитивных участках
- objective_geo_radius в подписях источника цены (рыночный источник не
  падает в норматив-фразу — инверсия честности)
- условный пункт scrollspy «Альтернативы программы»

14 unit-тестов оптимизатора + 20 parcel_financial зелёные; tsc/eslint/vitest clean.
2026-07-03 01:13:02 +05:00

229 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.

"""Оптимизатор программы застройки — топ-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"]