"""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")