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