All checks were successful
CI / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 11s
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 Trade-In / backend-tests (pull_request) Successful in 52s
The normalization used an unreachable all-POI-at-0m maximum, compressing real scores into ~0.975-0.98 so every address read as "location reduces value". Normalize against a realistic reference distance instead; strong central ~ 1.0, poor locations stay below. Informational only (estimator does not use it). Kept the uncalibrated-heuristic caveat. LOW audit R2 (#7a).
224 lines
10 KiB
Python
224 lines
10 KiB
Python
"""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.
|
||
#
|
||
# ИСТОРИЯ (audit R2 #7a): раньше максимум считался при d=0 (все top-7 категорий прямо у
|
||
# порога) — недостижимо на практике. Аудит показал, что даже сильная центральная локация в
|
||
# Екб (транспорт/садик/супермаркет рядом, школа/метро/ТЦ уже заметно дальше — типичный
|
||
# профиль квартала, реалистичные дистанции по категориям ~80-700м) даёт raw_sum лишь
|
||
# ~0.08-0.095, т.е. score ~25-30/100 при старой нормировке (/100 при d=0) → coef ~0.975-0.98.
|
||
# Сильная локация систематически читалась как "немного снижает стоимость" — знаменатель
|
||
# сжимал реалистичные scores в нижнюю треть шкалы 0-100.
|
||
#
|
||
# ФИКС: нормируем не на d=0, а на РЕФЕРЕНСНОЕ расстояние _REF_DISTANCE_M — top-7 весов,
|
||
# как если бы каждая категория была на этом расстоянии. REF=100м ровно удваивает
|
||
# знаменатель (distance_m + 100): 100+100=200 вместо 0+100=100 → теоретический максимум
|
||
# вдвое ниже прежнего → реалистичные scores вдвое выше: сильный центр ~25-30 → ~50-60
|
||
# (coef ~1.00-1.01), с запасом выше 1.0 для исключительно плотных локаций (все top-7
|
||
# категорий <150м). Слабые/разреженные локации остаются далеко ниже 1.0; пустая
|
||
# osm_poi_ekb_local (рефреш не запускался) по-прежнему даёт coef=1.0 через отдельный
|
||
# graceful-fallback путь в compute_location_coef, эту нормировку не трогающий.
|
||
#
|
||
# ВНИМАНИЕ: REF_DIST — калибровочная константа для MVP-эвристики (см. _score_to_coef),
|
||
# НЕ откалибрована на реальных ценовых дельтах — только на распределении реалистичных POI-
|
||
# профилей, чтобы диапазон [0.95, 1.05] соответствовал интуиции "сильный центр ≈ 1.0".
|
||
#
|
||
# 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])
|
||
_REF_DISTANCE_M: float = 100.0
|
||
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / (
|
||
_REF_DISTANCE_M + 100.0
|
||
) # ≈ 0.1575 (было 0.315 при d=0)
|
||
|
||
# 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")
|