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