gendesign/tradein-mvp/backend/app/services/location_index.py
lekss361 76a2963edc
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m46s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
Локальный индекс считает медианы по своему региону, а статус точек интереса не врёт (#3517)
2026-09-13 11:14:44 +00:00

550 lines
30 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 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
«Сопоставимые» = ровно тот же пул, что берёт эстиматор (#2660): активные И свежие
(scraped_at в пределах LISTINGS_FRESH_DAYS — `is_active` на проде не равно «живо») И
только вторичка (гард #1186 — девелоперский прайс новостроек завышал обе медианы).
Самообновляем (те же `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
# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте.
# Дублировать значение здесь нельзя: две константы разъедутся при первой же
# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена.
from app.services import regions as regions_mod
from app.services.estimator import LISTINGS_FRESH_DAYS
logger = logging.getLogger(__name__)
# ── Гео-охват продукта ────────────────────────────────────────────────────────
# #3051: границы живут в реестре регионов (app.services.regions), НЕ здесь.
# У региона 66 bbox_product_core байт-в-байт равен прежним литералам этого файла
# (56.70..56.95 / 60.50..60.75, синхронизирован с EKB_BBOX Overpass-загрузчика
# POI основного gendesign-бэкенда — комментарий в обе стороны остаётся в
# реестре). Точка Москвы теперь попадает в покрытие региона 77 — медианы и POI
# считаются в ЕГО ядре (сегодня листингов Москвы нет → честный
# insufficient_data, не ложный out_of_coverage).
def _covered_region(lat: float, lon: float) -> regions_mod.Region | None:
"""Регион, в чьём продукт-ядре лежит точка; None — вне покрытия продукта."""
region = regions_mod.region_for_point(lat, lon)
if region is not None and regions_mod.is_within_bbox(lat, lon, region.bbox_product_core):
return region
return None
# ── Калибровочные константы (радиус / минимальная выборка) ──────────────────
#
# Плотность-прикидка для обоснования порядка величины (НЕ подтверждено живым запросом к
# прод-БД в этом изменении — см. 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 пуста/не отрефрешена
# ДЛЯ ПРОДУКТ-ЯДРА РЕГИОНА точки — не глобальный count(*) по таблице)
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 выше).
#
# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора.
# Вклад у них РАЗНЫЙ, и не тот, на который легко подумать. Прод-разложение
# (2026-08-05, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision):
#
# было (только is_active) 30 222 строк 172 984 ₽/м²
# + только свежесть 11 453 строк 163 363 ₽/м²
# + только сегмент 11 219 строк 147 632 ₽/м²
# стало (оба) 7 715 строк 147 368 ₽/м²
#
# - listing_segment guard (#1186) — ЭТО и есть исправление смещения: из 14.8%
# сдвига городской медианы он даёт 14.7 п.п. Девелоперский прайс новостроек
# завышал и локальную, и городскую медиану. NULL = legacy вторичка до м.011.
# Мертвецы, кстати, живут почти целиком тут же: из 18 769 протухших строк
# пула 15 265 — новостройки, и гард выносит их заодно.
# - scraped_at > NOW() - LISTINGS_FRESH_DAYS — даёт ПОВЕРХ сегмента всего
# 0.18 п.п. Для ЭТОЙ метрики он не коррекция смещения, а СТРАХОВКА на
# будущее (пул совпадает с пулом цены; если завтра протухнет вторичка —
# виджет не соврёт), и страховка не бесплатная: выбрасывает 3 504 вторичных
# строки, из которых 2 724 — живые объявления, отскрейпленные 15-30 дней
# назад. Пул 31%, шум растёт: на центре ЕКБ (r=800) n падает 423 → 86, а
# сам индекс гуляет по выбору окна на 12-14 п.п. (7д +75.7% / 14д +77.0% /
# 21д +79.1% / 30д +64.7%) — при n=86 это в пределах шума выборки медианы.
# Размен «меньше смещения ↔ больше дисперсии» сделан осознанно: старое число
# было предвзятым, новое — шумным, но честным. Окно менять здесь НЕ надо,
# LISTINGS_FRESH_DAYS живёт в estimator.py (см. импорт выше).
#
# НОВЫЙ РЕЖИМ ОТКАЗА (знать обязательно): свежесть связала витрину со здоровьем
# СБОРА. Встанет скрейпинг на LISTINGS_FRESH_DAYS — городская выборка не наберёт
# MIN_SAMPLE_SIZE, и "insufficient_data" прилетит ВСЕМ пользователям разом; до
# этой правки виджет продолжал бы показывать устаревшее число. Учитывая, что
# #2574 — ровно месяц молчаливой поломки сбора, сценарий не гипотетический.
# Деградация честная (прочерк, а не выдуманное число), но она теперь массовая.
#
# Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
#
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО в продукт-ядре региона,
# даже если сам продукт уже скрейпит соседние города/области (city-sweep): географию
# location_index явно ограничил владелец продукта.
#
# region_code (дефект Москвы, прод-замер 13.09.2026): bbox продукт-ядра — геометрический
# прямоугольник, а границы регионов НЕ прямоугольны — в московское ядро (55.55..55.95 /
# 37.30..37.90) попадают Химки/Реутов/Котельники/Люберцы (region_code=50). Внутри ядра 77
# лежало 16 801 лот региона 50 (медиана 251 295 ₽/м²) и 82 719 лотов региона 77 (медиана
# 458 208) — примесь заниженная городскую медиану до 422 222 (7.9%). У областных строк
# `city IS NULL`, фильтровать по имени города нельзя — только по region_code.
#
# СТРОГОЕ равенство, БЕЗ `OR region_code IS NULL`: строка без региона не считается ни за
# один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля — is_active,
# price sanity, geo_precision, свежесть 14 дней, сегмент, bbox ядра 66): 15 858 строк
# проходят фильтры, из них 3 с NULL region_code; медиана с ними 151 101 ₽/м², без них
# 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), статистически ничто для 66. Взамен —
# `region_code IS NULL` НЕ постоянная лазейка того же класса, что и сам дефект: в
# `listings` 16 930 NULL-region строк Avito, 24 из них лежат в bbox Москвы и сегодня не
# проходят по свежести — но могут завтра. Мягкий `OR NULL` тихо открыл бы им дорогу
# обратно в московскую медиану ровно тем же путём, каким туда попадала Московская
# область. Три рубля на 66 — цена закрытия дыры, а не повод её оставить.
_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 scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS integer)
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 scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS integer)
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)
"""
)
_POI_COUNT_IN_REGION_SQL = text(
"""
SELECT count(*) AS n
FROM osm_poi_ekb_local
WHERE 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, region: regions_mod.Region
) -> tuple[float | None, int]:
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
row = (
db.execute(
_MEDIAN_PPM2_LOCAL_SQL,
{
"lat": lat,
"lon": lon,
"radius_m": radius_m,
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
"bbox_east": lon_max,
},
)
.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, region: regions_mod.Region) -> tuple[float | None, int]:
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
row = (
db.execute(
_MEDIAN_PPM2_CITYWIDE_SQL,
{
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
"bbox_east": lon_max,
},
)
.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, region: regions_mod.Region
) -> tuple[list[NearbyPoi], str]:
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
Graceful fallback → ([], "unavailable") вместо 500 или сфабрикованного списка. Дефект
(прод 13.09.2026): гейт раньше смотрел на count(*) ПО ВСЕЙ osm_poi_ekb_local (4 850
строк, все екатеринбургские) — для Москвы это count > 0 (таблица не пуста), и статус
молча становился "ok" с пустым списком, хотя внутри московского продукт-ядра лежит 0
POI. Гейт обязан смотреть на наличие POI В РЕЛЕВАНТНОЙ области (bbox_product_core
региона точки), а не на размер таблицы целиком. Загрузка московских POI — отдельная
задача; здесь только честная деградация тем же статусом "unavailable" (терминология
не расширяется — trade_in.py и так трактует его как «нет данных», см. #2131).
"""
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
in_region = (
db.execute(
_POI_COUNT_IN_REGION_SQL,
{
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
"bbox_east": lon_max,
},
).scalar()
or 0
)
if in_region == 0:
logger.warning(
"location_index: osm_poi_ekb_local has no rows in region=%s product core "
"(empty or refresh job not yet run for this region) — nearby_poi unavailable, "
"no fabricated factors",
region.code,
)
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 надёжен.
"""
region = _covered_region(lat, lon)
if region is None:
logger.info(
"location_index: lat=%.5f lon=%.5f вне продукт-ядра всех регионов "
"покрытия (#3051) — 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, region)
city_median, city_n = _citywide_median_ppm2(db, region)
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, region)
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,
)