gendesign/backend/app/services/site_finder/parcel_financial.py
bot-backend 06b0063c67
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m56s
CI / backend-tests (pull_request) Successful in 17m8s
fix(ptica): невозможные параметры регламента не превращаются в деньги (#2464)
`synthesize_teap_from_buildability` проверяла параметры только на `> 0`.
Процент застройки 150 давал пятно БОЛЬШЕ участка (10 000 м² → 15 000 м²),
а дальше — жилую площадь, число квартир и выручку: физически невозможные
числа, поданные как обычные цифры финмодели. КСИТ 500 давал GFA
5 000 000 м² на гектаре.

Параметры приходят из ПЗЗ-регламента (`zone_regulation_cache`) — внешние
разобранные данные, то есть граница доверия.

Невозможное значение ОТБРАСЫВАЕТСЯ, а не роняет расчёт: если рядом есть
КСИТ, GFA считается по нему и остаётся верной. Лучше отсутствие
параметра, чем неверный. Если вменяемых не осталось — None, и caller
штатно показывает отсутствие финоценки с caveat, а не ноль.

Границы взяты с запасом к реальным данным прода 20.08.2026 (33 строки
zone_regulation_cache: pct 0..100, far 1..4, floors 0..5): pct ≤ 100,
far ≤ 30, этажей ≤ 100 — сито против порчи разбора, а не норматив.

ВТОРОЙ дефект, найденный этими же тестами и существовавший до правки:
ветка «нет ни процента, ни этажности → пятно = GFA» неявно предполагает
один этаж, и при КСИТ > 1 давала пятно больше участка (10 000 м² с far=2
→ 20 000 м²). Добавлен физический инвариант «пятно ≤ участок» — не
эвристика, а геометрия, и стоит он ОДИН раз после всех ветвей, чтобы
держаться и для будущих способов оценки пятна. GFA при этом не меняется.

Двусторонне: против origin/main четыре теста красные с конкретными
невозможными значениями («пятно 15000.0 больше участка 10000.0»,
«GFA=5000000.0»). Восемь контролей зелёные с обеих сторон — среди них
пять сочетаний (pct, far, floors), взятых ДОСЛОВНО с прода, и граница
100 % застройки, которая законна и на проде есть.

pytest test_parcel_financial + services/generative + новый файл — 185 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:14:38 +05:00

435 lines
24 KiB
Python
Raw Permalink 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.

"""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 · 58 → 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"
# Потолки правдоподобия для параметров градрегламента (#2464). Не нормативные
# лимиты, а сито против порчи разбора: самый плотный жилой КСИТ в РФ — единицы,
# самый высокий жилой дом — меньше 100 этажей. Прод 20.08.2026: far 1..4,
# floors 0..5 — запас больше чем семикратный.
_MAX_PLAUSIBLE_FAR: float = 30.0
_MAX_PLAUSIBLE_FLOORS: int = 100
def _sane(value: float | None, low: float, high: float, name: str) -> float | None:
"""Вернуть значение, если оно в (low, high]; иначе None с предупреждением.
Ноль и отрицательные отбрасываются молча — их отсутствие уже штатно
обрабатывается ветвями ниже, и логировать «в регламенте нет параметра»
незачем. Предупреждаем только о значениях ВНЕ верхней границы: это признак
порчи разбора, и его нужно видеть.
"""
if value is None or value <= low:
return None
if value > high:
logger.warning(
"synthesize_teap: %s=%s вне правдоподобного диапазона (%s, %s] — "
"параметр отброшен, расчёт продолжен по остальным",
name,
value,
low,
high,
)
return None
return float(value)
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
# ── Санитария входа (#2464) ────────────────────────────────────────────────
# Параметры приходят из ПЗЗ-регламента (zone_regulation_cache) — это внешние
# разобранные данные, а не наши вычисления. Проверялось только `> 0`, поэтому
# процент застройки 150 дал бы пятно БОЛЬШЕ участка, а дальше — жилую площадь,
# число квартир и выручку, физически невозможные, но поданные как обычные
# цифры финмодели.
#
# Невозможное значение ОТБРАСЫВАЕМ, а не роняем расчёт: если рядом есть КСИТ,
# GFA считается по нему и остаётся верной. Лучше отсутствие параметра, чем
# неверный — тот же принцип, что в остальных правках этого эпика.
#
# Границы взяты с запасом к реальным данным прода 20.08.2026
# (33 строки zone_regulation_cache: pct 0..100, far 1..4, floors 0..5),
# чтобы ловить порчу разбора, а не отсекать законные значения.
max_building_pct = _sane(max_building_pct, 0.0, 100.0, "max_building_pct")
max_far = _sane(max_far, 0.0, _MAX_PLAUSIBLE_FAR, "max_far")
max_floors_f = _sane(
float(max_floors) if max_floors is not None else None,
0.0,
float(_MAX_PLAUSIBLE_FLOORS),
"max_floors",
)
max_floors = int(max_floors_f) if max_floors_f is not None else 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:
# Нет ни процента, ни этажности — пятно оцениваем как GFA (неявно «один этаж»).
built_area = gfa
# Пятно застройки физически не может превышать участок (#2464). Это не эвристика,
# а геометрия. Ветка выше (`built_area = gfa`) нарушала её при КСИТ > 1: участок
# 10 000 м² с far=2 давал пятно 20 000 м². Ограничение вводится ЗДЕСЬ, а не в
# каждой ветке, чтобы инвариант держался и для будущих способов оценки пятна.
if built_area > area_m2:
logger.warning(
"synthesize_teap: пятно %.0f м² превысило участок %.0f м² — ограничено "
"площадью участка (far=%s, pct=%s, floors=%s)",
built_area,
area_m2,
max_far,
max_building_pct,
max_floors,
)
built_area = area_m2
# Нежилое (коммерция/офисы 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",
]