gendesign/backend/tests/services/generative/test_teap_financial.py
Light1YT d38d5d43c5 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).
2026-06-28 00:27:25 +05:00

432 lines
20 KiB
Python
Raw 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.

"""Stage 1c tests — ТЭП and financial model arithmetic.
Pure-arithmetic unit tests against known footprint geometry: GFA, residential area,
apartment count, FAR, parking, revenue/cost/margin and the IRR-proxy clamp.
"""
from __future__ import annotations
from shapely.geometry import box
from app.schemas.concept import TEAP
from app.services.generative import financial, teap
# Один прямоугольник 20 x 10 = 200 кв.м пятна.
_FOOTPRINT = box(0, 0, 20, 10)
def test_teap_basic_arithmetic() -> None:
result = teap.compute_teap(
footprints=[_FOOTPRINT],
floors=10,
site_area_sqm=1000.0,
housing_class="comfort",
)
assert result.built_area_sqm == 200.0
# GFA = пятно * этажность.
assert result.total_floor_area_sqm == 2000.0
# FAR = GFA / участок.
assert result.density == 2.0
# Нежилое (comfort share 0.05) вырезается из GFA: office = 2000 * 0.05 = 100.
assert result.office_area_sqm == 100.0
# Жилая = (GFA нежилое) * efficiency(comfort=0.78) = 1900 * 0.78 = 1482.
assert result.residential_area_sqm == 1482.0
# Квартир = floor(жилая / avg(comfort=55)).
assert result.apartments_count == int(1482.0 // 55.0)
# Парковка = ceil(квартир * 1.0).
assert result.parking_spaces == result.apartments_count
def test_teap_class_changes_efficiency_and_lot() -> None:
econ = teap.compute_teap(
footprints=[_FOOTPRINT], floors=10, site_area_sqm=1000.0, housing_class="econom"
)
biz = teap.compute_teap(
footprints=[_FOOTPRINT], floors=10, site_area_sqm=1000.0, housing_class="business"
)
# Эконом эффективнее по площади -> больше жилой при той же GFA.
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_teap_zero_site_area_no_division_error() -> None:
result = teap.compute_teap(
footprints=[_FOOTPRINT], floors=5, site_area_sqm=0.0, housing_class="comfort"
)
assert result.density == 0.0
def test_teap_empty_placement_is_zeroed() -> None:
result = teap.compute_teap(
footprints=[], floors=9, site_area_sqm=1000.0, housing_class="comfort"
)
assert result.built_area_sqm == 0.0
assert result.total_floor_area_sqm == 0.0
assert result.apartments_count == 0
assert result.parking_spaces == 0
def _teap(residential: float, gfa: float, parking: int = 10) -> TEAP:
return TEAP(
built_area_sqm=100.0,
total_floor_area_sqm=gfa,
residential_area_sqm=residential,
apartments_count=10,
density=1.0,
parking_spaces=parking,
)
def test_financial_revenue_includes_parking() -> None:
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=50_000_000.0
)
# revenue = жильё (1000 * 145_000) + паркинг comfort (10 * 1_300_000).
assert model.revenue_residential_rub == 1000.0 * 145_000.0
assert model.revenue_parking_rub == 10 * 1_300_000.0
assert model.revenue_rub == model.revenue_residential_rub + model.revenue_parking_rub
def test_financial_cost_cascade_includes_all_lines() -> None:
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=50_000_000.0
)
# СМР = GFA*СМР + паркинг comfort*себест (10 * 1_000_000).
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0
# Каждая статья каскада > 0 при ненулевых вводных.
assert model.pir_rub > 0
assert model.networks_rub > 0
assert model.developer_services_rub > 0
assert model.contingency_rub > 0
assert model.marketing_rub > 0
assert model.land_rub == 50_000_000.0
# Итог затрат = сумма всех статей.
expected_cost = (
model.construction_rub
+ model.pir_rub
+ model.networks_rub
+ model.developer_services_rub
+ model.contingency_rub
+ model.marketing_rub
+ model.land_rub
)
assert abs(model.cost_rub - expected_cost) < 1.0
assert model.gross_margin_rub == model.revenue_rub - model.cost_rub
def test_financial_net_profit_below_gross_margin_when_profitable() -> None:
# Прибыльный сценарий: налоги срезают валовую маржу -> net < gross.
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
assert model.gross_margin_rub > 0
assert model.vat_rub > 0
assert model.profit_tax_rub > 0
assert model.net_profit_rub < model.profit_before_tax_rub < model.gross_margin_rub
# ROI и маржа% согласованы с чистой прибылью (поля округлены до 4 знаков).
assert model.roi == round(model.net_profit_rub / model.cost_rub, 4)
assert model.margin_pct == round(model.net_profit_rub / model.revenue_rub, 4)
def test_financial_no_profit_tax_on_loss() -> None:
# Убыток: налог на прибыль = 0, НДС = 0; net == pbt == gross_margin.
t = _teap(residential=1.0, gfa=10_000.0, parking=0)
model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None)
assert model.gross_margin_rub < 0
assert model.profit_tax_rub == 0.0
assert model.vat_rub == 0.0
assert model.profit_before_tax_rub == model.gross_margin_rub
assert model.net_profit_rub == model.gross_margin_rub
def test_financial_parking_zero_yields_zero_parking_lines() -> None:
t = _teap(residential=1000.0, gfa=1300.0, parking=0)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
assert model.revenue_parking_rub == 0.0
# СМР без паркинговой составляющей = ровно GFA*СМР.
assert model.construction_rub == 1300.0 * 88_000.0
def test_financial_land_cost_optional() -> None:
t = _teap(residential=1000.0, gfa=1300.0)
no_land = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
with_land = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=10_000_000.0
)
# Земля увеличивает затраты ровно на свою стоимость.
assert with_land.cost_rub - no_land.cost_rub == 10_000_000.0
assert with_land.land_rub == 10_000_000.0
assert no_land.land_rub == 0.0
def test_financial_irr_proxy_clamped_and_flagged() -> None:
# Огромная маржа -> irr-proxy зажат в [-1, 1]; флаг proxy выставлен.
t = _teap(residential=100_000.0, gfa=1.0, parking=0)
model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None)
assert -1.0 <= model.irr <= 1.0
assert model.irr_is_proxy is True
def test_financial_zero_cost_no_division_error() -> None:
t = _teap(residential=0.0, gfa=0.0, parking=0)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
assert model.cost_rub == 0.0
assert model.revenue_rub == 0.0
assert model.roi == 0.0
assert model.margin_pct == 0.0
assert model.irr == 0.0
def test_financial_backward_compat_fields_present() -> None:
t = _teap(residential=1000.0, gfa=1300.0)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
# Старые summary-поля контракта остаются.
assert isinstance(model.revenue_rub, float)
assert isinstance(model.cost_rub, float)
assert isinstance(model.gross_margin_rub, float)
assert isinstance(model.irr, float)
# PR-3 DCF-поля additive — присутствуют с дефолтами.
assert isinstance(model.npv_rub, float)
assert model.discount_rate_used == 0.15
assert model.schedule_is_default is True
assert model.payback_months is None or isinstance(model.payback_months, float)
# ── PR-2: рыночная калибровка цены продажи жилья ───────────────────────────────
def test_financial_no_market_price_uses_class_norm() -> None:
# Без market_price_per_sqm — норматив класса comfort (145_000), флаг не калибровано.
t = _teap(residential=1000.0, gfa=1300.0)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
assert model.price_per_sqm_used == 145_000.0
assert model.price_is_calibrated is False
assert model.price_source == "class_norm"
# Выручка жилья считается по нормативу.
assert model.revenue_residential_rub == 1000.0 * 145_000.0
def test_financial_market_price_overrides_class_norm() -> None:
# market_price_per_sqm передан → используется он, флаг калибровано, source проброшен.
t = _teap(residential=1000.0, gfa=1300.0)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=None,
market_price_per_sqm=180_000.0,
price_source="objective_district_median",
)
assert model.price_per_sqm_used == 180_000.0
assert model.price_is_calibrated is True
assert model.price_source == "objective_district_median"
# Выручка жилья считается по рыночной цене, НЕ по нормативу класса.
assert model.revenue_residential_rub == 1000.0 * 180_000.0
def test_financial_market_price_does_not_calibrate_parking_or_smr() -> None:
# Калибруем ТОЛЬКО цену жилья: паркинг (класс comfort) и себестоимость СМР
# (88_000) — норма, не зависят от market_price_per_sqm.
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=None,
market_price_per_sqm=200_000.0,
price_source="district_reference",
)
# Паркинг — норматив класса comfort (10 * 1_300_000), не зависит от цены жилья.
assert model.revenue_parking_rub == 10 * 1_300_000.0
# СМР — норматив класса comfort (GFA*88_000 + паркинг*1_000_000).
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0
def test_financial_none_market_price_forces_class_norm_source() -> None:
# Даже если source передан, при market_price_per_sqm=None он форсится в class_norm —
# нельзя выдавать норматив за рыночные данные.
t = _teap(residential=1000.0, gfa=1300.0)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=None,
market_price_per_sqm=None,
price_source="objective_district_median",
)
assert model.price_is_calibrated is False
assert model.price_source == "class_norm"
assert model.price_per_sqm_used == 145_000.0
# ── Классозависимая экономика паркинга (econom наземный / business подземный) ────
def test_financial_parking_econom_uses_surface_price_and_cost() -> None:
# Эконом — наземный паркинг: цена 800k, себестоимость 350k за м/м.
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
model = financial.compute_financial(teap=t, housing_class="econom", land_cost_rub=None)
assert model.revenue_parking_rub == 10 * 800_000.0
# СМР = GFA*72_000 (econom) + паркинг наземный 10 * 350_000.
assert model.construction_rub == 1300.0 * 72_000.0 + 10 * 350_000.0
def test_financial_parking_comfort_uses_mixed_price_and_cost() -> None:
# Комфорт — частично подземный: цена 1.3М, себестоимость 1.0М за м/м.
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
assert model.revenue_parking_rub == 10 * 1_300_000.0
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0
def test_financial_parking_business_matches_excel_avangardnaya13() -> None:
# РЕГРЕСС-ПИН: бизнес-паркинг подземный — без изменений vs Excel «Авангардная 13»
# (продажа 1.9М / себестоимость 1.8М за м/м). Любой дрейф этих чисел — провал.
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None)
assert model.revenue_parking_rub == 10 * 1_900_000.0
assert model.construction_rub == 1300.0 * 120_000.0 + 10 * 1_800_000.0
# Прямая сверка с константами-словарями (теперь dict, не скаляр).
assert financial._PARKING_PRICE_PER_SPOT["business"] == 1_900_000.0
assert financial._PARKING_COST_PER_SPOT["business"] == 1_800_000.0
def test_financial_parking_margin_positive_for_all_classes() -> None:
# Цена > себестоимости во всех классах → паркинг не убыточен ни в одном классе.
for hc in ("econom", "comfort", "business"):
price = financial._PARKING_PRICE_PER_SPOT[hc] # type: ignore[index]
cost = financial._PARKING_COST_PER_SPOT[hc] # type: ignore[index]
assert price > cost, hc
# Конкретные маржи из спецификации: econom +450k, comfort +300k, business +100k.
assert financial._PARKING_PRICE_PER_SPOT["econom"] - financial._PARKING_COST_PER_SPOT[
"econom"
] == 450_000.0
assert financial._PARKING_PRICE_PER_SPOT["comfort"] - financial._PARKING_COST_PER_SPOT[
"comfort"
] == 300_000.0
assert financial._PARKING_PRICE_PER_SPOT["business"] - financial._PARKING_COST_PER_SPOT[
"business"
] == 100_000.0
def test_financial_econom_parking_cheaper_than_business() -> None:
# Тот же участок: econom-паркинг (наземный) даёт МЕНЬШЕ паркинговой выручки и
# МЕНЬШЕ паркинговой себестоимости, чем business (подземный) — проверка, что
# классозависимость реально применяется в каскаде.
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
econ = financial.compute_financial(teap=t, housing_class="econom", land_cost_rub=None)
biz = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None)
assert econ.revenue_parking_rub < biz.revenue_parking_rub
# Паркинговая часть СМР: econom 350k/м < business 1.8М/м.
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