Заменяет хардкод-цену продажи жилья (класс-норма) на реальную рыночную медиану из Objective по локации участка. compute_financial остаётся ЧИСТЫМ (без БД) — DB-lookup в API-слое, цена прокидывается параметром. - compute_financial: +optional market_price_per_sqm/price_source; цена = рынок если есть, иначе класс-норма. Паркинг/СМР НЕ калибруем (только жильё). - concepts.py _lookup_market_price(db, lon, lat): центроид → ближайший ekb_district (ST_DWithin 5км) → медиана objective_lots.price_per_m2_rub (n>=10, фильтр 30k-600k) → fallback ekb_districts.median_price_per_m2 → None. try/except → graceful (None, class_norm) при любой ошибке (вне ЕКБ/ нет гео/SQL) без краха генерации. psycopg3 CAST. - FinancialModel +price_per_sqm_used/price_is_calibrated/price_source (additive). - Threading market_price через generate→placement→compute_financial (optional kwargs, backward-compat). - UI/CSV: честный caption источника (рынок Objective / справочник района / норматив класса). Старый лживый footnote «не калиброванная модель» → условный. Prod-verified: калибруется 4 главных ЕКБ-района по name-match (Академический 204k лотов, Ленинский 38k, Кировский, Орджоникидзевский); остальные 5 admin- районов честно → district_reference. Гео-радиус matching (полное покрытие) — follow-up. api-types.ts регенерён авторитетно. mypy strict clean (generative.*), +14 тестов (калибровка/lookup 4 ветки/SQL-ошибки graceful/backward-compat). Refs #1881
212 lines
9.7 KiB
Python
212 lines
9.7 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
|
||
from app.services.generative import geometry
|
||
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
|
||
|
||
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
|
||
|
||
# Район ЕКБ (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.
|
||
"""
|
||
# Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех
|
||
# стратегий). 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)
|