"""Site Finder ↔ финмодель bridge — лёгкий ТЭП из buildability → DCF (PR-4, #1881). Участок в ``/analyze`` несёт **buildability** (площадь + предельные параметры зоны НСПД/ПЗЗ: КСИТ/max_far, % застройки, этажность) + рыночную цену + gate-вердикт, но НЕ полную проектную концепцию (нет реальной расстановки секций). Чтобы дать девелоперу ОРИЕНТИРОВОЧНУЮ финмодель прямо на карточке участка, синтезируем лёгкий ТЭП из предельных параметров зоны и прогоняем его через готовый :func:`compute_financial` (полноценный статический каскад + помесячный DCF, PR-1/2/3). Обе функции **чистые** — без БД / LLM / внешних API. Данные участка прокидывает caller (analyze-эндпоинт) уже извлечёнными. Результат — ``dict`` (``FinancialModel`` + bridge-метаданные) или ``None``, когда считать нельзя (gate против, нет чисел зоны, нет площади). ВАЖНО — HONEST CAVEAT --------------------- Это финмодель по МАКСИМАЛЬНОЙ застройке согласно градрегламенту, НЕ по реальной концепции: ТЭП синтезирован из предельных параметров зоны; класс жилья и темп продаж — рыночные/нормативные прокси; ``land_cost`` = кадастровая стоимость (не рыночная); график фаз — типовой. Caveat кладётся в результат (ключ ``caveat``) и обязан показываться в UI/PDF рядом с цифрами. """ from __future__ import annotations import logging import math from typing import Any from app.schemas.concept import TEAP from app.services.generative.financial import compute_financial # Переиспользуем нормативные константы ТЭП из Stage 1c (НЕ дублируем): эффективность # площади, средний лот, норма парковки по классу. Имена private (`_`) — импортируем # их напрямую как single-source-of-truth, чтобы синтез не разошёлся с compute_teap. from app.services.generative.teap import ( _AVG_APARTMENT_SQM, _EFFICIENCY_BY_CLASS, _OFFICE_SHARE_OF_GFA, _PARKING_PER_APARTMENT, HousingClass, ) logger = logging.getLogger(__name__) # ── HEAVY caveat — обязателен в любом результате (UI/PDF MUST показать) ───────── _FINANCIAL_CAVEAT: str = ( "ОРИЕНТИРОВОЧНАЯ финмодель по МАКСИМАЛЬНОЙ застройке согласно градрегламенту " "НСПД/ПЗЗ (КСИТ/высота/%застройки), НЕ по реальной проектной концепции. " "ТЭП синтезирован из предельных параметров зоны; класс жилья и темп продаж — " "рыночные/нормативные прокси; land_cost = кадастровая стоимость (не рыночная); " "график фаз — типовой. Доля нежилого (коммерция/офисы 1-го этажа) — нормативный " "прокси по классу (комфорт/бизнес ~5% GFA, эконом 0%), не проектный факт." ) # ── Класс жилья по цене продажи, руб/кв.м (рыночный прокси) ───────────────────── # price < 125k → эконом · < 177k → комфорт · иначе → бизнес. None цены → комфорт # (нейтральный дефолт массового сегмента ЕКБ). _HOUSING_CLASS_ECONOM_MAX: float = 125_000.0 _HOUSING_CLASS_COMFORT_MAX: float = 177_000.0 # ── Тип застройки по этажности (длительность СМР в DCF-графике) ───────────────── # <=4 → spot · 5–8 → mid_rise · >=9 → high_rise. _DEV_TYPE_SPOT_MAX_FLOORS: int = 4 _DEV_TYPE_MID_RISE_MAX_FLOORS: int = 8 def _infer_housing_class(price_per_sqm: float | None) -> HousingClass: """Класс жилья из цены продажи (рыночный прокси). None → comfort (дефолт).""" if price_per_sqm is None: return "comfort" if price_per_sqm < _HOUSING_CLASS_ECONOM_MAX: return "econom" if price_per_sqm < _HOUSING_CLASS_COMFORT_MAX: return "comfort" return "business" def _infer_development_type(max_floors: int | None) -> str: """Тип застройки из этажности зоны. None → mid_rise (нейтральный дефолт).""" if max_floors is None: return "mid_rise" if max_floors <= _DEV_TYPE_SPOT_MAX_FLOORS: return "spot" if max_floors <= _DEV_TYPE_MID_RISE_MAX_FLOORS: return "mid_rise" 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, max_far: float | None, max_building_pct: float | None, max_floors: int | None, housing_class: HousingClass, ) -> TEAP | None: """Синтезировать лёгкий :class:`TEAP` из buildability-параметров зоны. Формулы (максимальная застройка по градрегламенту): * ``GFA`` (total_floor_area) = ``area × max_far``; если ``max_far`` нет, но есть ``max_building_pct`` + ``max_floors`` → ``GFA = area × (pct/100) × floors``. * ``built`` (пятно) = ``area × (max_building_pct/100)``; если % нет → оценка ``GFA / max_floors`` (или ``GFA`` при отсутствии этажности). * ``office`` (нежилое 1-го этажа) = ``GFA × office_share[class]``, вырезается из GFA до расчёта жилой (зеркалит ``compute_teap``; см. ``_OFFICE_SHARE_OF_GFA``). * ``residential`` = ``(GFA − office) × efficiency[class]`` (вычет МОП/тех по классу). * ``apartments`` = ``floor(residential / avg_apt[class])``. * ``parking`` = ``ceil(apartments × parking_norm[class])``. * ``density`` = ``GFA / area`` (= ``max_far``). Возвращает ``None``, если нет площади ИЛИ нельзя вычислить GFA (нет ни ``max_far``, ни пары ``max_building_pct`` + ``max_floors``). """ if area_m2 is None or area_m2 <= 0: return None # ── GFA: предпочитаем КСИТ/max_far; иначе % застройки × этажность ─────────── gfa: float if max_far is not None and max_far > 0: gfa = area_m2 * max_far elif ( max_building_pct is not None and max_building_pct > 0 and max_floors is not None and max_floors > 0 ): gfa = area_m2 * (max_building_pct / 100.0) * max_floors else: # Ни КСИТ, ни (%застройки + этажность) — GFA не вывести. return None # ── Пятно застройки (built) ──────────────────────────────────────────────── if max_building_pct is not None and max_building_pct > 0: built_area = area_m2 * (max_building_pct / 100.0) elif max_floors is not None and max_floors > 0: # Нет %застройки → пятно ≈ GFA / этажность. built_area = gfa / max_floors else: built_area = gfa # Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA до расчёта жилой — точно # как compute_teap: жилая считается по ОСТАВШЕЙСЯ GFA, total (gfa) не меняется. office_share = _OFFICE_SHARE_OF_GFA[housing_class] office_area = gfa * office_share residential_gfa = gfa - 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 parking_norm = _PARKING_PER_APARTMENT[housing_class] parking_spaces = math.ceil(apartments_count * parking_norm) # Плотность застройки = FAR = GFA / площадь участка (= max_far, если он задан). density = gfa / area_m2 if area_m2 > 0 else 0.0 return TEAP( built_area_sqm=round(built_area, 1), total_floor_area_sqm=round(gfa, 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, ) def synthesize_parcel_financial( *, area_m2: float | None, nspd_zoning: dict[str, Any] | None, market_price: dict[str, Any] | None, district_price_median: float | None, cadastral_value_rub: float | None, gate_verdict: dict[str, Any] | None, geo_radius_price_median: float | None = None, velocity_sqm_per_month: float | None = None, ) -> dict[str, Any] | None: """Мост buildability участка → ОРИЕНТИРОВОЧНАЯ финмодель (DCF) или ``None``. Gate-guard: считаем ТОЛЬКО когда gate допускает МКД и есть числа зоны + площадь. Цена продажи калибруется по приоритету (от точного рыночного к нормативу): 1. ``market_price.median`` — квартальная MV перепродаж (resale) → ``price_source="objective_district_median"``. 2. ``geo_radius_price_median`` — медиана новостроек Objective в гео-радиусе вокруг центроида участка → ``price_source="objective_geo_radius"``. Закрывает пробел district_reference (только 4 из 9 админ-районов ЕКБ матчатся по имени). 3. ``district_price_median`` — name-match медиана админ-района → ``price_source="district_reference"``. 4. норматив класса → ``price_source="class_norm"``. Args: area_m2: площадь участка, кв.м (из parcel_meta/EGRN или geometry_suitability). nspd_zoning: блок зоны НСПД/ПЗЗ — берём ``max_far`` / ``max_building_pct`` / ``max_floors`` (merged regulation-поля из EKB-geoportal, #1067). market_price: квартальная ценовая статистика (берём ``median``). district_price_median: медиана цены по району, руб/кв.м (fallback к market). cadastral_value_rub: кадастровая стоимость участка → land_cost (НЕ рыночная). gate_verdict: can-build-MKD вердикт — гейт на расчёт. geo_radius_price_median: медиана цены новостроек Objective (``price_per_m2_rub``) в гео-радиусе вокруг центроида участка, руб/кв.м. Приоритет МЕЖДУ квартальной MV и ``district_price_median``. ``None`` → слой пропускается (back-compat). velocity_sqm_per_month: RANK 1 — темп абсорбции ОДНОГО типового конкурента района (``velocity.project_absorption_sqm_per_month``), м²/мес. Прокидывается в :func:`compute_financial` как ``market_velocity_sqm_per_month`` → драйвит окно продаж DCF. ``None`` → дефолт-30-мес график (``schedule_is_default``). Returns: ``dict`` = ``FinancialModel.model_dump()`` + bridge-метаданные (``housing_class_inferred`` / ``development_type_inferred`` / ``teap_synth`` / ``caveat`` / ``schedule_is_default``), либо ``None`` когда считать нельзя. """ # ── Gate-guard: только при разрешённом МКД ───────────────────────────────── can_build = (gate_verdict or {}).get("can_build_mkd") if can_build is not True: # False (запрещено) или "unknown" (нет данных) — финмодель не строим. return None # ── Числа зоны обязательны ───────────────────────────────────────────────── if not nspd_zoning: return None max_far = _as_float(nspd_zoning.get("max_far")) max_building_pct = _as_float(nspd_zoning.get("max_building_pct")) max_floors = _as_int(nspd_zoning.get("max_floors")) # Нужен хотя бы КСИТ ИЛИ (%застройки + этажность) — иначе GFA не вывести. if max_far is None and not (max_building_pct is not None and max_floors is not None): return None # ── Площадь обязательна ──────────────────────────────────────────────────── if area_m2 is None or area_m2 <= 0: return None # ── Цена продажи: quarter-MV → гео-радиус → district median (рыночный прокси) ─ # Приоритет от точного рынка к нормативу. Гео-радиус вставлен МЕЖДУ квартальной MV # и 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) # ── Синтез ТЭП из предельных параметров зоны ─────────────────────────────── teap = synthesize_teap_from_buildability( area_m2=area_m2, max_far=max_far, max_building_pct=max_building_pct, max_floors=max_floors, housing_class=housing_class, ) if teap is None: return None # ── Полноценный статический каскад + помесячный DCF (PR-1/2/3) ────────────── fin = compute_financial( teap=teap, housing_class=housing_class, land_cost_rub=cadastral_value_rub, market_price_per_sqm=price, price_source=price_source, development_type=development_type, # RANK 1: окно продаж по рыночной абсорбции района (один типовой конкурент). market_velocity_sqm_per_month=velocity_sqm_per_month, ) logger.info( "parcel financial bridge: area=%.0f far=%s floors=%s class=%s dev=%s " "gfa=%.0f resid=%.0f apts=%d npv=%.0f irr=%.3f%s sales_dur=%sm sched=%s", area_m2, max_far, max_floors, housing_class, development_type, teap.total_floor_area_sqm, teap.residential_area_sqm, teap.apartments_count, fin.npv_rub, fin.irr, " (proxy)" if fin.irr_is_proxy else "", fin.sales_duration_months, "default" if fin.schedule_is_default else "market", ) return { # Полный FinancialModel-каскад (revenue/cost/net/roi/margin/irr/npv/pbp + цена). **fin.model_dump(), # ── Bridge-метаданные (синтез из buildability) ───────────────────────── "housing_class_inferred": housing_class, "development_type_inferred": development_type, "teap_synth": { "residential_area_sqm": teap.residential_area_sqm, "total_floor_area_sqm": teap.total_floor_area_sqm, "office_area_sqm": teap.office_area_sqm, "parking_spaces": teap.parking_spaces, "apartments_count": teap.apartments_count, }, # HEAVY caveat — обязан показываться в UI/PDF рядом с цифрами. "caveat": _FINANCIAL_CAVEAT, } def _as_float(value: Any) -> float | None: """Best-effort numeric coercion → ``float`` или ``None`` (нечисла/bool → None).""" if value is None or isinstance(value, bool): return None try: out = float(value) except (ValueError, TypeError): return None return out if math.isfinite(out) else None def _as_int(value: Any) -> int | None: """Best-effort numeric coercion → ``int`` или ``None`` (нечисла/bool → None).""" f = _as_float(value) return int(f) if f is not None else None __all__ = [ "select_calibrated_price", "synthesize_parcel_financial", "synthesize_teap_from_buildability", ]