Extend the concept generator so ConceptInput can carry an optional
building_program (list of typed houses from a catalog). When present,
placement lays out EXACTLY that program — for each item, place `count`
sections of the catalog footprint at the item's floors — instead of the
greedy max-FAR coverage-cap sweep. When absent, the existing greedy
behavior is unchanged (byte-for-byte backward-compatible).
- catalog.py: hardcoded HOUSE_TYPES (panel_econom, monolith_comfort,
tower_business, lowrise_comfort, townhouse) — sane-default catalog,
promote to DB later; get_house_type / available_section_types lookups.
- schema: additive BuildingProgramItem {section_type, floors, count} and
ConceptInput.building_program (default None -> greedy). ConceptVariant
gains optional placed_count / requested_count (partial-fit signal).
- placement: shared _Placer (collision/STRtree/setback machine extracted
from greedy sweep, reused — no duplication); place_program +
place_program_variant; branch in place_all_strategies on
building_program. Mixed-floor TEAP via exact per-floor-group aggregation
(GFA = sum(area_i * floors_i), no rounding drift).
- partial fit: when the parcel can't fit all sections, place as many as
fit and report placed_count < requested_count (no hard-422); zero-fit
still raises ParcelGeometryError (-> 422).
- API: validate program section_type keys against the catalog (unknown ->
422) before placement.
- tests: catalog integrity, greedy backward-compat, exact 2-item program +
TEAP reflection, over-packed partial placement, API program path.
- regenerate frontend api-types.ts (OpenAPI codegen gate stays green).
309 lines
16 KiB
Python
309 lines
16 KiB
Python
import logging
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from fastapi.concurrency import run_in_threadpool
|
||
from shapely.geometry import Point
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import get_db
|
||
from app.schemas.concept import (
|
||
ConceptInput,
|
||
ConceptOutput,
|
||
MassingProgram,
|
||
MassingRecomputeOutput,
|
||
)
|
||
from app.services.generative import geometry
|
||
from app.services.generative.catalog import available_section_types
|
||
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__)
|
||
|
||
router = APIRouter()
|
||
|
||
# Минимум объявлений Objective в районе для надёжной медианы цены продажи жилья.
|
||
# n>=10: с меньшей выборкой медиана шумна (1-2 нетипичных лота её перекашивают) и
|
||
# выдавать её за «рыночную цену района» нечестно. При n<10 честнее откатиться на
|
||
# справочную median_price_per_m2 района (ekb_districts, 24-мес окно), а при её
|
||
# отсутствии — на норматив класса. Порог зеркалит «осторожную выборку» из других
|
||
# district-агрегатов проекта (см. parcels.py district_price_block).
|
||
_MIN_OBJECTIVE_SAMPLE: int = 10
|
||
|
||
# Радиус поиска района от центроида участка (м). Зеркалит ST_DWithin 5 км из
|
||
# parcels.py district-context — участок может лежать у границы полигона района.
|
||
_DISTRICT_SEARCH_RADIUS_M: int = 5000
|
||
|
||
# Санитарные границы цены продажи, руб/кв.м — отсекают мусорные лоты Objective
|
||
# (price_per_m2_rub — это РУБЛИ, не тысячи). Те же пороги, что в parcels.py.
|
||
_PRICE_MIN_RUB: int = 30_000
|
||
_PRICE_MAX_RUB: int = 600_000
|
||
|
||
# Stage 2a (#1965): дефолтный лейбл source для ПРЕДРЕЗОЛВЛЕННОЙ цены в /recompute, когда
|
||
# фронт НЕ форвардит подлинный price_source. Консервативный fallback: цена пришла извне
|
||
# (фронт её уже резолвил), но без явного происхождения берём нейтральный district-median.
|
||
_DEFAULT_PRERESOLVED_SOURCE: str = "objective_district_median"
|
||
|
||
# Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены.
|
||
# Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён).
|
||
# ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT.
|
||
_DISTRICT_FOR_POINT_SQL = text(
|
||
"""
|
||
SELECT d.district_name,
|
||
d.median_price_per_m2
|
||
FROM ekb_districts d
|
||
WHERE d.geom IS NOT NULL
|
||
AND ST_DWithin(
|
||
d.geom::geography,
|
||
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
|
||
CAST(:radius AS double precision)
|
||
)
|
||
ORDER BY ST_Distance(
|
||
d.geom::geography,
|
||
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography
|
||
) ASC
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
|
||
# Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
|
||
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
|
||
_OBJECTIVE_MEDIAN_SQL = text(
|
||
"""
|
||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
|
||
COUNT(*) AS sample_size
|
||
FROM objective_lots
|
||
WHERE district = CAST(:dn AS text)
|
||
AND price_per_m2_rub IS NOT NULL
|
||
AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric)
|
||
"""
|
||
)
|
||
|
||
|
||
def _parcel_centroid_wkt(payload: ConceptInput) -> str:
|
||
"""Центроид полигона участка как WKT-точка (WGS84) для spatial-lookup."""
|
||
polygon = _parse_polygon(payload.parcel_geojson)
|
||
c = polygon.centroid
|
||
wkt: str = Point(c.x, c.y).wkt
|
||
return wkt
|
||
|
||
|
||
def _lookup_market_price(db: Session, wkt_point: str) -> tuple[float | None, str]:
|
||
"""Рыночная цена продажи жилья, руб/кв.м, для участка по его центроиду + источник.
|
||
|
||
Алгоритм (от точного к общему):
|
||
1. Центроид → ближайший район ЕКБ (ekb_districts, ST_DWithin 5 км).
|
||
2. Медиана Objective по этому району (price_per_m2_rub, n>=:_MIN_OBJECTIVE_SAMPLE)
|
||
→ ("objective_district_median"). Свежайший рыночный сигнал.
|
||
3. Иначе справочная ekb_districts.median_price_per_m2 → ("district_reference").
|
||
4. Иначе (нет района / нет данных) → (None, "class_norm") — финмодель возьмёт
|
||
норматив класса.
|
||
|
||
Никогда не роняет генерацию концепции: ЛЮБАЯ ошибка (вне ЕКБ, нет PostGIS, сбой
|
||
БД, нет таблицы) логируется warning'ом и деградирует в (None, "class_norm").
|
||
Read-only — SAVEPOINT не нужен.
|
||
"""
|
||
try:
|
||
district_row = (
|
||
db.execute(
|
||
_DISTRICT_FOR_POINT_SQL,
|
||
{"wkt": wkt_point, "radius": _DISTRICT_SEARCH_RADIUS_M},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("concept market-price: district lookup failed, fallback class_norm: %s", exc)
|
||
return None, "class_norm"
|
||
|
||
if not district_row or not district_row["district_name"]:
|
||
logger.info("concept market-price: parcel outside ЕКБ districts → class_norm")
|
||
return None, "class_norm"
|
||
|
||
district_name: str = district_row["district_name"]
|
||
district_reference = district_row["median_price_per_m2"]
|
||
|
||
# 2) Свежайший сигнал — медиана объявлений Objective по району (n>=порог).
|
||
try:
|
||
obj_row = (
|
||
db.execute(
|
||
_OBJECTIVE_MEDIAN_SQL,
|
||
{"dn": district_name, "lo": _PRICE_MIN_RUB, "hi": _PRICE_MAX_RUB},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"concept market-price: objective median failed (district=%s), "
|
||
"trying district reference: %s",
|
||
district_name,
|
||
exc,
|
||
)
|
||
obj_row = None
|
||
|
||
if (
|
||
obj_row
|
||
and obj_row["sample_size"]
|
||
and int(obj_row["sample_size"]) >= _MIN_OBJECTIVE_SAMPLE
|
||
and obj_row["median_ppm2"]
|
||
):
|
||
price = float(obj_row["median_ppm2"])
|
||
logger.info(
|
||
"concept market-price: objective median %.0f руб/м² (district=%s, n=%d)",
|
||
price,
|
||
district_name,
|
||
int(obj_row["sample_size"]),
|
||
)
|
||
return price, "objective_district_median"
|
||
|
||
# 3) Справочная медиана района (24-мес окно ekb_districts).
|
||
if district_reference is not None:
|
||
price = float(district_reference)
|
||
logger.info(
|
||
"concept market-price: district reference %.0f руб/м² (district=%s, n<%d)",
|
||
price,
|
||
district_name,
|
||
_MIN_OBJECTIVE_SAMPLE,
|
||
)
|
||
return price, "district_reference"
|
||
|
||
logger.info(
|
||
"concept market-price: district=%s has no price reference → class_norm", district_name
|
||
)
|
||
return None, "class_norm"
|
||
|
||
|
||
@router.post("", response_model=ConceptOutput)
|
||
async def create_concept(
|
||
payload: ConceptInput,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> ConceptOutput:
|
||
"""Generate 3 building variants for the given parcel polygon.
|
||
|
||
Stage 1a: Shapely parse + buildable area (setback) + placement grid.
|
||
Stage 1b: greedy section placement with STRtree collisions (3 strategies).
|
||
Stage 1c: real ТЭП + financial model attached to each variant. The housing
|
||
sale price is calibrated to real market data (Objective / district reference)
|
||
when available, falling back to the per-class norm with an honest source flag
|
||
(PR-2, эпик #1881).
|
||
|
||
A degenerate parcel (setback consumes everything, malformed geometry) yields a
|
||
422 rather than empty variants — that is a bad request, not a valid empty result.
|
||
|
||
Stage 3a (#1965): если задана ``building_program`` (типовые дома из каталога), кладём
|
||
РОВНО эту программу вместо жадной max-FAR раскладки (один вариант). Неизвестный
|
||
``section_type`` → 422 (валидируется тут, до размещения). Если участок не вмещает всю
|
||
программу — НЕ 422: кладём сколько влезло и отдаём честный ``placed_count`` < requested.
|
||
"""
|
||
# Stage 3a: валидируем ключи программы по каталогу ДО размещения — неизвестный
|
||
# section_type это bad request (422), а не 500 из KeyError в глубине placement.
|
||
if payload.building_program:
|
||
known = available_section_types()
|
||
unknown = sorted({item.section_type for item in payload.building_program} - known)
|
||
if unknown:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=(
|
||
f"unknown house type(s): {', '.join(unknown)}; "
|
||
f"available: {', '.join(sorted(known))}"
|
||
),
|
||
)
|
||
|
||
# Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех
|
||
# стратегий). DB-lookup — синхронный SQLAlchemy → run_in_threadpool, чтобы не
|
||
# блокировать event loop. Падение lookup'а не должно ронять генерацию.
|
||
market_price: float | None = None
|
||
price_source: str = "class_norm"
|
||
try:
|
||
wkt_point = await run_in_threadpool(_parcel_centroid_wkt, payload)
|
||
market_price, price_source = await run_in_threadpool(_lookup_market_price, db, wkt_point)
|
||
except ParcelGeometryError:
|
||
# Невалидная геометрия — пусть geometry.generate поднимет её ниже (один 422).
|
||
pass
|
||
except Exception as exc:
|
||
# Защитный пояс: любая иная ошибка lookup'а → норматив класса, не 500.
|
||
logger.warning("concept market-price: unexpected lookup error, class_norm: %s", exc)
|
||
|
||
try:
|
||
# geometry.generate — синхронный CPU-bound (Shapely/STRtree), мостим через
|
||
# run_in_threadpool, чтобы НЕ блокировать event loop (тот же приём, что и в chat.py).
|
||
variants = await run_in_threadpool(
|
||
geometry.generate,
|
||
payload,
|
||
market_price_per_sqm=market_price,
|
||
price_source=price_source,
|
||
)
|
||
except ParcelGeometryError as exc:
|
||
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-запросе. Источник
|
||
берём из ``payload.price_source`` (ПОДЛИННЫЙ source, который фронт получил из
|
||
financial_estimate — objective_geo_radius / objective_district_median /
|
||
district_reference / class_norm); если фронт его не прислал — дефолтный лейбл.
|
||
НЕ хардкодим source — иначе honest-флаг в UI/PDF врёт о происхождении цены.
|
||
* иначе, если передан ``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.
|
||
# Используем ПОДЛИННЫЙ source, который фронт форвардит из financial_estimate; если
|
||
# его нет — дефолтный лейбл. (При market_price=None compute_financial всё равно
|
||
# форсит class_norm, но сюда мы попадаем только при заданной цене.)
|
||
price_source = payload.price_source or _DEFAULT_PRERESOLVED_SOURCE
|
||
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)
|