feat(generative): LIVE financial recompute from massing program (#1965 Stage 2a)

Stage 2a of epic #1953: backend service + endpoint for live economic recompute
driven by the interactive 3D massing (Stage 2b debounced sliders).

- teap.py: add pure synthesize_teap_from_program(total_footprint_sqm, floors,
  site_area_sqm, housing_class, sections) — builds a TEAP from the SCALAR
  aggregate footprint × floors, mirroring synthesize_teap_from_buildability and
  reusing the same shared norm constants (_OFFICE_SHARE_OF_GFA / _EFFICIENCY_BY_CLASS
  / _AVG_APARTMENT_SQM / _PARKING_PER_APARTMENT) — single source of truth.
- schemas/concept.py: add MassingProgram (program contract, optional pre-resolved
  market_price_per_sqm + parcel_centroid_wkt) and MassingRecomputeOutput (teap + financial).
- api/v1/concepts.py: add POST /api/v1/concepts/recompute — synthesize TEAP → run
  the existing pure compute_financial. FAST path uses body market_price_per_sqm
  (no DB); else _lookup_market_price by centroid via run_in_threadpool; else class norm.
- tests: synthesize_teap_from_program (gfa math, parity with compute_teap, class
  efficiency, sections no-op) + endpoint (200, coherent output, price passthrough
  skips DB, DB fallback, class-norm default, floors validation).
This commit is contained in:
Light1YT 2026-06-28 00:27:25 +05:00
parent fed33fce27
commit d38d5d43c5
5 changed files with 428 additions and 2 deletions

View file

@ -8,9 +8,16 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.db import get_db from app.core.db import get_db
from app.schemas.concept import ConceptInput, ConceptOutput from app.schemas.concept import (
ConceptInput,
ConceptOutput,
MassingProgram,
MassingRecomputeOutput,
)
from app.services.generative import geometry from app.services.generative import geometry
from app.services.generative.financial import compute_financial
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
from app.services.generative.teap import synthesize_teap_from_program
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -210,3 +217,63 @@ async def create_concept(
logger.warning("concept generation rejected parcel: %s", exc) logger.warning("concept generation rejected parcel: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
return ConceptOutput(variants=variants) return ConceptOutput(variants=variants)
@router.post("/recompute", response_model=MassingRecomputeOutput)
async def recompute_massing(
payload: MassingProgram,
db: Annotated[Session, Depends(get_db)],
) -> MassingRecomputeOutput:
"""Stage 2a (#1965, эпик #1953) — LIVE-пересчёт экономики из 3D-массинга.
Hot, debounced-эндпоинт для интерактивных слайдеров Stage 2b: фронтовый
``computeModel`` отдаёт агрегированную программу (суммарное пятно × этажность),
мы синтезируем :class:`TEAP` (``synthesize_teap_from_program`` те же нормативные
константы, что и обычная генерация) и прогоняем готовый чистый ``compute_financial``
пересчитанный ТЭП + финмодель.
Цена продажи жилья:
* ``market_price_per_sqm`` в теле используется как есть (FAST path, БЕЗ БД)
фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе.
* иначе, если передан ``parcel_centroid_wkt`` ``_lookup_market_price`` по центроиду
через ``run_in_threadpool`` (тот же приём, что и в ``create_concept``; sync SQLAlchemy
не должен блокировать event loop). Падение lookup'а → норматив класса, не 500.
* иначе (нет ни цены, ни центроида) норматив класса (``price_source="class_norm"``).
"""
# ── Цена продажи: предрезолвленная (fast) → DB-fallback → норматив класса ───────
market_price: float | None = payload.market_price_per_sqm
price_source: str = "class_norm"
if market_price is not None:
# FAST path: фронт уже резолвил рыночную цену — НЕ ходим в БД на каждый keystroke.
price_source = "objective_district_median"
elif payload.parcel_centroid_wkt:
# Fallback: резолвим по центроиду. Sync-lookup → threadpool (как в create_concept).
# Любая ошибка → норматив класса (graceful, без 500) — деградация уже внутри
# _lookup_market_price, защитный пояс на непредвиденное оставляем тут.
try:
market_price, price_source = await run_in_threadpool(
_lookup_market_price, db, payload.parcel_centroid_wkt
)
except Exception as exc:
logger.warning("recompute market-price: unexpected lookup error, class_norm: %s", exc)
market_price, price_source = None, "class_norm"
teap = synthesize_teap_from_program(
total_footprint_sqm=payload.total_footprint_sqm,
floors=payload.floors,
site_area_sqm=payload.site_area_sqm,
housing_class=payload.housing_class,
sections=payload.sections,
)
# compute_financial — чистая, CPU-bound, лёгкая (без БД) → можно вызывать прямо в
# event loop (hot path; threadpool-overhead тут не нужен). price_source игнорируется
# внутри, когда market_price_per_sqm is None (форсится class_norm) — корректно.
financial = compute_financial(
teap=teap,
housing_class=payload.housing_class,
land_cost_rub=payload.land_cost_rub,
market_price_per_sqm=market_price,
price_source=price_source,
development_type=payload.development_type,
)
return MassingRecomputeOutput(teap=teap, financial=financial)

View file

@ -151,3 +151,47 @@ class ConceptVariant(BaseModel):
class ConceptOutput(BaseModel): class ConceptOutput(BaseModel):
variants: list[ConceptVariant] variants: list[ConceptVariant]
class MassingProgram(BaseModel):
"""Stage 2a (#1965) — агрегированная массинг-программа для LIVE-пересчёта экономики.
Контракт POST ``/api/v1/concepts/recompute``: вход уже СВЁРНУТАЯ программа из
фронтового ``computeModel`` интерактивного 3D-массинга (суммарное пятно × этажность),
без покомпонентной геометрии секций. Эндпоинт синтезирует из неё :class:`TEAP` и
прогоняет готовый ``compute_financial`` пересчитанная экономика для дебаунс-слайдеров
Stage 2b.
Класс жилья / тип застройки те же Literal-множества, что у :class:`ConceptInput`
и ``compute_financial`` (single source of truth по допустимым значениям).
"""
total_footprint_sqm: float = Field(
..., ge=0, description="Суммарное пятно застройки всех секций, кв.м (скаляр)"
)
floors: int = Field(..., ge=1, le=40, description="Этажность программы")
sections: int = Field(1, ge=1, le=6, description="Число секций (метаданные программы)")
site_area_sqm: float = Field(..., ge=0, description="Площадь участка для плотности (FAR)")
housing_class: Literal["econom", "comfort", "business"] = "comfort"
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
land_cost_rub: float | None = Field(
None, ge=0, description="Стоимость участка для финмодели (опционально)"
)
# Предрезолвленная рыночная цена жилья, руб/кв.м — позволяет hot-эндпоинту ПРОПУСТИТЬ
# per-keystroke DB-lookup (фронт резолвит цену один раз и шлёт её в каждом запросе).
# None → эндпоинт сам сделает _lookup_market_price по центроиду (медленный путь).
market_price_per_sqm: float | None = Field(
None, ge=0, description="Предрезолвленная рыночная цена жилья, руб/кв.м (skip DB lookup)"
)
# Центроид участка (WKT-точка WGS84) для DB-fallback цены, когда market_price_per_sqm
# не передан. None → пропускаем lookup и берём норматив класса (нет геопривязки).
parcel_centroid_wkt: str | None = Field(
None, description="WKT-точка центроида участка (WGS84) для DB-резолва цены при fallback"
)
class MassingRecomputeOutput(BaseModel):
"""Stage 2a (#1965) — результат LIVE-пересчёта: синтезированный ТЭП + финмодель."""
teap: TEAP
financial: FinancialModel

View file

@ -136,4 +136,76 @@ def compute_teap(
return teap return teap
__all__ = ["HousingClass", "compute_teap"] def synthesize_teap_from_program(
*,
total_footprint_sqm: float,
floors: int,
site_area_sqm: float,
housing_class: HousingClass,
sections: int = 1,
) -> TEAP:
"""Свести АГРЕГИРОВАННУЮ массинг-программу (скалярное пятно × этажность) в :class:`TEAP`.
Stage 2a (#1965): LIVE-пересчёт экономики из интерактивного 3D-массинга. Фронтовый
``computeModel`` отдаёт уже СВЁРНУТУЮ программу суммарное пятно застройки (кв.м) и
этажность, без покомпонентной геометрии секций. Здесь мы строим из этого тот же
:class:`TEAP`, что и :func:`compute_teap`/``synthesize_teap_from_buildability``, чтобы
прогнать его через готовый :func:`compute_financial`.
Зеркалит :func:`synthesize_teap_from_buildability` ОДИН-В-ОДИН, только источник
площадей СКАЛЯРНОЕ пятно, а не ``max_far × area``:
* ``built`` (пятно) = ``total_footprint_sqm`` (как есть, сумма по секциям).
* ``GFA`` (total_floor) = ``total_footprint_sqm × floors``.
* ``office`` (нежилое) = ``GFA × _OFFICE_SHARE_OF_GFA[class]`` вырезается из GFA
до расчёта жилой (жилая ужимается ровно на эту площадь, total GFA не меняется).
* ``residential`` = ``(GFA office) × _EFFICIENCY_BY_CLASS[class]``.
* ``apartments`` = ``floor(residential / _AVG_APARTMENT_SQM[class])``.
* ``parking`` = ``ceil(apartments × _PARKING_PER_APARTMENT[class])``.
* ``density`` = ``GFA / site_area_sqm`` (FAR), защита от деления на ноль.
Чистая функция без БД / LLM / внешних API. Те же нормативные константы, что и
остальные синтезаторы (single source of truth, без новых магических чисел).
Args:
total_footprint_sqm: суммарное пятно застройки всех секций, кв.м (скаляр).
floors: этажность (общая для программы); вне диапазона клампится к >= 0.
site_area_sqm: площадь участка для плотности (FAR).
housing_class: класс жилья задаёт эффективность/средний лот/парковку/нежилое.
sections: число секций метаданные программы; на ТЭП НЕ влияет (площади уже
свёрнуты во ``total_footprint_sqm``). Принимается для симметрии с контрактом
фронта и будущей пер-секционной логики; здесь намеренно не используется.
"""
del sections # метаданные программы; площади уже агрегированы — на ТЭП не влияют
built_area = max(0.0, total_footprint_sqm)
total_floor_area = built_area * max(0, floors)
# Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA ДО расчёта жилой — точно как
# compute_teap / synthesize_teap_from_buildability (single source of truth по долям).
office_share = _OFFICE_SHARE_OF_GFA[housing_class]
office_area = total_floor_area * office_share
residential_gfa = total_floor_area - 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
density = total_floor_area / site_area_sqm if site_area_sqm > 0 else 0.0
parking_norm = _PARKING_PER_APARTMENT[housing_class]
parking_spaces = math.ceil(apartments_count * parking_norm)
return TEAP(
built_area_sqm=round(built_area, 1),
total_floor_area_sqm=round(total_floor_area, 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,
)
__all__ = ["HousingClass", "compute_teap", "synthesize_teap_from_program"]

View file

@ -0,0 +1,135 @@
"""Stage 2a (#1965, эпик #1953) — POST /api/v1/concepts/recompute endpoint tests.
LIVE-пересчёт экономики из агрегированной массинг-программы (пятно × этажность) для
дебаунс-слайдеров Stage 2b. Проверяем: 200 + связный вывод (ТЭП-математика, revenue>0,
NPV посчитан), FAST path с предрезолвленной ценой (БЕЗ обращения к БД) и DB-fallback по
центроиду (через dependency override get_db мок-сессией). compute_financial остаётся
чистой её арифметика покрыта в tests/services/generative; тут контракт эндпоинта.
"""
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from app.core.db import get_db
from app.main import app
def _program(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"total_footprint_sqm": 3000.0,
"floors": 12,
"sections": 2,
"site_area_sqm": 10_000.0,
"housing_class": "comfort",
"development_type": "mid_rise",
}
base.update(overrides)
return base
def test_recompute_returns_200_and_coherent_output() -> None:
client = TestClient(app)
resp = client.post(
"/api/v1/concepts/recompute",
json=_program(market_price_per_sqm=150_000.0),
)
assert resp.status_code == 200, resp.text
body = resp.json()
teap = body["teap"]
fin = body["financial"]
# ТЭП-математика: GFA = пятно × этажность; пятно = total_footprint_sqm.
assert teap["built_area_sqm"] == 3000.0
assert teap["total_floor_area_sqm"] == 3000.0 * 12
# Финмодель связна: выручка/затраты > 0, NPV посчитан (поле присутствует).
assert fin["revenue_rub"] > 0
assert fin["cost_rub"] > 0
assert isinstance(fin["npv_rub"], float)
def test_recompute_market_price_passthrough_skips_db() -> None:
# Предрезолвленная цена в теле → используется как есть, БЕЗ обращения к БД.
# get_db переопределён сессией, которая ВЗРЫВАЕТСЯ при любом execute — если бы
# эндпоинт пошёл в БД, тест бы упал. Зелёный тест доказывает skip DB lookup.
class _ExplodingSession:
def execute(self, *_a: object, **_k: object) -> None:
raise AssertionError("DB lookup must be skipped when market_price_per_sqm given")
app.dependency_overrides[get_db] = lambda: _ExplodingSession()
try:
client = TestClient(app)
resp = client.post(
"/api/v1/concepts/recompute",
json=_program(market_price_per_sqm=180_000.0),
)
assert resp.status_code == 200, resp.text
fin = resp.json()["financial"]
# Цена прокинута в выручку жилья → price_per_sqm_used == переданная цена.
assert fin["price_per_sqm_used"] == 180_000.0
assert fin["price_is_calibrated"] is True
finally:
app.dependency_overrides.pop(get_db, None)
def test_recompute_db_fallback_when_no_price_but_centroid() -> None:
# Нет market_price_per_sqm, но передан centroid → эндпоинт делает _lookup_market_price.
# Мок-сессия отдаёт район + Objective-медиану (n>=10) → цена калибруется из БД.
class _Result:
def __init__(self, row: dict[str, Any] | None) -> None:
self._row = row
def mappings(self) -> _Result:
return self
def first(self) -> dict[str, Any] | None:
return self._row
class _FakeSession:
def __init__(self, rows: list[dict[str, Any] | None]) -> None:
self._rows = rows
self.calls = 0
def execute(self, *_a: object, **_k: object) -> _Result:
row = self._rows[self.calls] if self.calls < len(self._rows) else None
self.calls += 1
return _Result(row)
rows = [
{"district_name": "Кировский", "median_price_per_m2": 130_000},
{"median_ppm2": 175_000.0, "sample_size": 42},
]
app.dependency_overrides[get_db] = lambda: _FakeSession(rows)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/concepts/recompute",
json=_program(parcel_centroid_wkt="POINT (60.6 56.83)"),
)
assert resp.status_code == 200, resp.text
fin = resp.json()["financial"]
# Цена пришла из Objective-медианы района (DB-fallback).
assert fin["price_per_sqm_used"] == 175_000.0
assert fin["price_source"] == "objective_district_median"
assert fin["price_is_calibrated"] is True
finally:
app.dependency_overrides.pop(get_db, None)
def test_recompute_no_price_no_centroid_uses_class_norm() -> None:
# Ни цены, ни центроида → норматив класса comfort (145_000), не калибровано.
client = TestClient(app)
resp = client.post("/api/v1/concepts/recompute", json=_program())
assert resp.status_code == 200, resp.text
fin = resp.json()["financial"]
assert fin["price_per_sqm_used"] == 145_000.0
assert fin["price_is_calibrated"] is False
assert fin["price_source"] == "class_norm"
def test_recompute_validates_floors_bounds() -> None:
# floors вне [1, 40] → 422 (Pydantic-валидация контракта).
client = TestClient(app)
resp = client.post("/api/v1/concepts/recompute", json=_program(floors=99))
assert resp.status_code == 422

View file

@ -322,3 +322,111 @@ def test_financial_econom_parking_cheaper_than_business() -> None:
econ_parking_smr = econ.construction_rub - 1300.0 * 72_000.0 econ_parking_smr = econ.construction_rub - 1300.0 * 72_000.0
biz_parking_smr = biz.construction_rub - 1300.0 * 120_000.0 biz_parking_smr = biz.construction_rub - 1300.0 * 120_000.0
assert econ_parking_smr < biz_parking_smr assert econ_parking_smr < biz_parking_smr
# ── Stage 2a (#1965): synthesize_teap_from_program (скалярное пятно × этажность) ──
def test_synthesize_program_gfa_is_footprint_times_floors() -> None:
# GFA = суммарное пятно × этажность; пятно = total_footprint_sqm как есть.
result = teap.synthesize_teap_from_program(
total_footprint_sqm=3000.0,
floors=12,
site_area_sqm=10_000.0,
housing_class="comfort",
sections=2,
)
assert result.built_area_sqm == 3000.0
assert result.total_floor_area_sqm == 3000.0 * 12 # 36_000
# FAR = GFA / участок.
assert result.density == round(36_000.0 / 10_000.0, 3)
def test_synthesize_program_matches_compute_teap_for_equivalent_input() -> None:
# synthesize_teap_from_program(footprint, floors) ДОЛЖЕН дать ровно тот же ТЭП,
# что compute_teap из одного прямоугольного пятна той же площади — единая логика.
footprint = box(0, 0, 50, 40) # 2000 кв.м
from_program = teap.synthesize_teap_from_program(
total_footprint_sqm=2000.0,
floors=9,
site_area_sqm=5000.0,
housing_class="comfort",
)
from_geometry = teap.compute_teap(
footprints=[footprint],
floors=9,
site_area_sqm=5000.0,
housing_class="comfort",
)
assert from_program == from_geometry
def test_synthesize_program_office_carveout_and_efficiency_by_class() -> None:
# Нежилое вырезается из GFA (comfort share 0.05), жилая = (GFAнежилое)*efficiency.
result = teap.synthesize_teap_from_program(
total_footprint_sqm=2000.0,
floors=10,
site_area_sqm=5000.0,
housing_class="comfort",
)
gfa = 2000.0 * 10 # 20_000
assert result.office_area_sqm == round(gfa * 0.05, 1) # 1000
assert result.residential_area_sqm == round((gfa - gfa * 0.05) * 0.78, 1)
def test_synthesize_program_class_changes_efficiency_and_lot() -> None:
common = dict(total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0)
econ = teap.synthesize_teap_from_program(housing_class="econom", **common) # type: ignore[arg-type]
biz = teap.synthesize_teap_from_program(housing_class="business", **common) # type: ignore[arg-type]
# Эконом эффективнее (0.82 vs 0.72) + нет нежилого carve-out → больше жилой.
assert econ.residential_area_sqm > biz.residential_area_sqm
# Бизнес — крупнее средний лот → меньше квартир.
assert biz.apartments_count < econ.apartments_count
# Бизнес — выше норма парковки на квартиру.
assert biz.parking_spaces / max(1, biz.apartments_count) >= 1.4
def test_synthesize_program_zero_site_area_no_division_error() -> None:
result = teap.synthesize_teap_from_program(
total_footprint_sqm=1000.0,
floors=5,
site_area_sqm=0.0,
housing_class="comfort",
)
assert result.density == 0.0
def test_synthesize_program_sections_do_not_affect_teap() -> None:
# sections — метаданные программы; площади уже свёрнуты → ТЭП от них не зависит.
one = teap.synthesize_teap_from_program(
total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0,
housing_class="comfort", sections=1,
)
six = teap.synthesize_teap_from_program(
total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0,
housing_class="comfort", sections=6,
)
assert one == six
def test_synthesize_program_then_financial_is_coherent() -> None:
# Сквозь финмодель: жизнеспособная программа → выручка > 0, NPV посчитан.
t = teap.synthesize_teap_from_program(
total_footprint_sqm=3000.0,
floors=12,
site_area_sqm=10_000.0,
housing_class="comfort",
)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=None,
market_price_per_sqm=150_000.0,
price_source="objective_district_median",
)
assert model.revenue_rub > 0
assert model.cost_rub > 0
assert isinstance(model.npv_rub, float)
# Цена прокинута в выручку жилья.
assert model.price_per_sqm_used == 150_000.0
assert model.price_is_calibrated is True