diff --git a/backend/app/api/v1/concepts.py b/backend/app/api/v1/concepts.py index 3e5dda47..3d2f8d35 100644 --- a/backend/app/api/v1/concepts.py +++ b/backend/app/api/v1/concepts.py @@ -8,9 +8,16 @@ from sqlalchemy import text from sqlalchemy.orm import Session 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.financial import compute_financial from app.services.generative.geometry import ParcelGeometryError, _parse_polygon +from app.services.generative.teap import synthesize_teap_from_program logger = logging.getLogger(__name__) @@ -210,3 +217,63 @@ async def create_concept( logger.warning("concept generation rejected parcel: %s", exc) raise HTTPException(status_code=422, detail=str(exc)) from exc 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) diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py index 371fc0c8..e19f7d05 100644 --- a/backend/app/schemas/concept.py +++ b/backend/app/schemas/concept.py @@ -151,3 +151,47 @@ class ConceptVariant(BaseModel): class ConceptOutput(BaseModel): 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 diff --git a/backend/app/services/generative/teap.py b/backend/app/services/generative/teap.py index d9995406..895dbdfb 100644 --- a/backend/app/services/generative/teap.py +++ b/backend/app/services/generative/teap.py @@ -136,4 +136,76 @@ def compute_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"] diff --git a/backend/tests/api/v1/test_concept_recompute.py b/backend/tests/api/v1/test_concept_recompute.py new file mode 100644 index 00000000..435a7d9b --- /dev/null +++ b/backend/tests/api/v1/test_concept_recompute.py @@ -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 diff --git a/backend/tests/services/generative/test_teap_financial.py b/backend/tests/services/generative/test_teap_financial.py index 3fc14d04..a8757ff4 100644 --- a/backend/tests/services/generative/test_teap_financial.py +++ b/backend/tests/services/generative/test_teap_financial.py @@ -322,3 +322,111 @@ def test_financial_econom_parking_cheaper_than_business() -> None: econ_parking_smr = econ.construction_rub - 1300.0 * 72_000.0 biz_parking_smr = biz.construction_rub - 1300.0 * 120_000.0 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