"""Location index for trade-in estimates — replaces the broken `location_coef` (LocationDrawer). ИСТОРИЯ / ПОЧЕМУ ПЕРЕПИСАНО: Старый `location_coef.py` считал `coef = 0.95 + (poi_weighted_score/100) * 0.10` — диапазон жёстко зажат в [0.95, 1.05], без какой-либо калибровки на реальных ценах. Аудит на боевой БД (1500 адресов ЕКБ + 4000 активных лотов) показал: - 67% адресов попадали в −1%…+1%, у ~25% coef был РОВНО 1.0 (score=50) — почти неинформативно, весь город умещался в −4%…+5%; - связи с ценой не было вообще: медиана ₽/м² по бакетам coef плоская и НЕ монотонна (бакет −4% дороже бакета +3%). Для сравнения, расстояние до центра ЕКБ на 31 тыс. лотов даёт чистый монотонный градиент (0-2км 249 686 ₽/м² → 12-13км 93 677 ₽/м², разброс 2.7×) — сигнал в данных есть, просто POI-score его не улавливал (POI ranking ≠ цена). НОВЫЙ ПОКАЗАТЕЛЬ (location index): location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки − медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100 Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую ("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) — медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат искусственно — если район реально на 40% дороже, так и покажет. ЧЕСТНАЯ ДЕГРАДАЦИЯ (см. LocationIndexResult.status): - "out_of_coverage" — точка вне гео-охвата продукта (bbox Екатеринбурга). НЕ 0%, НЕ fallback-число — прочерк на фронте. - "insufficient_data" — даже на максимальном радиусе выборки < MIN_SAMPLE_SIZE сопоставимых активных листингов. Тоже прочерк, а не шум по 3 объявлениям. - "ok" — index надёжен. В ЦЕНУ НЕ ИДЁТ: estimator.py про этот модуль не знает и не должен знать — аналоги уже берутся из того же района (локация учтена в базовой цене через сам подбор сопоставимых объектов), повторное умножение на локационный индекс было бы двойным учётом одного и того же эффекта. POI («что рядом» — школа/метро/остановка) сохранены как ОТДЕЛЬНАЯ качественная справка (`nearby_poi`, ранжирование как раньше в location_coef.py), но больше не участвуют в числовом показателе. """ from __future__ import annotations import logging from typing import Any from pydantic import BaseModel from sqlalchemy import text logger = logging.getLogger(__name__) # ── Гео-охват продукта: только Екатеринбург ────────────────────────────────── # Тот же bbox, что EKB_BBOX в backend/app/services/site_finder/poi_loader.py (main # gendesign backend, Overpass-загрузчик osm_poi_ekb) и что использовался при аудите # (1500 адресов / 4000 активных лотов / 2787 POI, все — "по Екатеринбургу"). tradein-mvp — # отдельный деплой/venv от backend/, поэтому константа продублирована, не импортирована; # при изменении bbox в одном месте — проверить и второе (комментарий в обе стороны). _EKB_BBOX_SOUTH = 56.70 _EKB_BBOX_WEST = 60.50 _EKB_BBOX_NORTH = 56.95 _EKB_BBOX_EAST = 60.75 def _in_ekb_bbox(lat: float, lon: float) -> bool: """True если точка внутри гео-охвата продукта (Екатеринбург).""" return _EKB_BBOX_SOUTH <= lat <= _EKB_BBOX_NORTH and _EKB_BBOX_WEST <= lon <= _EKB_BBOX_EAST # ── Калибровочные константы (радиус / минимальная выборка) ────────────────── # # Плотность-прикидка для обоснования порядка величины (НЕ подтверждено живым запросом к # прод-БД в этом изменении — см. PR description "непроверенное"): ЕКБ-аудит насчитал ~4000 # активных лотов в bbox площадью ~ 27.8км (0.25° широты) × 15.3км (0.25° долготы на широте # 56.8°) ≈ 425 км² → плотность ~9.4 лота/км². Круг радиусом 800м имеет площадь ~2.01 км² → # ожидаемо ~19 лотов при равномерной плотности — близко к MIN_SAMPLE_SIZE=20, т.е. стартовый # радиус разумен для "средней" точки. Плотность в городе крайне неравномерна (центр много # гуще окраин) — поэтому лестница радиусов расширяется, а не фиксированный радиус. RADIUS_LADDER_M: tuple[int, ...] = (800, 1500, 2500) # Ниже этого числа сопоставимых активных листингов медиана — шум, не показатель. # Порог не откалиброван статистически (например через доверительный интервал медианы) — # первая рабочая оценка для MVP. TODO: перепроверить на реальном распределении выборок по # районам ЕКБ (см. "непроверенное" в отчёте задачи). MIN_SAMPLE_SIZE = 20 # Санитарные (НЕ бизнес-калибровочные) границы ₽/м² — отсекают заведомо битые скрейп-строки # (парсинг ошибся на порядок и т.п.), не сужают реальный рынок ЕКБ (там диапазон примерно # 40-400 тыс₽/м², с большим запасом по краям). _PRICE_PER_M2_SANITY_MIN = 20_000 _PRICE_PER_M2_SANITY_MAX = 1_000_000 DEFAULT_POI_RADIUS_M = 1200 # как в старом location_coef.py — подобран для МКД DEFAULT_POI_TOP_N = 7 # Веса по категории POI — те же, что были в location_coef.py (ranking "что рядом", # больше НЕ конвертируются в число, влияющее на индекс). 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, } def _category_weight(category: str | None) -> float: """Вернуть вес категории. Если не знаем — default.""" return CATEGORY_WEIGHTS.get(category or "default", CATEGORY_WEIGHTS["default"]) class NearbyPoi(BaseModel): """Один пункт «что рядом» — качественная справка, НЕ участвует в location_index_pct.""" poi_type: str name: str | None distance_m: float class LocationIndexResult(BaseModel): """Результат compute_location_index — потребляется эндпоинтом location-index.""" status: str # "ok" | "out_of_coverage" | "insufficient_data" location_index_pct: float | None local_median_price_per_m2: int | None city_median_price_per_m2: int | None sample_size: int radius_m: int nearby_poi: list[NearbyPoi] poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена) def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: """% отклонения локальной медианы от городской. Округление до 1 знака — не создаёт ложной точности (исходные данные — шумные скрейп-цены). """ if city_median_ppm2 <= 0: # Защита от деления на ноль при вырожденной городской выборке — не должно # случаться в проде (там ~4000 активных лотов), только в пустой dev-БД. return 0.0 return round((local_median_ppm2 - city_median_ppm2) / city_median_ppm2 * 100.0, 1) # ── SQL: медиана ₽/м² сопоставимых активных листингов ──────────────────────── # # percentile_cont(0.5) — тот же идиом, что уже используется в estimator.py для медианных # ₽/м² трендов (_fetch_price_trend) — устойчив к выбросам В ОТЛИЧИЕ от AVG/min/max: единичный # аномально дорогой/дешёвый лот не сдвигает медиану заметно. # # geo_precision IS DISTINCT FROM 'city' — тот же фильтр, что в estimator.py (#769 Part E): # исключает листинги с геокодом до центра города (city-centroid fallback без номера дома), # которые иначе "подмешивались" бы в любой радиус вокруг центра. # # price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк # (см. _PRICE_PER_M2_SANITY_MIN/MAX выше). # # bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если # сам продукт уже скрейпит соседние города области (city-sweep): географию location_index # явно ограничил владелец продукта. _MEDIAN_PPM2_LOCAL_SQL = text( """ SELECT CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision) AS median_ppm2, count(*) AS n FROM listings WHERE is_active = true AND price_per_m2 IS NOT NULL AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) AND (geo_precision IS DISTINCT FROM 'city') AND lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) AND CAST(:bbox_east AS double precision) AND ST_DWithin( geom::geography, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, CAST(:radius_m AS double precision) ) """ ) _MEDIAN_PPM2_CITYWIDE_SQL = text( """ SELECT CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision) AS median_ppm2, count(*) AS n FROM listings WHERE is_active = true AND price_per_m2 IS NOT NULL AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) AND (geo_precision IS DISTINCT FROM 'city') AND lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) AND CAST(:bbox_east AS double precision) """ ) _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 _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[float | None, int]: row = ( db.execute( _MEDIAN_PPM2_LOCAL_SQL, { "lat": lat, "lon": lon, "radius_m": radius_m, "price_min": _PRICE_PER_M2_SANITY_MIN, "price_max": _PRICE_PER_M2_SANITY_MAX, "bbox_south": _EKB_BBOX_SOUTH, "bbox_north": _EKB_BBOX_NORTH, "bbox_west": _EKB_BBOX_WEST, "bbox_east": _EKB_BBOX_EAST, }, ) .mappings() .first() ) if row is None: return None, 0 median = row["median_ppm2"] return (float(median) if median is not None else None), int(row["n"] or 0) def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]: row = ( db.execute( _MEDIAN_PPM2_CITYWIDE_SQL, { "price_min": _PRICE_PER_M2_SANITY_MIN, "price_max": _PRICE_PER_M2_SANITY_MAX, "bbox_south": _EKB_BBOX_SOUTH, "bbox_north": _EKB_BBOX_NORTH, "bbox_west": _EKB_BBOX_WEST, "bbox_east": _EKB_BBOX_EAST, }, ) .mappings() .first() ) if row is None: return None, 0 median = row["median_ppm2"] return (float(median) if median is not None else None), int(row["n"] or 0) def _fetch_nearby_poi( db: Any, lat: float, lon: float, radius_m: int, top_n: int ) -> tuple[list[NearbyPoi], str]: """Top-N POI поблизости — качественная справка «что рядом», не числовой показатель. Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении) → ([], "unavailable") вместо 500 или сфабрикованного списка. """ total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0 if total == 0: logger.warning( "location_index: osm_poi_ekb_local is empty (refresh job not yet run on this " "environment) — nearby_poi unavailable, no fabricated factors" ) return [], "unavailable" rows = ( db.execute( _NEAREST_POI_SQL, {"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10}, ) .mappings() .all() ) ranked: list[tuple[float, NearbyPoi]] = [] 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) ranked.append( ( weight, NearbyPoi(poi_type=category, name=row["name"], distance_m=round(distance_m, 1)), ) ) ranked.sort(key=lambda pair: pair[0], reverse=True) return [poi for _weight, poi in ranked[:top_n]], "ok" def compute_location_index( db: Any, lat: float, lon: float, *, radius_m: int | None = None, poi_radius_m: int = DEFAULT_POI_RADIUS_M, poi_top_n: int = DEFAULT_POI_TOP_N, ) -> LocationIndexResult: """Посчитать location index для координат (lat, lon). location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки − медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100. Радиус — лестница RADIUS_LADDER_M (расширяется, пока выборка не наберёт MIN_SAMPLE_SIZE), если явный radius_m не передан (тогда используется РОВНО он, без расширения — для отладки/тестов). Args: db: SQLAlchemy Session. lat: широта целевой точки. lon: долгота целевой точки. radius_m: явный радиус в метрах — если задан, лестница не используется. poi_radius_m: радиус для качественного списка «что рядом» (независим от индекса). poi_top_n: сколько POI показать в «что рядом». Returns: LocationIndexResult со status: - "out_of_coverage" — точка вне bbox Екатеринбурга, ничего не считаем. - "insufficient_data" — даже на максимальном радиусе сопоставимых листингов меньше MIN_SAMPLE_SIZE (или городская выборка-эталон сама вырождена). - "ok" — location_index_pct надёжен. """ if not _in_ekb_bbox(lat, lon): logger.info( "location_index: lat=%.5f lon=%.5f outside EKB coverage bbox — out_of_coverage", lat, lon, ) return LocationIndexResult( status="out_of_coverage", location_index_pct=None, local_median_price_per_m2=None, city_median_price_per_m2=None, sample_size=0, radius_m=radius_m or RADIUS_LADDER_M[0], nearby_poi=[], poi_status="unavailable", ) nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n) city_median, city_n = _citywide_median_ppm2(db) if city_median is None or city_n < MIN_SAMPLE_SIZE: logger.warning( "location_index: citywide reference sample too small (n=%d) — insufficient_data", city_n, ) return LocationIndexResult( status="insufficient_data", location_index_pct=None, local_median_price_per_m2=None, city_median_price_per_m2=(round(city_median) if city_median is not None else None), sample_size=city_n, radius_m=radius_m or RADIUS_LADDER_M[-1], nearby_poi=nearby_poi, poi_status=poi_status, ) radii = [radius_m] if radius_m is not None else list(RADIUS_LADDER_M) local_median: float | None = None sample_size = 0 used_radius = radii[-1] for r in radii: local_median, sample_size = _local_median_ppm2(db, lat, lon, r) used_radius = r if sample_size >= MIN_SAMPLE_SIZE: break if local_median is None or sample_size < MIN_SAMPLE_SIZE: logger.info( "location_index: lat=%.5f lon=%.5f sample=%d < MIN_SAMPLE_SIZE=%d up to " "radius=%dm — insufficient_data", lat, lon, sample_size, MIN_SAMPLE_SIZE, used_radius, ) return LocationIndexResult( status="insufficient_data", location_index_pct=None, local_median_price_per_m2=None, city_median_price_per_m2=round(city_median), sample_size=sample_size, radius_m=used_radius, nearby_poi=nearby_poi, poi_status=poi_status, ) pct = _pct_deviation(local_median, city_median) logger.debug( "location_index: lat=%.5f lon=%.5f radius=%dm n=%d local=%d city=%d pct=%.1f", lat, lon, used_radius, sample_size, round(local_median), round(city_median), pct, ) return LocationIndexResult( status="ok", location_index_pct=pct, local_median_price_per_m2=round(local_median), city_median_price_per_m2=round(city_median), sample_size=sample_size, radius_m=used_radius, nearby_poi=nearby_poi, poi_status=poi_status, )