gendesign/tradein-mvp/backend/app/services/location_coef.py
bot-backend 90731da537
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m36s
feat(tradein): location-coef MVP через FDW-мост к OSM POI Птицы (#2045)
Финальный PR issue #2045 (BE-3): GET /api/v1/trade-in/location-coef для
LocationDrawer. FDW foreign table -> локальное зеркало osm_poi_ekb_local
(TRUNCATE+INSERT, тот же паттерн что cad_buildings_local/cadastral_geo_match,
избегает ~1.16s/row FDW round-trip) -> straight-line POI-скоринг, портированный
из Site Finder poi_score.py::compute_poi_weighted_top7 (CATEGORY_WEIGHTS as-is,
радиус 1200м для квартир вместо Ptica 2000м для участков). score->coef -
новая MVP-эвристика (0.95..1.05, не откалибрована на реальных дельтах).

Graceful fallback (не 500, не фабрикуем факторы): пустая/не отрефрешенная
osm_poi_ekb_local или отсутствие lat/lon у оценки -> coef=1.0, factors=[],
geo_source="unavailable".

Scheduler: source=osm_poi_ekb_refresh, daily, зарегистрирован и в боевом
dispatch (scheduler.py), и в kit-registry (product_handlers.py) - иначе
test_kit_registry_completeness падает на ship-dark инварианте (#2192).

Frontend wiring (mappers.ts/LocationDrawer.tsx) - вне scope, отдельная задача
после проверки endpoint'а curl'ом на деплое.
2026-07-03 23:44:55 +03:00

200 lines
7.9 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.

"""Location-coefficient POI scoring for trade-in estimates (#2045 BE-3, LocationDrawer).
Ported straight-line formula from Site Finder (ПТИЦА)
`backend/app/services/site_finder/poi_score.py::compute_poi_weighted_top7`:
weight = (1 / (distance_m + 100)) * CATEGORY_WEIGHTS[category]
Reads from the LOCAL mirror table `osm_poi_ekb_local` (populated by
`app/tasks/osm_poi_ekb_refresh.py` from the `gendesign_osm_poi_ekb` FDW — see migrations
168-170). We deliberately do NOT query the FDW directly per-request: the same per-row cost
measured for the analogous cadastral-buildings FDW (~1.16s/row without a geom index on the
remote) would make a synchronous endpoint unusable.
Scope (MVP, #2045 BE-3):
- Straight-line distance only. The ORS routing-decay mode from Site Finder
(`compute_poi_routing_decay`) is NOT ported — no ORS infrastructure in trade-in, out of
MVP scope.
- Radius tuned for apartments (1000-1500m), NOT Site Finder's 2000m default for land parcels.
- The score→coef mapping (`_score_to_coef`) is a NEW MVP heuristic, not present in Site
Finder (there POI score is a ranking metric, not a price multiplier) — see its docstring.
"""
from __future__ import annotations
import logging
from typing import Any
from pydantic import BaseModel
from sqlalchemy import text
logger = logging.getLogger(__name__)
# Веса по категории — скопированы as-is из Site Finder CATEGORY_WEIGHTS
# (backend/app/services/site_finder/poi_score.py), чтобы ranking POI был согласован
# между продуктами.
CATEGORY_WEIGHTS: dict[str, float] = {
"metro_stop": 6.0,
"school": 5.0,
"kindergarten": 4.5,
"hospital": 4.0,
"shop_mall": 4.0,
"shop_supermarket": 3.5,
"bus_stop": 4.5,
"park": 3.5,
"pharmacy": 2.5,
"tram_stop": 2.0,
"shop_small": 2.0,
"default": 1.0,
}
# Радиус подобран для КВАРТИР (МКД), а не для участков (Ptica default 2000м) —
# пешая доступность в пределах квартала/микрорайона.
DEFAULT_RADIUS_M = 1200
DEFAULT_TOP_N = 7
# Теоретический максимум суммы весов top-7 POI при идеальном расположении (d=0):
# w_i = cat_weight_i / (0 + 100) → max_sum = Σ(top7 cat_weights) / 100.
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica).
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах
# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель).
_COEF_BASE = 0.95
_COEF_SPREAD = 0.10
def _category_weight(category: str | None) -> float:
"""Вернуть вес категории. Если не знаем — default."""
return CATEGORY_WEIGHTS.get(category or "default", CATEGORY_WEIGHTS["default"])
class LocationCoefFactor(BaseModel):
"""Один POI-фактор в ответе location-coef."""
poi_type: str
name: str | None
distance_m: float
weight: float
class LocationCoefResult(BaseModel):
"""Результат compute_location_coef — потребляется эндпоинтом location-coef."""
coef: float
factors: list[LocationCoefFactor]
geo_source: str # "osm_poi_ekb" (норма) | "unavailable" (mirror пуста/не отрефрешена)
def _score_to_coef(poi_weighted_score: float) -> float:
"""MVP-эвристика score(0..100) → ценовой коэффициент.
coef = 0.95 + (score/100) * 0.10 → диапазон [0.95, 1.05].
ВНИМАНИЕ: это НЕ откалиброванная на реальных ценовых дельтах формула — первая рабочая
эвристика для MVP location-coef. Site Finder использует ту же POI-модель как ранжирующую
метрику (poi_weighted_score), а не как прямой ценовой множитель; здесь смысл другой,
поэтому маппинг введён отдельно и явно помечен как heuristic v1.
"""
return round(_COEF_BASE + (poi_weighted_score / 100.0) * _COEF_SPREAD, 4)
_NEAREST_POI_SQL = text(
"""
SELECT
p.name,
p.category,
CAST(
ST_Distance(
p.geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
) AS double precision
) AS distance_m
FROM osm_poi_ekb_local p
WHERE p.geom IS NOT NULL
AND ST_DWithin(
p.geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
CAST(:radius_m AS double precision)
)
ORDER BY distance_m ASC
LIMIT :limit
"""
)
def compute_location_coef(
db: Any,
lat: float,
lon: float,
radius_m: int = DEFAULT_RADIUS_M,
top_n: int = DEFAULT_TOP_N,
) -> LocationCoefResult:
"""Посчитать location-coef для координат (lat, lon) по POI из osm_poi_ekb_local.
Graceful fallback: если osm_poi_ekb_local пуста (рефреш ещё не запускался на этом
окружении) — возвращает coef=1.0, factors=[], geo_source="unavailable" вместо 500 или
сфабрикованных факторов. Отсутствие POI В РАДИУСЕ у непустой таблицы — это легитимный
результат (coef=0.95, factors=[], geo_source="osm_poi_ekb"), не fallback.
Args:
db: SQLAlchemy Session.
lat: широта целевой квартиры.
lon: долгота целевой квартиры.
radius_m: радиус поиска в метрах (default 1200 — подобран для МКД, не для участков).
top_n: количество POI, учитываемых в score (default 7).
"""
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
if total == 0:
logger.warning(
"location_coef: osm_poi_ekb_local is empty (refresh job not yet run on this "
"environment) — returning unavailable fallback, no fabricated factors"
)
return LocationCoefResult(coef=1.0, factors=[], geo_source="unavailable")
rows = (
db.execute(
_NEAREST_POI_SQL,
{"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10},
)
.mappings()
.all()
)
scored: list[tuple[float, LocationCoefFactor]] = []
for row in rows:
distance_m = float(row["distance_m"])
category = row["category"] or "default"
weight = (1.0 / (distance_m + 100.0)) * _category_weight(category)
scored.append(
(
weight,
LocationCoefFactor(
poi_type=category,
name=row["name"],
distance_m=round(distance_m, 1),
weight=round(weight, 6),
),
)
)
scored.sort(key=lambda pair: pair[0], reverse=True)
top_factors = [factor for _weight, factor in scored[:top_n]]
raw_sum = sum(factor.weight for factor in top_factors)
poi_weighted_score = min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0)
coef = _score_to_coef(poi_weighted_score)
logger.debug(
"location_coef: lat=%.5f lon=%.5f radius=%dm poi_found=%d top=%d " "score=%.1f coef=%.4f",
lat,
lon,
radius_m,
len(rows),
len(top_factors),
poi_weighted_score,
coef,
)
return LocationCoefResult(coef=coef, factors=top_factors, geo_source="osm_poi_ekb")