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:
parent
fed33fce27
commit
d38d5d43c5
5 changed files with 428 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
135
backend/tests/api/v1/test_concept_recompute.py
Normal file
135
backend/tests/api/v1/test_concept_recompute.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue