coef = 0.95 + score/100*0.10 не был связан с ценой и не участвовал в расчёте estimator'а вовсе (0 упоминаний), но интерфейс рисовал «база -> результат», обещая влияние на цену. Замеры на боевой БД: по 1500 адресам ЕКБ 67% попадают в -1%..+1%, весь город укладывается в размах 1.10x; медиана руб/м2 по бакетам коэффициента плоская и немонотонная (бакет -4% дороже бакета +3%). Для сравнения, расстояние до центра даёт монотонный градиент с размахом 2.70x (93 677 -> 249 686 руб/м2 по 31 тыс. лотов). Новый показатель — отклонение медианы руб/м2 сопоставимых активных листингов в радиусе от медианы по городу (percentile_cont, лестница радиусов до набора выборки). Честная деградация вне ЕКБ и при малой выборке вместо молчаливого вырождения в 0.95. В цену по-прежнему не идёт — аналоги берутся из того же района, локация в базовой цене уже учтена. Заодно две причины потери POI: 1. Overpass-фильтр метро ловил только station=subway, без railway=station+subway=yes. 2. v_tradein_osm_poi_ekb резала всё, что не редактировали в OSM 2 года. Проверено на проде: из 5133 точек доходило 2787 (-46%), причём смещённо — парки -84%, больницы -80%, магазины -83%, остановки 0%. Из 9 станций метро терялись 4, включая Площадь 1905 года. Станция не перестаёт существовать оттого, что её тег два года не трогали; Site Finder использует эту дату как мягкий сигнал уверенности, а не как фильтр существования.
346 lines
14 KiB
Python
346 lines
14 KiB
Python
"""Unit tests for app.services.location_index (replaces test_location_coef.py).
|
||
|
||
No live Postgres needed — DB is a minimal fake returning queued results (mirrors the
|
||
convention in tests/tasks/test_cadastral_geo_match.py / the deleted test_location_coef.py).
|
||
Covers:
|
||
- pure functions: _category_weight, _in_ekb_bbox, _pct_deviation (incl. monotonicity)
|
||
- _fetch_nearby_poi: qualitative POI ranking (unchanged behaviour from the old module)
|
||
- compute_location_index: out-of-coverage degradation, insufficient-sample degradation
|
||
(citywide AND local), radius-ladder expansion, explicit radius_m override, happy path
|
||
- SQL discipline: psycopg v3 CAST, percentile_cont (not naive AVG/MIN/MAX) for outlier
|
||
robustness
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any
|
||
|
||
# psycopg v3 driver required; stub DATABASE_URL before any app import (settings needs a DSN).
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services import location_index as lc
|
||
|
||
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
|
||
_LAT_IN_EKB = 56.838
|
||
_LON_IN_EKB = 60.605
|
||
|
||
# ── Pure functions ──────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_category_weight_known_categories() -> None:
|
||
assert lc._category_weight("metro_stop") == 6.0
|
||
assert lc._category_weight("school") == 5.0
|
||
assert lc._category_weight("kindergarten") == 4.5
|
||
assert lc._category_weight("hospital") == 4.0
|
||
assert lc._category_weight("shop_mall") == 4.0
|
||
assert lc._category_weight("shop_supermarket") == 3.5
|
||
assert lc._category_weight("bus_stop") == 4.5
|
||
assert lc._category_weight("park") == 3.5
|
||
assert lc._category_weight("pharmacy") == 2.5
|
||
assert lc._category_weight("tram_stop") == 2.0
|
||
assert lc._category_weight("shop_small") == 2.0
|
||
|
||
|
||
def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
|
||
assert lc._category_weight("unknown_category") == 1.0
|
||
assert lc._category_weight(None) == 1.0
|
||
|
||
|
||
def test_in_ekb_bbox_center_is_inside() -> None:
|
||
assert lc._in_ekb_bbox(_LAT_IN_EKB, _LON_IN_EKB) is True
|
||
|
||
|
||
def test_in_ekb_bbox_bounds_are_inclusive() -> None:
|
||
assert lc._in_ekb_bbox(56.70, 60.50) is True
|
||
assert lc._in_ekb_bbox(56.95, 60.75) is True
|
||
|
||
|
||
def test_in_ekb_bbox_outside_is_rejected() -> None:
|
||
# Nizhny Tagil — same oblast (region_code=66), well outside the EKB product bbox.
|
||
assert lc._in_ekb_bbox(57.910, 59.970) is False
|
||
# Just past each edge of the bbox.
|
||
assert lc._in_ekb_bbox(56.69, 60.60) is False
|
||
assert lc._in_ekb_bbox(56.96, 60.60) is False
|
||
assert lc._in_ekb_bbox(56.80, 60.49) is False
|
||
assert lc._in_ekb_bbox(56.80, 60.76) is False
|
||
|
||
|
||
def test_pct_deviation_above_and_below_city_median() -> None:
|
||
assert lc._pct_deviation(165_000.0, 150_000.0) == 10.0
|
||
assert lc._pct_deviation(135_000.0, 150_000.0) == -10.0
|
||
assert lc._pct_deviation(150_000.0, 150_000.0) == 0.0
|
||
|
||
|
||
def test_pct_deviation_not_artificially_clamped() -> None:
|
||
"""Owner requirement: a genuinely +40% district must read as +40%, not clamped."""
|
||
assert lc._pct_deviation(210_000.0, 150_000.0) == 40.0
|
||
|
||
|
||
def test_pct_deviation_guards_zero_division() -> None:
|
||
assert lc._pct_deviation(100_000.0, 0.0) == 0.0
|
||
|
||
|
||
def test_pct_deviation_is_monotonic_in_local_median() -> None:
|
||
"""Индекс строго монотонен по локальной медиане при фиксированной городской — в отличие
|
||
от старого coef (немонотонные бакеты на реальных данных, см. модуль docstring).
|
||
|
||
Значения ниже — медианы ₽/м² по дистанционным бакетам от центра ЕКБ, измеренные на 31
|
||
тыс. лотов (аудит владельца продукта), отсортированные по возрастанию. Индекс,
|
||
построенный на этих же локальных медианах, обязан сохранить порядок.
|
||
"""
|
||
city_median = 155_000.0
|
||
local_medians_ascending = [
|
||
93_677.0,
|
||
136_729.0,
|
||
150_063.0,
|
||
159_382.0,
|
||
159_486.0,
|
||
191_682.0,
|
||
249_686.0,
|
||
]
|
||
pct_values = [lc._pct_deviation(m, city_median) for m in local_medians_ascending]
|
||
assert pct_values == sorted(pct_values)
|
||
|
||
|
||
# ── SQL discipline ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_no_psycopg_v3_colon_colon_cast() -> None:
|
||
"""psycopg v3: never :param::type — must use CAST(:param AS type)."""
|
||
import re
|
||
|
||
for sql in (
|
||
lc._MEDIAN_PPM2_LOCAL_SQL,
|
||
lc._MEDIAN_PPM2_CITYWIDE_SQL,
|
||
lc._NEAREST_POI_SQL,
|
||
):
|
||
assert not re.search(r":\w+::", str(sql.text))
|
||
|
||
|
||
def test_median_queries_use_percentile_not_naive_minmax() -> None:
|
||
"""Outlier robustness requirement: percentile_cont(0.5) (median), not AVG/MIN/MAX."""
|
||
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
||
sql_text = str(sql.text).lower()
|
||
assert "percentile_cont(0.5)" in sql_text
|
||
assert "avg(" not in sql_text
|
||
assert "min(" not in sql_text
|
||
assert "max(" not in sql_text
|
||
|
||
|
||
def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None:
|
||
"""Comparable-selection quality control (owner requirement #1): city-centroid geocodes
|
||
excluded (mirrors estimator.py #769 Part E), sample bounded to the EKB bbox."""
|
||
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
||
sql_text = str(sql.text)
|
||
assert "geo_precision IS DISTINCT FROM 'city'" in sql_text
|
||
assert "bbox_south" in sql_text and "bbox_north" in sql_text
|
||
assert "bbox_west" in sql_text and "bbox_east" in sql_text
|
||
|
||
|
||
# ── _fetch_nearby_poi (qualitative "что рядом" list) ─────────────────────────
|
||
|
||
|
||
class _FakeResult:
|
||
def __init__(
|
||
self,
|
||
*,
|
||
scalar_value: Any = None,
|
||
mapping_rows: list[dict] | None = None,
|
||
mapping_one: dict | None = None,
|
||
):
|
||
self._scalar_value = scalar_value
|
||
self._mapping_rows = mapping_rows or []
|
||
self._mapping_one = mapping_one
|
||
|
||
def scalar(self) -> Any:
|
||
return self._scalar_value
|
||
|
||
def mappings(self) -> Any:
|
||
outer = self
|
||
|
||
class _Mappings:
|
||
def all(self) -> list[dict]:
|
||
return outer._mapping_rows
|
||
|
||
def first(self) -> dict | None:
|
||
return outer._mapping_one
|
||
|
||
return _Mappings()
|
||
|
||
|
||
class _FakeDB:
|
||
"""Minimal Session stand-in: execute() returns queued results in order."""
|
||
|
||
def __init__(self, results: list[_FakeResult]) -> None:
|
||
self._results = list(results)
|
||
self.executed: list[Any] = []
|
||
|
||
def execute(self, clause: Any, params: dict | None = None) -> _FakeResult:
|
||
self.executed.append((clause, params))
|
||
return self._results.pop(0)
|
||
|
||
|
||
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
||
db = _FakeDB([_FakeResult(scalar_value=0)])
|
||
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
||
assert poi == []
|
||
assert status == "unavailable"
|
||
assert len(db.executed) == 1 # only the count probe ran
|
||
|
||
|
||
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
|
||
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
|
||
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
||
assert poi == []
|
||
assert status == "ok"
|
||
|
||
|
||
def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None:
|
||
rows = [
|
||
{"name": "Школа №1", "category": "school", "distance_m": 300.0},
|
||
{"name": "ТЦ Мега", "category": "shop_mall", "distance_m": 900.0},
|
||
{"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0},
|
||
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
|
||
]
|
||
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
|
||
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
||
assert status == "ok"
|
||
assert len(poi) == 4
|
||
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
|
||
# distance-only ranking.
|
||
assert poi[0].poi_type == "metro_stop"
|
||
|
||
|
||
def test_fetch_nearby_poi_limits_to_top_n() -> None:
|
||
rows = [
|
||
{"name": f"POI {i}", "category": "shop_small", "distance_m": float(100 + i * 10)}
|
||
for i in range(20)
|
||
]
|
||
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
|
||
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
||
assert len(poi) == 7
|
||
|
||
|
||
# ── compute_location_index: degradation + ladder logic ───────────────────────
|
||
|
||
|
||
def test_compute_location_index_out_of_coverage_skips_all_db_calls() -> None:
|
||
"""Owner requirement #2: point outside EKB → honest 'no data', never a fallback number.
|
||
|
||
Also a perf/honesty check: no DB round-trip at all for an out-of-scope point.
|
||
"""
|
||
db = _FakeDB([])
|
||
result = lc.compute_location_index(db, lat=57.910, lon=59.970) # Nizhny Tagil
|
||
assert result.status == "out_of_coverage"
|
||
assert result.location_index_pct is None
|
||
assert result.local_median_price_per_m2 is None
|
||
assert result.city_median_price_per_m2 is None
|
||
assert result.sample_size == 0
|
||
assert result.nearby_poi == []
|
||
assert result.poi_status == "unavailable"
|
||
assert db.executed == []
|
||
|
||
|
||
def test_compute_location_index_citywide_sample_too_small_short_circuits() -> None:
|
||
"""Degenerate citywide reference (e.g. empty dev DB) → insufficient_data without ever
|
||
issuing a local-radius query (nothing to compare against anyway)."""
|
||
db = _FakeDB(
|
||
[
|
||
_FakeResult(scalar_value=0), # POI mirror empty
|
||
_FakeResult(mapping_one={"median_ppm2": None, "n": 3}), # citywide: n < MIN
|
||
]
|
||
)
|
||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||
assert result.status == "insufficient_data"
|
||
assert result.location_index_pct is None
|
||
assert result.sample_size == 3
|
||
assert len(db.executed) == 2 # poi-count + citywide only — no radius-ladder queries
|
||
|
||
|
||
def test_compute_location_index_first_radius_rung_sufficient() -> None:
|
||
db = _FakeDB(
|
||
[
|
||
_FakeResult(scalar_value=0), # poi mirror empty
|
||
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), # citywide
|
||
_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25}), # radius[0]=800
|
||
]
|
||
)
|
||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||
assert result.status == "ok"
|
||
assert result.radius_m == lc.RADIUS_LADDER_M[0]
|
||
assert result.sample_size == 25
|
||
assert result.local_median_price_per_m2 == 165_000
|
||
assert result.city_median_price_per_m2 == 150_000
|
||
assert result.location_index_pct == 10.0
|
||
assert len(db.executed) == 3 # ladder stopped at rung 1 — no further radius queries
|
||
|
||
|
||
def test_compute_location_index_expands_ladder_when_first_rung_insufficient() -> None:
|
||
db = _FakeDB(
|
||
[
|
||
_FakeResult(scalar_value=0),
|
||
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||
_FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 10}), # 800m: too few
|
||
_FakeResult(mapping_one={"median_ppm2": 180_000.0, "n": 30}), # 1500m: enough
|
||
]
|
||
)
|
||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||
assert result.status == "ok"
|
||
assert result.radius_m == lc.RADIUS_LADDER_M[1]
|
||
assert result.sample_size == 30
|
||
assert len(db.executed) == 4
|
||
|
||
|
||
def test_compute_location_index_insufficient_even_at_max_radius() -> None:
|
||
db = _FakeDB(
|
||
[
|
||
_FakeResult(scalar_value=0),
|
||
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||
_FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 5}), # 800m
|
||
_FakeResult(mapping_one={"median_ppm2": 195_000.0, "n": 12}), # 1500m
|
||
_FakeResult(mapping_one={"median_ppm2": 190_000.0, "n": 15}), # 2500m — still < 20
|
||
]
|
||
)
|
||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||
assert result.status == "insufficient_data"
|
||
assert result.location_index_pct is None
|
||
assert result.local_median_price_per_m2 is None
|
||
assert result.city_median_price_per_m2 == 150_000
|
||
assert result.radius_m == lc.RADIUS_LADDER_M[-1]
|
||
assert result.sample_size == 15 # honest: shows how close it got, not just "no data"
|
||
assert len(db.executed) == 5 # exhausted the full ladder
|
||
|
||
|
||
def test_compute_location_index_explicit_radius_skips_ladder() -> None:
|
||
"""An explicit radius_m must be used AS-IS — no adaptive expansion (caller-controlled)."""
|
||
db = _FakeDB(
|
||
[
|
||
_FakeResult(scalar_value=0),
|
||
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||
_FakeResult(mapping_one={"median_ppm2": 160_000.0, "n": 50}), # single query only
|
||
]
|
||
)
|
||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB, radius_m=1000)
|
||
assert result.status == "ok"
|
||
assert result.radius_m == 1000
|
||
assert len(db.executed) == 3 # exactly one radius query, no ladder rungs tried
|
||
nearest_call_params = db.executed[2][1]
|
||
assert nearest_call_params["radius_m"] == 1000
|
||
|
||
|
||
def test_compute_location_index_poi_unavailable_does_not_block_index() -> None:
|
||
"""poi_status and status degrade INDEPENDENTLY — an empty POI mirror must not prevent a
|
||
perfectly computable price-based index."""
|
||
db = _FakeDB(
|
||
[
|
||
_FakeResult(scalar_value=0), # poi mirror empty
|
||
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||
_FakeResult(mapping_one={"median_ppm2": 172_500.0, "n": 40}),
|
||
]
|
||
)
|
||
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||
assert result.status == "ok"
|
||
assert result.poi_status == "unavailable"
|
||
assert result.nearby_poi == []
|
||
assert result.location_index_pct == 15.0
|