feat(site-finder): оптимизатор программ — «Как участок сходится» при отрицательном вердикте (#2181) #2222

Merged
bot-backend merged 2 commits from feat/program-optimizer-2181 into main 2026-07-02 20:57:13 +00:00
7 changed files with 940 additions and 28 deletions

View file

@ -77,12 +77,22 @@ from app.services.site_finder.osrm_client_local import (
OsrmLocalUnavailableError,
get_road_distances_m,
)
from app.services.site_finder.parcel_financial import synthesize_parcel_financial
from app.services.site_finder.parcel_financial import (
_as_float as _fin_as_float,
)
from app.services.site_finder.parcel_financial import (
_as_int as _fin_as_int,
)
from app.services.site_finder.parcel_financial import (
select_calibrated_price,
synthesize_parcel_financial,
)
from app.services.site_finder.poi_score import (
PoiScoreResponse,
compute_poi_routing_decay,
compute_poi_weighted_top7,
)
from app.services.site_finder.program_optimizer import optimize_program
from app.services.site_finder.quarter_dump_lookup import (
get_connection_points,
get_quarter_dump_data,
@ -3533,6 +3543,40 @@ def analyze_parcel(
logger.warning("financial_estimate bridge failed for %s: %s", cad_num, e)
financial_estimate = None
# #2181: оптимизатор программы — при ОТРИЦАТЕЛЬНОМ фин-вердикте (NPV<0 или ROI<0)
# честно перебираем сетку (класс × этажность) той же compute_financial и отдаём топ-3
# варианта + флаг any_viable (есть ли ВООБЩЕ плюс на этом участке). Позитивные отчёты
# не засоряем (program_alternatives=None). Быстро (≈15 мс/12 комбо) → синхронно, без
# Celery. Тот же «пояс безопасности», что у financial_estimate: любой сбой → None,
# analyze НЕ падает. Единая откалиброванная цена во все комбо (DRY через
# select_calibrated_price) — иначе business ложно выиграет за счёт норматива 210k.
program_alternatives: dict[str, Any] | None = None
try:
_fin_negative = financial_estimate is not None and (
(_fin_as_float(financial_estimate.get("npv_rub")) or 0.0) < 0
or (_fin_as_float(financial_estimate.get("roi")) or 0.0) < 0
)
if _fin_negative:
_opt_price, _opt_price_source = select_calibrated_price(
market_price=market_price,
geo_radius_price_median=geo_radius_price.get("median"),
district_price_median=district_price_block["district_price_per_m2_median"],
)
_opt_zoning = nspd_dump_data["nspd_zoning"] or {}
program_alternatives = optimize_program(
area_m2=_fin_area_m2,
max_far=_fin_as_float(_opt_zoning.get("max_far")),
max_building_pct=_fin_as_float(_opt_zoning.get("max_building_pct")),
max_floors_allowed=_fin_as_int(_opt_zoning.get("max_floors")),
land_cost_rub=_fin_cad_value,
market_price_per_sqm=_opt_price,
price_source=_opt_price_source,
velocity_sqm_per_month=_velocity_absorption,
)
except Exception:
logger.exception("program_optimizer failed for %s", cad_num)
program_alternatives = None
# #1960: карточка «Медиана рынка» (district.median_price_per_m2) должна быть
# КОНСИСТЕНТНА с остальной страницей, построенной на NEWBUILD Objective/DOM.РФ
# basis (geo_radius_price.median, market_avg ≈197k), а не на загрязнённой
@ -3692,6 +3736,10 @@ def analyze_parcel(
# застройке зоны (синтез ТЭП из buildability). dict|None (None когда gate
# против / нет чисел зоны / нет площади). caveat внутри. См. parcel_financial.
"financial_estimate": financial_estimate,
# #2181: при ОТРИЦАТЕЛЬНОМ фин-вердикте — топ-3 варианта программы (класс ×
# этажность) по NPV + флаг any_viable (существует ли ВООБЩЕ плюс). dict|None
# (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри.
"program_alternatives": program_alternatives,
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
"weights_profile": {
"source": _weights_source,

View file

@ -87,6 +87,37 @@ def _infer_development_type(max_floors: int | None) -> str:
return "high_rise"
def select_calibrated_price(
*,
market_price: dict[str, Any] | None,
geo_radius_price_median: float | None,
district_price_median: float | None,
) -> tuple[float | None, str]:
"""Выбрать откалиброванную цену продажи жилья + её источник (single source of truth).
Приоритет от точного рынка к нормативу (совпадает с исходной логикой
:func:`synthesize_parcel_financial`, вынесен сюда для переиспользования оптимизатором
программы, чтобы выбор цены не разошёлся):
1. ``market_price.median`` квартальная MV перепродаж ``objective_district_median``.
2. ``geo_radius_price_median`` медиана новостроек Objective в гео-радиусе
``objective_geo_radius``.
3. ``district_price_median`` name-match медиана админ-района ``district_reference``.
4. ничего из рынка ``(None, "class_norm")`` caller использует норматив класса.
Returns:
``(price, price_source)`` ``price`` может быть ``None`` (тогда source
``"class_norm"``), что сигналит caller-у про fallback на норматив класса.
"""
if market_price and _as_float(market_price.get("median")) is not None:
return _as_float(market_price.get("median")), "objective_district_median"
if _as_float(geo_radius_price_median) is not None:
return _as_float(geo_radius_price_median), "objective_geo_radius"
if district_price_median is not None:
return float(district_price_median), "district_reference"
return None, "class_norm"
def synthesize_teap_from_buildability(
*,
area_m2: float | None,
@ -238,18 +269,13 @@ def synthesize_parcel_financial(
# ── Цена продажи: quarter-MV → гео-радиус → district median (рыночный прокси) ─
# Приоритет от точного рынка к нормативу. Гео-радиус вставлен МЕЖДУ квартальной MV
# и district_reference: даёт реальную цену 5 не-name-match районам ЕКБ.
price: float | None = None
price_source = "class_norm"
if market_price and _as_float(market_price.get("median")) is not None:
price = _as_float(market_price.get("median"))
price_source = "objective_district_median"
elif _as_float(geo_radius_price_median) is not None:
price = _as_float(geo_radius_price_median)
price_source = "objective_geo_radius"
elif district_price_median is not None:
price = float(district_price_median)
price_source = "district_reference"
# и district_reference: даёт реальную цену 5 не-name-match районам ЕКБ. Логика
# вынесена в общий helper (single-source-of-truth с program_optimizer).
price, price_source = select_calibrated_price(
market_price=market_price,
geo_radius_price_median=geo_radius_price_median,
district_price_median=district_price_median,
)
housing_class = _infer_housing_class(price)
development_type = _infer_development_type(max_floors)
@ -331,6 +357,7 @@ def _as_int(value: Any) -> int | None:
__all__ = [
"select_calibrated_price",
"synthesize_parcel_financial",
"synthesize_teap_from_buildability",
]

View file

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

View file

@ -0,0 +1,236 @@
"""Unit-тесты оптимизатора программы застройки (#2181).
Тестируем чистую функцию program_optimizer.optimize_program (без БД, без app.main):
ранжир вариантов по NPV убыв. (тай-брейк ROI);
any_viable=False когда все комбо в минусе и всё равно топ-N «наименее убыточных»;
кламп floors_grid по max_floors_allowed (10 [5, 9]);
ЕДИНАЯ откалиброванная цена во все комбо (price_per_sqm_used одинакова);
сбой одного комбо не валит грид;
полный вызов быстрый (грубый порог 1 с защита от деградации на порядки).
Детерминированно, без LLM / БД.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import time
from unittest.mock import patch
from app.services.site_finder.program_optimizer import (
_build_floors_grid,
optimize_program,
)
def _big_profitable_kwargs() -> dict:
"""Крупный участок + высокая цена → как минимум часть комбо прибыльна."""
return {
"area_m2": 12_000.0,
"max_far": 3.0,
"max_building_pct": 40.0,
"max_floors_allowed": 25,
"land_cost_rub": 50_000_000.0,
"market_price_per_sqm": 180_000.0,
"price_source": "objective_geo_radius",
"velocity_sqm_per_month": 900.0,
}
# ── floors-grid кламп ─────────────────────────────────────────────────────────
def test_floors_grid_clamped_to_max_allowed() -> None:
"""max_floors_allowed=10 → [5, 9] (14 и 25 отсечены)."""
assert _build_floors_grid(10) == [5, 9]
def test_floors_grid_none_returns_full_set() -> None:
"""Предел неизвестен → весь опорный набор."""
assert _build_floors_grid(None) == [5, 9, 14, 25]
def test_floors_grid_below_min_falls_back_to_limit() -> None:
"""Предел ниже минимального опорного (3) → сам предел единственной этажностью."""
assert _build_floors_grid(3) == [3]
def test_optimize_respects_floors_clamp() -> None:
"""max_floors_allowed=10 → в вариантах только этажи из [5, 9]."""
kwargs = _big_profitable_kwargs()
kwargs["max_floors_allowed"] = 10
result = optimize_program(**kwargs)
assert result is not None
floors_seen = {v["floors"] for v in result["variants"]}
assert floors_seen <= {5, 9}
# 3 класса × 2 этажа = 6 комбо всего в гриде.
assert result["grid_size"] == 6
# ── ранжир по NPV ─────────────────────────────────────────────────────────────
def test_variants_sorted_by_npv_desc() -> None:
"""Топ-N отсортирован по npv_rub убыв."""
result = optimize_program(**_big_profitable_kwargs())
assert result is not None
npvs = [v["npv_rub"] for v in result["variants"]]
assert npvs == sorted(npvs, reverse=True)
assert len(result["variants"]) == 3 # top_n=3 по умолчанию
def test_profitable_site_any_viable_true() -> None:
"""Крупный участок + высокая цена → хотя бы один вариант в плюсе."""
result = optimize_program(**_big_profitable_kwargs())
assert result is not None
assert result["any_viable"] is True
assert any(v["npv_rub"] > 0 for v in result["variants"])
# ── all-negative: any_viable=False, но топ-N всё равно есть ────────────────────
def test_all_negative_any_viable_false_still_returns_top_n() -> None:
"""Крошечный участок + мизерная цена → все в минусе, any_viable=False, топ-3 есть."""
result = optimize_program(
area_m2=600.0,
max_far=1.5,
max_building_pct=30.0,
max_floors_allowed=9,
land_cost_rub=200_000_000.0, # неадекватно дорогая земля топит все комбо
market_price_per_sqm=40_000.0, # мизерная цена
price_source="objective_geo_radius",
velocity_sqm_per_month=100.0,
)
assert result is not None
assert result["any_viable"] is False
assert all(v["npv_rub"] <= 0 for v in result["variants"])
# «Наименее убыточные» — всё равно топ-3 и отсортированы по NPV убыв.
assert len(result["variants"]) == 3
npvs = [v["npv_rub"] for v in result["variants"]]
assert npvs == sorted(npvs, reverse=True)
# ── ЕДИНАЯ цена во все комбо ───────────────────────────────────────────────────
def test_fixed_price_across_all_combos() -> None:
"""market_price_per_sqm един во всех комбо: price_per_sqm_used одинакова везде."""
kwargs = _big_profitable_kwargs()
kwargs["market_price_per_sqm"] = 165_000.0
result = optimize_program(**kwargs)
assert result is not None
prices = {v["price_per_sqm_used"] for v in result["variants"]}
assert prices == {165_000.0}
sources = {v["price_source"] for v in result["variants"]}
assert sources == {"objective_geo_radius"}
def test_no_market_price_uses_class_norm() -> None:
"""Рынка нет → каждый комбо на нормативе своего класса (цены РАЗНЫЕ по классам)."""
kwargs = _big_profitable_kwargs()
kwargs["market_price_per_sqm"] = None
kwargs["price_source"] = "class_norm"
result = optimize_program(**kwargs)
assert result is not None
# Норматив зависит от класса → в гриде минимум 2 разных цены (эконом 110k vs бизнес 210k).
all_prices = {v["price_per_sqm_used"] for v in result["variants"]}
assert all(v["price_source"] == "class_norm" for v in result["variants"])
assert len(all_prices) >= 1 # top-3 может оказаться одним классом; проверяем норму ниже
for v in result["variants"]:
assert v["price_per_sqm_used"] in (110_000.0, 145_000.0, 210_000.0)
# ── устойчивость: сбой одного комбо не валит грид ─────────────────────────────
def test_one_combo_failure_does_not_break_grid() -> None:
"""compute_financial кидает на 'business' → остальные комбо считаются, грид не падает."""
real_compute = None
from app.services.site_finder import program_optimizer as mod
real_compute = mod.compute_financial
def _flaky(*args, **kwargs): # type: ignore[no-untyped-def]
if kwargs.get("housing_class") == "business":
raise ValueError("boom")
return real_compute(*args, **kwargs)
with patch.object(mod, "compute_financial", side_effect=_flaky):
result = optimize_program(**_big_profitable_kwargs())
assert result is not None
# business (× 4 этажа) выпал → в гриде остались только econom + comfort.
assert all(v["housing_class"] != "business" for v in result["variants"])
assert result["grid_size"] > 0 # грид выжил
def test_no_area_returns_none() -> None:
"""Нет площади → None (нечего считать)."""
kwargs = _big_profitable_kwargs()
kwargs["area_m2"] = None
assert optimize_program(**kwargs) is None
def test_no_zoning_numbers_returns_none() -> None:
"""Ни far, ни (pct+floors) → ни один комбо не даёт ТЭП → None."""
result = optimize_program(
area_m2=8000.0,
max_far=None,
max_building_pct=None,
max_floors_allowed=None,
land_cost_rub=0.0,
market_price_per_sqm=150_000.0,
price_source="objective_geo_radius",
velocity_sqm_per_month=None,
)
assert result is None
# ── caveat / метаданные ───────────────────────────────────────────────────────
def test_result_carries_caveat_and_metadata() -> None:
"""Результат несёт honest-caveat + grid_size + структуру каждого варианта."""
result = optimize_program(**_big_profitable_kwargs())
assert result is not None
assert isinstance(result["caveat"], str) and result["caveat"]
assert result["grid_size"] == 12 # 3 класса × 4 этажа
v = result["variants"][0]
for key in (
"housing_class",
"floors",
"development_type",
"npv_rub",
"irr",
"roi",
"net_profit_rub",
"residential_sqm",
"apartments_count",
"price_per_sqm_used",
"price_source",
"schedule_is_default",
):
assert key in v
# ── производительность ────────────────────────────────────────────────────────
def test_full_grid_fast_enough_for_sync_request() -> None:
"""Полный грид достаточно быстр для синхронного вызова в теле запроса.
Локально 12 комбо 15 мс; порог 1000 мс намеренно грубый ловим
деградацию НА ПОРЯДКИ (случайный I/O, БД, квадратичный взрыв), а не
миллисекунды: на нагруженном shared CI-раннере тот же вызов занимал
199 мс и валил жёсткий порог 100 мс (flaky, run 3917).
"""
kwargs = _big_profitable_kwargs()
start = time.perf_counter()
result = optimize_program(**kwargs)
elapsed_ms = (time.perf_counter() - start) * 1000.0
assert result is not None
assert elapsed_ms < 1000.0, f"optimize_program took {elapsed_ms:.1f} ms (>1000 ms)"

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
@ -14,6 +14,7 @@ import { Section4Estimate } from "@/components/site-finder/analysis/Section4Esti
import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere";
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives";
import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
@ -169,7 +170,11 @@ export function AnalysisPageContent({ cad }: Props) {
alignSelf: "start",
}}
>
<AnalysisSidebarNav />
<AnalysisSidebarNav
hasAlternatives={
(analysis.program_alternatives?.variants?.length ?? 0) > 0
}
/>
</aside>
{/* ── Main scroll area ──────────────────────────────────────────── */}
@ -222,6 +227,12 @@ export function AnalysisPageContent({ cad }: Props) {
Seeded из анализа (geom + financial_estimate), считается по
запросу; result lazy-mounted (Leaflet). */}
<Section7Concept analysis={analysis} />
{/* «Как участок сходится» (#2181) авто-ранжир вариантов программы
(класс × этажность) по NPV при отрицательном фин-вердикте. Backend
кладёт program_alternatives только тогда; иначе блок не рендерится.
Не дубль §7: без карт/3D, статичный топ-3. */}
<SectionAlternatives alternatives={analysis.program_alternatives} />
</div>
</div>
@ -406,12 +417,23 @@ const NAV_GROUPS: NavGroup[] = [
},
];
// Top-level section anchors in scroll order — observed by the scrollspy.
// Children (3.x / 6.x) are intentionally NOT observed: their parent stays lit
// while the user is anywhere inside the section, which reads cleaner.
const TOP_LEVEL_IDS: string[] = NAV_GROUPS.flatMap((g) =>
g.items.map((i) => i.id),
);
// «Альтернативы» (#2181) — навигационный пункт для блока «Как участок сходится».
// Добавляется в конец группы «Стройка и рынок» ТОЛЬКО когда backend вернул
// program_alternatives (иначе якоря section-alternatives на странице нет).
const ALTERNATIVES_NAV_ITEM: NavItem = {
id: "section-alternatives",
label: "Альтернативы программы",
};
/** NAV_GROUPS с опциональным пунктом «Альтернативы» в хвосте (#2181). */
function buildNavGroups(hasAlternatives: boolean): NavGroup[] {
if (!hasAlternatives) return NAV_GROUPS;
return NAV_GROUPS.map((g) =>
g.label === "Стройка и рынок"
? { ...g, items: [...g.items, ALTERNATIVES_NAV_ITEM] }
: g,
);
}
// Sticky chrome offset (header + breadcrumb). The jump lands the section title
// just below this; the scrollspy uses the same line to pick the active section.
@ -429,12 +451,30 @@ function scrollToSection(id: string) {
window.scrollTo({ top, behavior: "auto" });
}
function AnalysisSidebarNav() {
function AnalysisSidebarNav({
hasAlternatives = false,
}: {
hasAlternatives?: boolean;
}) {
const navGroups = useMemo(
() => buildNavGroups(hasAlternatives),
[hasAlternatives],
);
// Top-level section anchors in scroll order — observed by the scrollspy.
// Children (3.x / 6.x) are intentionally NOT observed: their parent stays lit
// while the user is anywhere inside the section, which reads cleaner. Rebuilt
// only when «Альтернативы» (#2181) appears/disappears so the effect's dep is
// stable (not a fresh array each render).
const topLevelIds = useMemo(
() => navGroups.flatMap((g) => g.items.map((i) => i.id)),
[navGroups],
);
// Scrollspy: the active section is the LAST one whose top has scrolled past the
// offset line (deterministic, never "empty" — unlike a narrow IntersectionObserver
// band, which left the last/at-bottom section un-lit). Default to the first so
// something is lit before any scroll.
const [activeId, setActiveId] = useState<string>(TOP_LEVEL_IDS[0]);
const [activeId, setActiveId] = useState<string>(topLevelIds[0]);
useEffect(() => {
if (typeof window === "undefined") return;
@ -448,12 +488,12 @@ function AnalysisSidebarNav() {
window.innerHeight + window.scrollY >=
document.documentElement.scrollHeight - 2;
if (atBottom) {
setActiveId(TOP_LEVEL_IDS[TOP_LEVEL_IDS.length - 1]);
setActiveId(topLevelIds[topLevelIds.length - 1]);
return;
}
// Otherwise: the last section whose top has passed the offset line.
let current = TOP_LEVEL_IDS[0];
for (const id of TOP_LEVEL_IDS) {
let current = topLevelIds[0];
for (const id of topLevelIds) {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top - SCROLL_OFFSET <= 1) {
current = id;
@ -474,7 +514,7 @@ function AnalysisSidebarNav() {
window.removeEventListener("scroll", onScroll);
if (raf) window.cancelAnimationFrame(raf);
};
}, []);
}, [topLevelIds]);
return (
<nav
@ -488,7 +528,7 @@ function AnalysisSidebarNav() {
flexDirection: "column",
}}
>
{NAV_GROUPS.map((group, gi) => (
{navGroups.map((group, gi) => (
<div key={group.label}>
<div
style={{

View file

@ -0,0 +1,285 @@
"use client";
/**
* SectionAlternatives «Как участок сходится» (#2181).
*
* Компактный автоматический ранжир вариантов программы застройки
* (класс × этажность) по NPV. Backend кладёт `program_alternatives` в ответ
* /analyze ТОЛЬКО при отрицательном базовом фин-вердикте (program_optimizer.py);
* на позитивных участках поле null/отсутствует и блок НЕ рендерится вовсе.
*
* Это НЕ дубль §7 «Концепция»: там ручная интерактивная концепция с картой/3D,
* здесь статичный топ-3 без геометрии, отвечающий на вопрос «а вообще эта
* земля сходится при какой-нибудь программе?». Оценка по МАКСИМАЛЬНОЙ застройке
* зоны (синтез ТЭП из предельных параметров), НЕ инвест-рекомендация тон
* advisory, честный caveat из ответа показываем полным предложением.
*/
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { Badge } from "@/components/ui/Badge";
import {
DEVELOPMENT_TYPE_LABELS,
HOUSING_CLASS_LABELS,
type DevelopmentType,
type HousingClass,
} from "@/lib/concept-api";
import type { ProgramAlternatives, ProgramVariant } from "@/types/site-finder";
const SCROLL_MARGIN = 72;
const TOP_N = 3;
// ── Formatters (ru microcopy, Unicode-минус) ──────────────────────────────────
const MINUS = ""; // U+2212 — типографский минус, не hyphen-minus.
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
/**
* Деньги округлением до млн со знаком: «+42 млн » / «216 млн ». Ниже 1 млн
* точная сумма (редко, но честно). Знак «+» показываем только для положительных.
*/
function formatMoneySigned(rub: number): string {
const abs = Math.abs(rub);
const sign = rub > 0 ? "+" : rub < 0 ? MINUS : "";
if (abs >= 1e9) return `${sign}${(abs / 1e9).toFixed(1)} млрд ₽`;
if (abs >= 1e6) return `${sign}${Math.round(abs / 1e6)} млн ₽`;
return `${sign}${nf.format(Math.round(abs))}`;
}
/** ₽/м² с пробелами-разрядами: «142 500 ₽/м²». */
function formatPricePerSqm(rub: number): string {
return `${nf.format(Math.round(rub))} ₽/м²`;
}
/** Проценты доли: 0.184 → «18.4%». Отрицательные — с Unicode-минусом. */
function formatPct(fraction: number): string {
const v = (fraction * 100).toFixed(1);
return v.startsWith("-") ? `${MINUS}${v.slice(1)}%` : `${v}%`;
}
function formatInt(n: number): string {
return nf.format(Math.round(n));
}
// ── Narrowing (backend отдаёт класс/тип/источник строкой, extra=allow) ─────────
const HOUSING_CLASSES: readonly HousingClass[] = [
"econom",
"comfort",
"business",
];
const DEVELOPMENT_TYPES: readonly DevelopmentType[] = [
"spot",
"mid_rise",
"high_rise",
];
function housingClassLabel(value: string): string {
return (HOUSING_CLASSES as readonly string[]).includes(value)
? HOUSING_CLASS_LABELS[value as HousingClass]
: value;
}
function developmentTypeLabel(value: string): string {
return (DEVELOPMENT_TYPES as readonly string[]).includes(value)
? DEVELOPMENT_TYPE_LABELS[value as DevelopmentType]
: value;
}
/**
* Честная подпись источника цены (тот же набор, что priceSourceCaption в
* concept-api + objective_geo_radius из parcel_financial). Вариант несёт
* price_source строкой, а не FinancialModel-объект, поэтому берём каноничные
* фразы по значению норматив класса НИКОГДА не выдаётся за рыночную цену,
* и наоборот: рыночный источник не должен падать в норматив-фразу (инверсия
* честности; неизвестный источник показываем как есть, не как норматив).
*/
function priceSourceLabel(source: string): string {
switch (source) {
case "objective_district_median":
return "рынок: медиана объявлений Objective по району";
case "objective_geo_radius":
return "рынок: медиана новостроек в радиусе 3 км";
case "district_reference":
return "рынок: справочная медиана района";
case "class_norm":
return "норматив класса (нет рыночных данных по участку)";
default:
return source;
}
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
/** program_alternatives из ответа /analyze (null/пусто → блок не рендерится). */
alternatives?: ProgramAlternatives | null;
}
// ── Variant card ──────────────────────────────────────────────────────────────
function VariantCard({
variant,
isBest,
}: {
variant: ProgramVariant;
isBest: boolean;
}) {
const npvPositive = variant.npv_rub > 0;
const npvColor = npvPositive
? "var(--success)"
: variant.npv_rub < 0
? "var(--danger)"
: "var(--fg-primary)";
const title = `${housingClassLabel(variant.housing_class)} · ${variant.floors} эт · ${developmentTypeLabel(variant.development_type)}`;
return (
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "16px 18px",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{/* Титул + бейдж «лучший» */}
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--fg-primary)",
lineHeight: 1.35,
}}
>
{title}
</div>
{isBest && (
<Badge variant={npvPositive ? "success" : "neutral"} size="sm">
лучший
</Badge>
)}
</div>
{/* NPV — крупно */}
<div>
<div
style={{
fontSize: 12,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
}}
>
NPV
</div>
<div
style={{
marginTop: 2,
fontSize: 28,
fontWeight: 600,
color: npvColor,
fontVariantNumeric: "tabular-nums",
}}
>
{formatMoneySigned(variant.npv_rub)}
</div>
</div>
{/* ROI / IRR строкой */}
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
ROI {formatPct(variant.roi)} · IRR {formatPct(variant.irr)}
</div>
{/* Жилая S · квартиры */}
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
Жилая {formatInt(variant.residential_sqm)} м² ·{" "}
{formatInt(variant.apartments_count)} квартир
</div>
{/* Цена м² + источник */}
<div style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
Цена {formatPricePerSqm(variant.price_per_sqm_used)} (
{priceSourceLabel(variant.price_source)})
</div>
</div>
);
}
// ── SectionAlternatives ───────────────────────────────────────────────────────
export function SectionAlternatives({ alternatives }: Props) {
// Рендер-guard: нет поля / пустой список → блок не рендерится вовсе.
const variants = alternatives?.variants ?? [];
if (!alternatives || variants.length === 0) return null;
const topVariants = variants.slice(0, TOP_N);
const { any_viable, grid_size, caveat } = alternatives;
const subtitle = any_viable
? "Базовая оценка отрицательна, но участок сходится при другой программе — топ вариантов по NPV:"
: `При текущих вводных ни один из ${grid_size} вариантов программы (класс × этажность) не выходит в плюс. Ниже — наименее убыточные:`;
// «Лучший» = вариант с максимальным NPV. Список уже отсортирован по NPV убыв.
// на backend, но берём индекс явно (устойчиво к любому порядку прихода).
let bestIndex = 0;
for (let i = 1; i < topVariants.length; i++) {
if (topVariants[i].npv_rub > topVariants[bestIndex].npv_rub) bestIndex = i;
}
// Honest-caveat одним предложением: caveat ответа + допущение по графику, если
// хоть один показанный вариант считался по типовому графику строительства.
const scheduleIsDefault = topVariants.some((v) => v.schedule_is_default);
const caveatText = scheduleIsDefault
? `${caveat} График строительства — типовое допущение, не график проекта.`
: caveat;
return (
<section
id="section-alternatives"
style={{ scrollMarginTop: SCROLL_MARGIN }}
>
<HeadlineBar title="Как участок сходится" subtitle={subtitle} />
{/* Карточки топ-3 — auto-fit грид как KPI-ряды. */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
marginTop: 12,
}}
>
{topVariants.map((variant, i) => (
<VariantCard
key={`${variant.housing_class}-${variant.floors}-${variant.development_type}`}
variant={variant}
isBest={i === bestIndex}
/>
))}
</div>
{/* Один честный caveat — полным предложением, --fg-tertiary. */}
<p
style={{
margin: "12px 0 0",
fontSize: 12,
lineHeight: "18px",
color: "var(--fg-tertiary)",
}}
>
{caveatText}
</p>
</section>
);
}

View file

@ -546,6 +546,53 @@ export interface ParcelAnalysis {
// Эпик #1881 PR-4 — финмодель из /analyze (extra=allow на backend, hand-typed
// здесь). null когда нельзя строить МКД / нет зонинга / нет площади.
financial_estimate?: ParcelFinancialEstimate | null;
// #2181 — топ-N вариантов программы (класс × этажность) по NPV. Backend кладёт
// это поле ТОЛЬКО при отрицательном фин-вердикте (program_optimizer.py);
// на позитивных участках null / отсутствует. Hand-typed (extra=allow на backend).
program_alternatives?: ProgramAlternatives | null;
}
/**
* Один вариант программы застройки (класс × этажность), посчитанный
* program_optimizer.optimize_program (#2181) через ту же compute_financial, что
* и основной financial_estimate. Класс/тип застройки приходят строкой (backend
* extra=allow) на UI сужаются к HOUSING_CLASS_LABELS / DEVELOPMENT_TYPE_LABELS.
*/
export interface ProgramVariant {
// "econom" | "comfort" | "business" (Literal HousingClass на backend).
housing_class: string;
floors: number;
// "spot" | "mid_rise" | "high_rise" (по этажности комбо).
development_type: string;
// NPV дисконтированного cashflow, ₽ (ключ ранжирования; может быть отрицательным).
npv_rub: number;
irr: number;
roi: number;
net_profit_rub: number;
residential_sqm: number;
apartments_count: number;
// Цена продажи жилья, ₽/м², фактически использованная в выручке комбо.
price_per_sqm_used: number;
// "objective_district_median" | "district_reference" | "class_norm" — тот же
// honest-набор источников, что у financial_estimate.price_source.
price_source: string;
// true — график фаз/продаж типовой (допущение), не график проекта.
schedule_is_default: boolean;
}
/**
* Блок «Как участок сходится» (#2181) ранжир вариантов программы. Присутствует
* только при отрицательном базовом фин-вердикте; иначе null / отсутствует.
*/
export interface ProgramAlternatives {
// Топ-N вариантов по NPV убыв. (тай-брейк ROI). При all-negative — «наименее убыточные».
variants: ProgramVariant[];
// true — хотя бы один вариант с NPV > 0 (участок сходится при другой программе).
any_viable: boolean;
// Сколько комбо (класс × этажность) реально посчиталось в переборе.
grid_size: number;
// HEAVY caveat — тот же, что у основной финмодели (оценка по МАКС. застройке зоны).
caveat: string;
}
/**