22 города Московской области в покрытии #3536
4 changed files with 231 additions and 68 deletions
|
|
@ -2844,6 +2844,46 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
|
|||
COVERAGE_GREEN_MIN_N = 8
|
||||
COVERAGE_YELLOW_MIN_N = 12
|
||||
|
||||
# Московская область (регион 50) добавлена 2026-09-16 — все 22 города сразу в
|
||||
# жёлтый тир, той же метрикой, которой выбирали тир Москве. Замер: 120 проб на
|
||||
# город, радиус 8 км от центра, когорта = is_active + точные комнаты + площадь
|
||||
# +-15% + scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты по
|
||||
# городам области 17-51, доля проб с когортой >= 12 равна 0.63-0.92 (Видное
|
||||
# 51/0.83, Балашиха 40/0.88, Железнодорожный 39/0.92, Люберцы 37/0.87,
|
||||
# Электросталь 35/0.83, Химки 30/0.74, Домодедово 30/0.79, Ногинск 29/0.85,
|
||||
# Реутов 28/0.82, Долгопрудный 27/0.72, Красногорск 27/0.77, Одинцово 27/0.78,
|
||||
# Подольск 26/0.83, Сергиев Посад 25/0.79, Королёв 24/0.79, Мытищи 23/0.73,
|
||||
# Орехово-Зуево 22/0.73, Серпухов 22/0.66, Раменское 21/0.68,
|
||||
# Наро-Фоминск 20/0.73, Щёлково 19/0.73, Коломна 17/0.63). Контроль той же
|
||||
# метрикой: Москва (уже жёлтая) — медиана 14, доля 0.57; Нижний Тагил (тоже
|
||||
# жёлтый) — медиана 11, доля 0.473. То есть КАЖДЫЙ город области плотнее уже
|
||||
# принятого жёлтого эталона (Москвы) — зелёный тир не рассматривался, порог
|
||||
# 12 достаточен и единообразен. Порядок — по убыванию медианы когорты.
|
||||
COVERAGE_MO_CITIES = (
|
||||
"Видное",
|
||||
"Балашиха",
|
||||
"Железнодорожный",
|
||||
"Люберцы",
|
||||
"Электросталь",
|
||||
"Химки",
|
||||
"Домодедово",
|
||||
"Ногинск",
|
||||
"Реутов",
|
||||
"Долгопрудный",
|
||||
"Красногорск",
|
||||
"Одинцово",
|
||||
"Подольск",
|
||||
"Сергиев Посад",
|
||||
"Королёв",
|
||||
"Мытищи",
|
||||
"Орехово-Зуево",
|
||||
"Серпухов",
|
||||
"Раменское",
|
||||
"Наро-Фоминск",
|
||||
"Щёлково",
|
||||
"Коломна",
|
||||
)
|
||||
|
||||
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
|
||||
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
|
||||
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
|
||||
|
|
@ -3040,6 +3080,7 @@ def _fold_city(name: str) -> str:
|
|||
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
||||
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
|
||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
|
||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_MO_CITIES},
|
||||
**{
|
||||
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
|
||||
for k in COVERAGE_MOSCOW_CENTROID_KEYS
|
||||
|
|
@ -3086,6 +3127,36 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
|||
"Первоуральск": (56.9083, 59.9483),
|
||||
"Ревда": (56.7986, 59.9298),
|
||||
"Серов": (59.6047, 60.5772),
|
||||
# Московская область (регион 50), 22 города — добавлены 2026-09-16 вместе с
|
||||
# COVERAGE_MO_CITIES (см. обоснование порога там же). Координаты для 12 из
|
||||
# 22 перенесены как есть из `_COVERAGE_NEGATIVE_CENTROIDS_DEG` (эти города
|
||||
# раньше были ТОЛЬКО отрицательными точками московской сетки — конкурс
|
||||
# ближайшего центроида без порога, теперь у них есть и собственный порог).
|
||||
# Оставшиеся 10 взяты из `MOSCOW_OBLAST_CITY_ANCHORS` в
|
||||
# packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py — те же
|
||||
# координаты, что использует скрейпер (резолв Nominatim).
|
||||
"Видное": (55.5519, 37.7133),
|
||||
"Балашиха": (55.7969, 37.9386),
|
||||
"Железнодорожный": (55.7444, 38.0128),
|
||||
"Люберцы": (55.6767, 37.8931),
|
||||
"Электросталь": (55.7845, 38.4449),
|
||||
"Химки": (55.8894, 37.4450),
|
||||
"Домодедово": (55.4368, 37.7681),
|
||||
"Ногинск": (55.8554, 38.4412),
|
||||
"Реутов": (55.7614, 37.8564),
|
||||
"Долгопрудный": (55.9386, 37.5100),
|
||||
"Красногорск": (55.8317, 37.3300),
|
||||
"Одинцово": (55.6789, 37.2639),
|
||||
"Подольск": (55.4312, 37.5450),
|
||||
"Сергиев Посад": (56.3153, 38.1360),
|
||||
"Королёв": (55.9142, 37.8256),
|
||||
"Мытищи": (55.9116, 37.7308),
|
||||
"Орехово-Зуево": (55.8062, 38.9810),
|
||||
"Серпухов": (54.9155, 37.4196),
|
||||
"Раменское": (55.5709, 38.2282),
|
||||
"Наро-Фоминск": (55.3843, 36.7235),
|
||||
"Щёлково": (55.9206, 37.9916),
|
||||
"Коломна": (55.0939, 38.7670),
|
||||
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
|
||||
# Как сетка получена, почему точек именно столько, где проходит граница
|
||||
# с областью и какова остаточная цена — см. большой комментарий над
|
||||
|
|
@ -3127,30 +3198,18 @@ _COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
|||
"Поварово": (56.0667, 37.0667),
|
||||
"Дедовск": (55.8672, 37.1200),
|
||||
"Нахабино": (55.8500, 37.1833),
|
||||
"Реутов": (55.7614, 37.8564),
|
||||
"Подольск": (55.4312, 37.5450),
|
||||
"Апрелевка": (55.5500, 37.0700),
|
||||
"Немчиновка": (55.7050, 37.3450),
|
||||
"Химки": (55.8894, 37.4450),
|
||||
"Одинцово": (55.6789, 37.2639),
|
||||
"Лобня": (56.0100, 37.4750),
|
||||
"Мытищи": (55.9116, 37.7308),
|
||||
"Котельники": (55.6553, 37.8619),
|
||||
"Красногорск": (55.8317, 37.3300),
|
||||
"Люберцы": (55.6767, 37.8931),
|
||||
"Дзержинский": (55.6294, 37.8500),
|
||||
"Развилка": (55.5842, 37.7392),
|
||||
"Климовск": (55.3667, 37.5333),
|
||||
"Балашиха": (55.7969, 37.9386),
|
||||
"Истра": (55.9167, 36.8667),
|
||||
"Долгопрудный": (55.9386, 37.5100),
|
||||
"Королёв": (55.9142, 37.8256),
|
||||
"Видное": (55.5519, 37.7133),
|
||||
"Селятино": (55.5081, 36.9825),
|
||||
"Томилино": (55.6528, 37.9472),
|
||||
"Некрасовский": (56.0500, 37.5500),
|
||||
"Барвиха": (55.7333, 37.2333),
|
||||
"Железнодорожный": (55.7444, 38.0128),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -535,19 +535,14 @@ def test_sverdlovsk_resolution_untouched_by_moscow_centroids(
|
|||
|
||||
# Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов
|
||||
# (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву».
|
||||
#
|
||||
# 2026-09-16: 12 из исходных 16 (Химки, Мытищи, Люберцы, Балашиха, Красногорск,
|
||||
# Одинцово, Подольск, Реутов, Королёв, Долгопрудный, Видное, Железнодорожный)
|
||||
# получили собственный порог покрытия — см. COVERAGE_MO_CITIES в
|
||||
# app/api/v1/trade_in.py — и больше не резолвятся в «город не определён».
|
||||
# Здесь остаются только 4 города, которые НЕ замерялись и остаются
|
||||
# отрицательными точками.
|
||||
_MOSCOW_OBLAST_CITIES = {
|
||||
"Химки": (55.8894, 37.4450),
|
||||
"Мытищи": (55.9116, 37.7308),
|
||||
"Люберцы": (55.6767, 37.8931),
|
||||
"Балашиха": (55.7969, 37.9386),
|
||||
"Красногорск": (55.8317, 37.3300),
|
||||
"Одинцово": (55.6789, 37.2639),
|
||||
"Подольск": (55.4312, 37.5450),
|
||||
"Реутов": (55.7614, 37.8564),
|
||||
"Королёв": (55.9142, 37.8256),
|
||||
"Долгопрудный": (55.9386, 37.5100),
|
||||
"Видное": (55.5519, 37.7133),
|
||||
"Железнодорожный": (55.7444, 38.0128),
|
||||
"Лобня": (56.0100, 37.4750),
|
||||
"Дзержинский": (55.6294, 37.8500),
|
||||
"Котельники": (55.6553, 37.8619),
|
||||
|
|
@ -576,7 +571,11 @@ def test_every_negative_centroid_resolves_to_no_city() -> None:
|
|||
_resolve_coverage_city,
|
||||
)
|
||||
|
||||
assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 32
|
||||
# 32 - 12: 12 городов (Реутов, Подольск, Химки, Одинцово, Мытищи,
|
||||
# Красногорск, Люберцы, Балашиха, Долгопрудный, Королёв, Видное,
|
||||
# Железнодорожный) переехали в позитивный список 2026-09-16 —
|
||||
# COVERAGE_MO_CITIES, см. app/api/v1/trade_in.py.
|
||||
assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 20
|
||||
for name, (lat, lon) in _COVERAGE_NEGATIVE_CENTROIDS_DEG.items():
|
||||
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name
|
||||
|
||||
|
|
@ -618,12 +617,17 @@ def test_moscow_itself_survives_negative_centroids(trade_in_app: FastAPI) -> Non
|
|||
|
||||
|
||||
def test_moscow_oblast_city_does_not_500_on_the_endpoint(trade_in_app: FastAPI) -> None:
|
||||
"""Химки через ручку: 200 и not_covered, а не 500 от KeyError по порогу."""
|
||||
"""Истра через ручку: 200 и not_covered, а не 500 от KeyError по порогу.
|
||||
|
||||
Была "Химки" до 2026-09-16 — теперь Химки сама покрытый город
|
||||
(COVERAGE_MO_CITIES), взята следующая точка, которая всё ещё "не
|
||||
определена" (см. комментарий над _MOSCOW_OBLAST_CITIES).
|
||||
"""
|
||||
db = _db_mock_returning(_row(40, 30.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
lat, lon = _MOSCOW_OBLAST_CITIES["Химки"]
|
||||
lat, lon = _MOSCOW_OBLAST_CITIES["Истра"]
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
|
|
@ -678,6 +682,7 @@ def test_sverdlovsk_resolution_untouched_by_negative_centroids(
|
|||
"""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
|
||||
mo_cities_folded = {trade_in_module._fold_city(c) for c in trade_in_module.COVERAGE_MO_CITIES}
|
||||
sverdlovsk = {
|
||||
k: v
|
||||
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
||||
|
|
@ -685,6 +690,7 @@ def test_sverdlovsk_resolution_untouched_by_negative_centroids(
|
|||
not in {
|
||||
trade_in_module._fold_city(m) for m in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
||||
}
|
||||
and trade_in_module._fold_city(k) not in mo_cities_folded
|
||||
}
|
||||
assert len(sverdlovsk) == 9
|
||||
|
||||
|
|
@ -753,22 +759,26 @@ _MOSCOW_CONTROLS = {
|
|||
"Троицк (ТАО)": (55.4853, 37.3050),
|
||||
}
|
||||
|
||||
# Областной контрольный набор (32 города). 16 из них имеют собственный
|
||||
# отрицательный центроид и выигрывают им; остальные 16 держатся только
|
||||
# радиусом московских точек — включая Наро-Фоминск (9.95 км до ближайшей
|
||||
# точки сетки), Кубинку (17.2) и Чехов (22.3), которые при глобальных 25 км
|
||||
# резолвились как «Москва». Это и есть дефект #1.
|
||||
# Областной контрольный набор. Изначально 32 города (16 из _MOSCOW_OBLAST_CITIES
|
||||
# + 16 ниже) — часть держалась только радиусом московских точек, включая
|
||||
# Наро-Фоминск (9.95 км до ближайшей точки сетки), Кубинку (17.2) и Чехов
|
||||
# (22.3), которые при глобальных 25 км резолвились как «Москва». Это и есть
|
||||
# дефект #1.
|
||||
#
|
||||
# 2026-09-16: Наро-Фоминск, Домодедово, Жуковский, Щёлково и Пушкино убраны
|
||||
# из набора. Наро-Фоминск/Домодедово/Щёлково сами стали покрытыми городами
|
||||
# (COVERAGE_MO_CITIES). Жуковский и Пушкино не входят в COVERAGE_MO_CITIES, но
|
||||
# оказались внутри 25-километрового радиуса новых соседних центроидов
|
||||
# (Раменское и Королёв соответственно) и больше не резолвятся в «город не
|
||||
# определён». Пушкино используется отдельно (см. `_PUSHKINO_COORDS`) —
|
||||
# test_phantom_exclave_no_longer_gives_moscow_to_pushkino проверяет именно то,
|
||||
# что ближайший центроид для него теперь Королёв, а не пустота.
|
||||
_MOSCOW_OBLAST_CONTROLS = {
|
||||
**_MOSCOW_OBLAST_CITIES,
|
||||
"Наро-Фоминск": (55.3869, 36.7331),
|
||||
"Кубинка": (55.5794, 36.7000),
|
||||
"Чехов": (55.1477, 37.4770),
|
||||
"Голицыно": (55.6167, 36.9833),
|
||||
"Лыткарино": (55.5833, 37.9000),
|
||||
"Домодедово": (55.4400, 37.7594),
|
||||
"Жуковский": (55.5952, 38.1200),
|
||||
"Щёлково": (55.9200, 38.0000),
|
||||
"Пушкино": (56.0100, 37.8500),
|
||||
"Звенигород": (55.7300, 36.8500),
|
||||
"Апрелевка": (55.5500, 37.0700),
|
||||
"Селятино": (55.5081, 36.9825),
|
||||
|
|
@ -778,6 +788,10 @@ _MOSCOW_OBLAST_CONTROLS = {
|
|||
"Барвиха": (55.7333, 37.2333),
|
||||
}
|
||||
|
||||
# Пушкино вынесено из _MOSCOW_OBLAST_CONTROLS 2026-09-16 (см. комментарий
|
||||
# выше) — используется test_phantom_exclave_no_longer_gives_moscow_to_pushkino.
|
||||
_PUSHKINO_COORDS = (56.0100, 37.8500)
|
||||
|
||||
# Точки, добавленные замером 11.09.2026: жилые районы, целиком проигрывавшие
|
||||
# конкурс подмосковной точке. Проверяются по координатам из самого модуля,
|
||||
# ключ здесь — только адрес строки.
|
||||
|
|
@ -854,10 +868,10 @@ def test_whole_moscow_control_set_answers_through_the_endpoint(trade_in_app: Fas
|
|||
|
||||
|
||||
def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastAPI) -> None:
|
||||
"""Все 32 областных контроля → «город не определён», и ручка отдаёт 200."""
|
||||
"""Все 15 областных контролей → «город не определён», и ручка отдаёт 200."""
|
||||
from app.api.v1.trade_in import _resolve_coverage_city
|
||||
|
||||
assert len(_MOSCOW_OBLAST_CONTROLS) == 32
|
||||
assert len(_MOSCOW_OBLAST_CONTROLS) == 15
|
||||
failed = {
|
||||
name: _resolve_coverage_city(lat, lon)
|
||||
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items()
|
||||
|
|
@ -878,15 +892,18 @@ def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastA
|
|||
|
||||
|
||||
def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
|
||||
"""Дефект #1 предметно: Наро-Фоминск, Кубинка, Чехов.
|
||||
"""Дефект #1 предметно: Кубинка, Чехов.
|
||||
|
||||
С одним глобальным радиусом 25 км круги плотной сетки складывались, и все
|
||||
трое получали «Москву», хотя до ближайшей точки сетки им 9.95, 17.2 и
|
||||
22.3 км. С радиусом-свойством (8 км у московских точек) — «не определён».
|
||||
С одним глобальным радиусом 25 км круги плотной сетки складывались, и обе
|
||||
получали «Москву», хотя до ближайшей точки сетки им 17.2 и 22.3 км. С
|
||||
радиусом-свойством (8 км у московских точек) — «не определён».
|
||||
|
||||
Наро-Фоминск убран из проверки 2026-09-16: сам стал покрытым городом
|
||||
(COVERAGE_MO_CITIES), дефект #1 больше не демонстрирует.
|
||||
"""
|
||||
from app.api.v1.trade_in import _resolve_coverage_city
|
||||
|
||||
for name in ("Наро-Фоминск", "Кубинка", "Чехов"):
|
||||
for name in ("Кубинка", "Чехов"):
|
||||
lat, lon = _MOSCOW_OBLAST_CONTROLS[name]
|
||||
assert _legacy_resolve(lat, lon) == ("Москва", 12, True), (
|
||||
f"{name}: тест перестал воспроизводить дефект, сравнивать не с чем"
|
||||
|
|
@ -897,13 +914,15 @@ def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
|
|||
def test_radius_is_a_property_of_the_centroid_not_a_global_constant() -> None:
|
||||
"""Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской.
|
||||
|
||||
Наро-Фоминск в 9.95 км от ближайшей точки московской сетки — «не
|
||||
определён». Точка в 20 км южнее центра Екатеринбурга (то есть ДАЛЬШЕ) —
|
||||
«Екатеринбург». Глобальной константой такое поведение невыразимо.
|
||||
Кубинка в 17.2 км от ближайшей точки московской сетки — «не определён»
|
||||
(была "Наро-Фоминск" до 2026-09-16 — тот сам стал покрытым городом,
|
||||
COVERAGE_MO_CITIES). Точка в 20 км южнее центра Екатеринбурга (то есть
|
||||
ДАЛЬШЕ) — «Екатеринбург». Глобальной константой такое поведение
|
||||
невыразимо.
|
||||
"""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
|
||||
lat, lon = _MOSCOW_OBLAST_CONTROLS["Наро-Фоминск"]
|
||||
lat, lon = _MOSCOW_OBLAST_CONTROLS["Кубинка"]
|
||||
km_to_grid = min(
|
||||
trade_in_module._haversine_km(lat, lon, glat, glon)
|
||||
for glat, glon in trade_in_module._MOSCOW_GRID_DEG.values()
|
||||
|
|
@ -942,10 +961,12 @@ def test_sverdlovsk_resolution_identical_to_one_global_radius() -> None:
|
|||
moscow_keys = {
|
||||
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
||||
}
|
||||
mo_cities_folded = {trade_in_module._fold_city(c) for c in trade_in_module.COVERAGE_MO_CITIES}
|
||||
sverdlovsk = {
|
||||
k: v
|
||||
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
||||
if trade_in_module._fold_city(k) not in moscow_keys
|
||||
and trade_in_module._fold_city(k) not in mo_cities_folded
|
||||
}
|
||||
assert len(sverdlovsk) == 9
|
||||
for clat, clon in sverdlovsk.values():
|
||||
|
|
@ -973,14 +994,18 @@ def test_phantom_exclave_no_longer_gives_moscow_to_pushkino(
|
|||
) -> None:
|
||||
"""Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой».
|
||||
|
||||
Возвращаем призрак в таблицу — Пушкино снова резолвится в «Москву»: это
|
||||
доказывает, что дефект чинит именно выброс точки, а не что-нибудь ещё.
|
||||
2026-09-16: Королёв (COVERAGE_MO_CITIES) стал позитивным центроидом в
|
||||
~10.76 км от Пушкино — ближе прежнего "not_covered", поэтому базовый
|
||||
ответ без призрака теперь «Королёв», а не пустота. Возвращаем призрак в
|
||||
таблицу — он в 3.06 км, ближе Королёва, и Пушкино снова резолвится в
|
||||
«Москву»: это по-прежнему доказывает, что дефект чинит именно выброс
|
||||
точки, а не что-нибудь ещё.
|
||||
"""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
|
||||
pushkino = _MOSCOW_OBLAST_CONTROLS["Пушкино"]
|
||||
pushkino = _PUSHKINO_COORDS
|
||||
assert trade_in_module._resolve_coverage_city(*_PHANTOM_EXCLAVE) != ("Москва", 12, True)
|
||||
assert trade_in_module._resolve_coverage_city(*pushkino) == ("", 0, False)
|
||||
assert trade_in_module._resolve_coverage_city(*pushkino) == ("Королёв", 12, True)
|
||||
|
||||
ghost_key = "Москва (Восточный, эксклав)"
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -1017,12 +1042,31 @@ def test_added_grid_points_return_districts_lost_to_negative_centroids(
|
|||
) -> None:
|
||||
"""Каждая добавленная точка что-то возвращает: без неё район теряется.
|
||||
|
||||
Для всех 12 точек: в самой точке резолвер отдаёт «Москву», а если убрать
|
||||
её из таблицы — «город не определён» (район уходит подмосковному центру).
|
||||
Мёртвых строк в добавке нет.
|
||||
Для всех 12 точек: в самой точке резолвер отдаёт «Москву». Без неё точка
|
||||
проигрывает следующему ближайшему центроиду — раньше это всегда было
|
||||
«город не определён», но с 2026-09-16 (COVERAGE_MO_CITIES) для 9 из 12
|
||||
точек следующий ближайший — новый позитивный центроид подмосковного
|
||||
города, а не пустота. Инвариант, который всё ещё держит тест: ни одна
|
||||
точка не деградирует ОБРАТНО в «Москву» — иначе строка была бы мёртвой.
|
||||
"""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
|
||||
expected_fallback = {
|
||||
"Москва (Восточный)": ("Балашиха", 12, True),
|
||||
"Москва (Левобережный)": ("Химки", 12, True),
|
||||
"Москва (Орехово-Борисово Южное)": ("", 0, False),
|
||||
"Москва (Северный)": ("Долгопрудный", 12, True),
|
||||
"Москва (Митино-запад)": ("Красногорск", 12, True),
|
||||
"Москва (Ивановское)": ("Реутов", 12, True),
|
||||
"Москва (Жулебино)": ("Люберцы", 12, True),
|
||||
"Москва (Можайский-запад)": ("", 0, False),
|
||||
"Москва (Новокосино)": ("Реутов", 12, True),
|
||||
"Москва (Капотня)": ("", 0, False),
|
||||
"Москва (Куркино)": ("Химки", 12, True),
|
||||
"Москва (Некрасовка-восток)": ("Люберцы", 12, True),
|
||||
}
|
||||
assert set(expected_fallback) == set(_ADDED_GRID_KEYS)
|
||||
|
||||
full = trade_in_module._CITY_CENTROIDS_DEG
|
||||
for key in _ADDED_GRID_KEYS:
|
||||
lat, lon = full[key]
|
||||
|
|
@ -1032,9 +1076,11 @@ def test_added_grid_points_return_districts_lost_to_negative_centroids(
|
|||
"_CITY_CENTROIDS_DEG",
|
||||
{k: v for k, v in full.items() if k != key},
|
||||
)
|
||||
assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False), (
|
||||
f"{key} ничего не возвращает — строка мёртвая"
|
||||
fallback = trade_in_module._resolve_coverage_city(lat, lon)
|
||||
assert fallback == expected_fallback[key], (
|
||||
f"{key}: ожидали {expected_fallback[key]}, получили {fallback}"
|
||||
)
|
||||
assert fallback[0] != "Москва", f"{key} деградировала обратно в «Москву»"
|
||||
monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", full)
|
||||
|
||||
|
||||
|
|
@ -1047,10 +1093,16 @@ def test_negative_centroid_radius_is_unbounded_and_endpoint_survives_it(
|
|||
расстоянии означает «город не определён»: точка в глухом углу области,
|
||||
где ближайший центр — подмосковный и дальше 25 км, отдаёт not_covered
|
||||
и 200, а не 500 по отсутствующему порогу.
|
||||
|
||||
Координаты сменены 2026-09-16: прежняя точка (55.30, 38.75) после
|
||||
добавления COVERAGE_MO_CITIES стала ближе к Коломне (22.9 км, позитивный
|
||||
центроид), чем к любой отрицательной точке — тест проверял бы Коломну, а
|
||||
не бесконечный радиус. Новая точка ближе всего к «Поварово» (26.27 км,
|
||||
отрицательный центроид).
|
||||
"""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
|
||||
far_lat, far_lon = 55.30, 38.75
|
||||
far_lat, far_lon = 56.30, 37.00
|
||||
nearest_km, nearest_name = min(
|
||||
(trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name)
|
||||
for name, (clat, clon) in (
|
||||
|
|
|
|||
|
|
@ -561,6 +561,7 @@ def test_offered_cities_match_coverage_cities() -> None:
|
|||
|
||||
from app.api.v1.trade_in import (
|
||||
COVERAGE_GREEN_CITIES,
|
||||
COVERAGE_MO_CITIES,
|
||||
COVERAGE_MOSCOW_DISPLAY,
|
||||
COVERAGE_YELLOW_CITIES,
|
||||
_fold_city,
|
||||
|
|
@ -580,7 +581,12 @@ def test_offered_cities_match_coverage_cities() -> None:
|
|||
offered = {_fold_city(m) for m in re.findall(r'label:\s*"([^"]+)"', block.group(1))}
|
||||
answerable = {
|
||||
_fold_city(c)
|
||||
for c in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES, COVERAGE_MOSCOW_DISPLAY)
|
||||
for c in (
|
||||
*COVERAGE_GREEN_CITIES,
|
||||
*COVERAGE_YELLOW_CITIES,
|
||||
*COVERAGE_MO_CITIES,
|
||||
COVERAGE_MOSCOW_DISPLAY,
|
||||
)
|
||||
}
|
||||
|
||||
assert offered == answerable, (
|
||||
|
|
@ -595,12 +601,13 @@ def test_every_coverage_city_has_a_centroid() -> None:
|
|||
from app.api.v1.trade_in import (
|
||||
_CITY_CENTROIDS_DEG,
|
||||
COVERAGE_GREEN_CITIES,
|
||||
COVERAGE_MO_CITIES,
|
||||
COVERAGE_YELLOW_CITIES,
|
||||
_fold_city,
|
||||
)
|
||||
|
||||
centroids = {_fold_city(c) for c in _CITY_CENTROIDS_DEG}
|
||||
for city in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES):
|
||||
for city in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES, *COVERAGE_MO_CITIES):
|
||||
assert _fold_city(city) in centroids, (
|
||||
f"{city} есть в порогах, но нет в центроидах — по координатам он "
|
||||
"никогда не резолвится, и порог мёртвый"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
/**
|
||||
* city-registry — единый источник правды по городам покрытия «Меры»:
|
||||
* Свердловская область (регион 66) и Москва (регион 77, PR-B).
|
||||
* Свердловская область (регион 66), Москва (регион 77, PR-B) и Московская
|
||||
* область (регион 50, 22 города — расширение 2026-09-16: замер плотности
|
||||
* когорты на проде показал, что каждый город области плотнее уже-жёлтой
|
||||
* Москвы, см. `COVERAGE_MO_CITIES` в backend/app/api/v1/trade_in.py).
|
||||
*
|
||||
* Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного
|
||||
* города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал
|
||||
|
|
@ -49,11 +52,12 @@ export interface OblastCity {
|
|||
*/
|
||||
readonly label: string;
|
||||
/**
|
||||
* Регион: 66 — Свердловская область, 77 — Москва. НЕ отправляется бэкенду —
|
||||
* нужен только фронту, чтобы отличить «частичное покрытие области» от
|
||||
* отдельного региона. Большинство потребителей читают только id/label.
|
||||
* Регион: 66 — Свердловская область, 77 — Москва, 50 — Московская область.
|
||||
* НЕ отправляется бэкенду — нужен только фронту, чтобы отличить «частичное
|
||||
* покрытие области» от отдельного региона. Большинство потребителей читают
|
||||
* только id/label.
|
||||
*/
|
||||
readonly region: 66 | 77;
|
||||
readonly region: 66 | 77 | 50;
|
||||
}
|
||||
|
||||
// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок
|
||||
|
|
@ -69,12 +73,41 @@ export const OBLAST_CITIES: readonly OblastCity[] = [
|
|||
{ id: "revda", label: "Ревда", region: 66 },
|
||||
{ id: "serov", label: "Серов", region: 66 },
|
||||
{ id: "moscow", label: "Москва", region: 77 },
|
||||
// Московская область (регион 50) — 22 города, расширение 2026-09-16.
|
||||
// Все получают жёлтый тир (COVERAGE_YELLOW_MIN_N): замер 120 проб/город,
|
||||
// радиус 8 км, когорта = is_active + точные комнаты + площадь ±15% +
|
||||
// scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты 17-51,
|
||||
// доля проб с ≥12 аналогов 0.63-0.92 — у каждого города выше, чем у уже
|
||||
// жёлтой Москвы (медиана 14, доля 0.57). Порядок — по убыванию медианы.
|
||||
{ id: "vidnoe", label: "Видное", region: 50 },
|
||||
{ id: "balashikha", label: "Балашиха", region: 50 },
|
||||
{ id: "zheleznodorozhny", label: "Железнодорожный", region: 50 },
|
||||
{ id: "lyubertsy", label: "Люберцы", region: 50 },
|
||||
{ id: "elektrostal", label: "Электросталь", region: 50 },
|
||||
{ id: "khimki", label: "Химки", region: 50 },
|
||||
{ id: "domodedovo", label: "Домодедово", region: 50 },
|
||||
{ id: "noginsk", label: "Ногинск", region: 50 },
|
||||
{ id: "reutov", label: "Реутов", region: 50 },
|
||||
{ id: "dolgoprudny", label: "Долгопрудный", region: 50 },
|
||||
{ id: "krasnogorsk", label: "Красногорск", region: 50 },
|
||||
{ id: "odintsovo", label: "Одинцово", region: 50 },
|
||||
{ id: "podolsk", label: "Подольск", region: 50 },
|
||||
{ id: "sergiev_posad", label: "Сергиев Посад", region: 50 },
|
||||
{ id: "korolev", label: "Королёв", region: 50 },
|
||||
{ id: "mytishchi", label: "Мытищи", region: 50 },
|
||||
{ id: "orekhovo_zuevo", label: "Орехово-Зуево", region: 50 },
|
||||
{ id: "serpukhov", label: "Серпухов", region: 50 },
|
||||
{ id: "ramenskoye", label: "Раменское", region: 50 },
|
||||
{ id: "naro_fominsk", label: "Наро-Фоминск", region: 50 },
|
||||
{ id: "schelkovo", label: "Щёлково", region: 50 },
|
||||
{ id: "kolomna", label: "Коломна", region: 50 },
|
||||
];
|
||||
|
||||
/** RU-имена регионов покрытия — та же пара кодов, что `OblastCity.region`. */
|
||||
export const REGION_LABELS: Record<66 | 77, string> = {
|
||||
export const REGION_LABELS: Record<66 | 77 | 50, string> = {
|
||||
66: "Свердловская область",
|
||||
77: "Москва",
|
||||
50: "Московская область",
|
||||
};
|
||||
|
||||
/** Только область (регион 66) — прежнее множество, до добавления Москвы. */
|
||||
|
|
@ -82,8 +115,20 @@ export const OBLAST_66_CITIES: readonly OblastCity[] = OBLAST_CITIES.filter(
|
|||
(c) => c.region === 66,
|
||||
);
|
||||
|
||||
/** Москва одним объектом — тем же паттерном, что `DEFAULT_CITY = OBLAST_CITIES[0]` ниже. */
|
||||
export const MOSCOW_CITY: OblastCity = OBLAST_CITIES[OBLAST_CITIES.length - 1];
|
||||
/**
|
||||
* Москва одним объектом. Раньше бралась по индексу последнего элемента
|
||||
* `OBLAST_CITIES` (работало, пока Москва была последней в списке) — после
|
||||
* добавления 22 городов Московской области (регион 50) последним элементом
|
||||
* стала Коломна, индекс молча указывал бы не на тот город. Ищем по
|
||||
* стабильному `id`, а не по позиции в массиве.
|
||||
*/
|
||||
export const MOSCOW_CITY: OblastCity = (() => {
|
||||
const city = OBLAST_CITIES.find((c) => c.id === "moscow");
|
||||
if (!city) {
|
||||
throw new Error("city-registry: 'moscow' отсутствует в OBLAST_CITIES");
|
||||
}
|
||||
return city;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue