feat(trade-in): 22 города Московской области в покрытии
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m34s
CI Trade-In / backend-tests (pull_request) Successful in 5m7s

Тестировщику по области было нечего выбрать: реестр городов во фронте знал
только Свердловскую область и Москву, а подмосковные населённые пункты
лежали в _COVERAGE_NEGATIVE_CENTROIDS_DEG — намеренно, решением от
11.09.2026 «центроид без порога = город не определён».

Решение пересмотрено на замере, а не на желании открыть регион. Метрика —
та же, которой Москве выбирали жёлтый тир: 120 проб на город, радиус 8 км
от центра, когорта = is_active + точные комнаты + площадь ±15% +
scraped_at > now()-14d + ST_DWithin 1000 м. Результат: медиана когорты по
городам области 17-51, доля проб с >=12 аналогов 0.63-0.92. У Москвы,
которая уже жёлтая, медиана 14 и доля 0.57; у Нижнего Тагила (тоже
жёлтый) — 11 и 0.473. То есть каждый город области плотнее Москвы, и
жёлтый порог для них — консервативный выбор, а не натяжка.

Парность реестра и порогов сохранена (гейт test_public_mera_api): 22
города добавлены и во фронт, и в _COVERAGE_CITY_THRESHOLDS с
COVERAGE_YELLOW_MIN_N, и в _CITY_CENTROIDS_DEG. 12 центроидов перенесены
из негативного списка (32 -> 20 записей, ключи двух словарей не
пересекаются), 10 взяты из MOSCOW_OBLAST_CITY_ANCHORS — там они
резолвлены Nominatim.

MOSCOW_CITY больше не берётся по индексу последнего элемента: список
перестал заканчиваться Москвой, и молчаливая поломка стоила бы дорого —
теперь поиск по id с явным throw.

Тесты сетки покрытия переписаны по факту, а не подогнаны: 12 промоутнутых
координат раньше держали утверждение «здесь город не определён».
Контрольные точки пересчитаны (Пушкино теперь резолвится в Королёв,
контроль бесконечного радиуса переехал с точки в 22.9 км от Коломны).
This commit is contained in:
bot-backend 2026-09-16 19:14:46 +03:00
parent 1bd5283aa6
commit f6dd426494
4 changed files with 231 additions and 68 deletions

View file

@ -2844,6 +2844,46 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
COVERAGE_GREEN_MIN_N = 8 COVERAGE_GREEN_MIN_N = 8
COVERAGE_YELLOW_MIN_N = 12 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 — ОТДЕЛЬНОЙ константой, а не в # Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше — # COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется # это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
@ -3040,6 +3080,7 @@ def _fold_city(name: str) -> str:
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = { _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_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_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) _fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
for k in COVERAGE_MOSCOW_CENTROID_KEYS for k in COVERAGE_MOSCOW_CENTROID_KEYS
@ -3086,6 +3127,36 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Первоуральск": (56.9083, 59.9483), "Первоуральск": (56.9083, 59.9483),
"Ревда": (56.7986, 59.9298), "Ревда": (56.7986, 59.9298),
"Серов": (59.6047, 60.5772), "Серов": (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-имя и один порог на все. # Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
# Как сетка получена, почему точек именно столько, где проходит граница # Как сетка получена, почему точек именно столько, где проходит граница
# с областью и какова остаточная цена — см. большой комментарий над # с областью и какова остаточная цена — см. большой комментарий над
@ -3127,30 +3198,18 @@ _COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Поварово": (56.0667, 37.0667), "Поварово": (56.0667, 37.0667),
"Дедовск": (55.8672, 37.1200), "Дедовск": (55.8672, 37.1200),
"Нахабино": (55.8500, 37.1833), "Нахабино": (55.8500, 37.1833),
"Реутов": (55.7614, 37.8564),
"Подольск": (55.4312, 37.5450),
"Апрелевка": (55.5500, 37.0700), "Апрелевка": (55.5500, 37.0700),
"Немчиновка": (55.7050, 37.3450), "Немчиновка": (55.7050, 37.3450),
"Химки": (55.8894, 37.4450),
"Одинцово": (55.6789, 37.2639),
"Лобня": (56.0100, 37.4750), "Лобня": (56.0100, 37.4750),
"Мытищи": (55.9116, 37.7308),
"Котельники": (55.6553, 37.8619), "Котельники": (55.6553, 37.8619),
"Красногорск": (55.8317, 37.3300),
"Люберцы": (55.6767, 37.8931),
"Дзержинский": (55.6294, 37.8500), "Дзержинский": (55.6294, 37.8500),
"Развилка": (55.5842, 37.7392), "Развилка": (55.5842, 37.7392),
"Климовск": (55.3667, 37.5333), "Климовск": (55.3667, 37.5333),
"Балашиха": (55.7969, 37.9386),
"Истра": (55.9167, 36.8667), "Истра": (55.9167, 36.8667),
"Долгопрудный": (55.9386, 37.5100),
"Королёв": (55.9142, 37.8256),
"Видное": (55.5519, 37.7133),
"Селятино": (55.5081, 36.9825), "Селятино": (55.5081, 36.9825),
"Томилино": (55.6528, 37.9472), "Томилино": (55.6528, 37.9472),
"Некрасовский": (56.0500, 37.5500), "Некрасовский": (56.0500, 37.5500),
"Барвиха": (55.7333, 37.2333), "Барвиха": (55.7333, 37.2333),
"Железнодорожный": (55.7444, 38.0128),
} }

View file

@ -535,19 +535,14 @@ def test_sverdlovsk_resolution_untouched_by_moscow_centroids(
# Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов # Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов
# (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву». # (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву».
#
# 2026-09-16: 12 из исходных 16 (Химки, Мытищи, Люберцы, Балашиха, Красногорск,
# Одинцово, Подольск, Реутов, Королёв, Долгопрудный, Видное, Железнодорожный)
# получили собственный порог покрытия — см. COVERAGE_MO_CITIES в
# app/api/v1/trade_in.py — и больше не резолвятся в «город не определён».
# Здесь остаются только 4 города, которые НЕ замерялись и остаются
# отрицательными точками.
_MOSCOW_OBLAST_CITIES = { _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), "Лобня": (56.0100, 37.4750),
"Дзержинский": (55.6294, 37.8500), "Дзержинский": (55.6294, 37.8500),
"Котельники": (55.6553, 37.8619), "Котельники": (55.6553, 37.8619),
@ -576,7 +571,11 @@ def test_every_negative_centroid_resolves_to_no_city() -> None:
_resolve_coverage_city, _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(): for name, (lat, lon) in _COVERAGE_NEGATIVE_CENTROIDS_DEG.items():
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name 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: 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)) db = _db_mock_returning(_row(40, 30.0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) 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}) resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon})
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
data = resp.json() 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 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 = { sverdlovsk = {
k: v k: v
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items() 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 { not in {
trade_in_module._fold_city(m) for m in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS 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 assert len(sverdlovsk) == 9
@ -753,22 +759,26 @@ _MOSCOW_CONTROLS = {
"Троицк (ТАО)": (55.4853, 37.3050), "Троицк (ТАО)": (55.4853, 37.3050),
} }
# Областной контрольный набор (32 города). 16 из них имеют собственный # Областной контрольный набор. Изначально 32 города (16 из _MOSCOW_OBLAST_CITIES
# отрицательный центроид и выигрывают им; остальные 16 держатся только # + 16 ниже) — часть держалась только радиусом московских точек, включая
# радиусом московских точек — включая Наро-Фоминск (9.95 км до ближайшей # Наро-Фоминск (9.95 км до ближайшей точки сетки), Кубинку (17.2) и Чехов
# точки сетки), Кубинку (17.2) и Чехов (22.3), которые при глобальных 25 км # (22.3), которые при глобальных 25 км резолвились как «Москва». Это и есть
# резолвились как «Москва». Это и есть дефект #1. # дефект #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_CONTROLS = {
**_MOSCOW_OBLAST_CITIES, **_MOSCOW_OBLAST_CITIES,
"Наро-Фоминск": (55.3869, 36.7331),
"Кубинка": (55.5794, 36.7000), "Кубинка": (55.5794, 36.7000),
"Чехов": (55.1477, 37.4770), "Чехов": (55.1477, 37.4770),
"Голицыно": (55.6167, 36.9833), "Голицыно": (55.6167, 36.9833),
"Лыткарино": (55.5833, 37.9000), "Лыткарино": (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.7300, 36.8500),
"Апрелевка": (55.5500, 37.0700), "Апрелевка": (55.5500, 37.0700),
"Селятино": (55.5081, 36.9825), "Селятино": (55.5081, 36.9825),
@ -778,6 +788,10 @@ _MOSCOW_OBLAST_CONTROLS = {
"Барвиха": (55.7333, 37.2333), "Барвиха": (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: жилые районы, целиком проигрывавшие # Точки, добавленные замером 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: 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 from app.api.v1.trade_in import _resolve_coverage_city
assert len(_MOSCOW_OBLAST_CONTROLS) == 32 assert len(_MOSCOW_OBLAST_CONTROLS) == 15
failed = { failed = {
name: _resolve_coverage_city(lat, lon) name: _resolve_coverage_city(lat, lon)
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items() 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: def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
"""Дефект #1 предметно: Наро-Фоминск, Кубинка, Чехов. """Дефект #1 предметно: Кубинка, Чехов.
С одним глобальным радиусом 25 км круги плотной сетки складывались, и все С одним глобальным радиусом 25 км круги плотной сетки складывались, и обе
трое получали «Москву», хотя до ближайшей точки сетки им 9.95, 17.2 и получали «Москву», хотя до ближайшей точки сетки им 17.2 и 22.3 км. С
22.3 км. С радиусом-свойством (8 км у московских точек) «не определён». радиусом-свойством (8 км у московских точек) «не определён».
Наро-Фоминск убран из проверки 2026-09-16: сам стал покрытым городом
(COVERAGE_MO_CITIES), дефект #1 больше не демонстрирует.
""" """
from app.api.v1.trade_in import _resolve_coverage_city from app.api.v1.trade_in import _resolve_coverage_city
for name in ("Наро-Фоминск", "Кубинка", "Чехов"): for name in ("Кубинка", "Чехов"):
lat, lon = _MOSCOW_OBLAST_CONTROLS[name] lat, lon = _MOSCOW_OBLAST_CONTROLS[name]
assert _legacy_resolve(lat, lon) == ("Москва", 12, True), ( assert _legacy_resolve(lat, lon) == ("Москва", 12, True), (
f"{name}: тест перестал воспроизводить дефект, сравнивать не с чем" 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: def test_radius_is_a_property_of_the_centroid_not_a_global_constant() -> None:
"""Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской. """Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской.
Наро-Фоминск в 9.95 км от ближайшей точки московской сетки «не Кубинка в 17.2 км от ближайшей точки московской сетки «не определён»
определён». Точка в 20 км южнее центра Екатеринбурга (то есть ДАЛЬШЕ) (была "Наро-Фоминск" до 2026-09-16 тот сам стал покрытым городом,
«Екатеринбург». Глобальной константой такое поведение невыразимо. COVERAGE_MO_CITIES). Точка в 20 км южнее центра Екатеринбурга (то есть
ДАЛЬШЕ) «Екатеринбург». Глобальной константой такое поведение
невыразимо.
""" """
from app.api.v1 import trade_in as trade_in_module 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( km_to_grid = min(
trade_in_module._haversine_km(lat, lon, glat, glon) trade_in_module._haversine_km(lat, lon, glat, glon)
for glat, glon in trade_in_module._MOSCOW_GRID_DEG.values() 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 = { moscow_keys = {
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_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 = { sverdlovsk = {
k: v k: v
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items() for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
if trade_in_module._fold_city(k) not in moscow_keys 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 assert len(sverdlovsk) == 9
for clat, clon in sverdlovsk.values(): for clat, clon in sverdlovsk.values():
@ -973,14 +994,18 @@ def test_phantom_exclave_no_longer_gives_moscow_to_pushkino(
) -> None: ) -> None:
"""Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой». """Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой».
Возвращаем призрак в таблицу Пушкино снова резолвится в «Москву»: это 2026-09-16: Королёв (COVERAGE_MO_CITIES) стал позитивным центроидом в
доказывает, что дефект чинит именно выброс точки, а не что-нибудь ещё. ~10.76 км от Пушкино ближе прежнего "not_covered", поэтому базовый
ответ без призрака теперь «Королёв», а не пустота. Возвращаем призрак в
таблицу он в 3.06 км, ближе Королёва, и Пушкино снова резолвится в
«Москву»: это по-прежнему доказывает, что дефект чинит именно выброс
точки, а не что-нибудь ещё.
""" """
from app.api.v1 import trade_in as trade_in_module 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(*_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 = "Москва (Восточный, эксклав)" ghost_key = "Москва (Восточный, эксклав)"
monkeypatch.setattr( monkeypatch.setattr(
@ -1017,12 +1042,31 @@ def test_added_grid_points_return_districts_lost_to_negative_centroids(
) -> None: ) -> None:
"""Каждая добавленная точка что-то возвращает: без неё район теряется. """Каждая добавленная точка что-то возвращает: без неё район теряется.
Для всех 12 точек: в самой точке резолвер отдаёт «Москву», а если убрать Для всех 12 точек: в самой точке резолвер отдаёт «Москву». Без неё точка
её из таблицы «город не определён» (район уходит подмосковному центру). проигрывает следующему ближайшему центроиду раньше это всегда было
Мёртвых строк в добавке нет. «город не определён», но с 2026-09-16 (COVERAGE_MO_CITIES) для 9 из 12
точек следующий ближайший новый позитивный центроид подмосковного
города, а не пустота. Инвариант, который всё ещё держит тест: ни одна
точка не деградирует ОБРАТНО в «Москву» иначе строка была бы мёртвой.
""" """
from app.api.v1 import trade_in as trade_in_module 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 full = trade_in_module._CITY_CENTROIDS_DEG
for key in _ADDED_GRID_KEYS: for key in _ADDED_GRID_KEYS:
lat, lon = full[key] lat, lon = full[key]
@ -1032,9 +1076,11 @@ def test_added_grid_points_return_districts_lost_to_negative_centroids(
"_CITY_CENTROIDS_DEG", "_CITY_CENTROIDS_DEG",
{k: v for k, v in full.items() if k != key}, {k: v for k, v in full.items() if k != key},
) )
assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False), ( fallback = trade_in_module._resolve_coverage_city(lat, lon)
f"{key} ничего не возвращает — строка мёртвая" 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) 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 где ближайший центр подмосковный и дальше 25 км, отдаёт not_covered
и 200, а не 500 по отсутствующему порогу. и 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 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( nearest_km, nearest_name = min(
(trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name) (trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name)
for name, (clat, clon) in ( for name, (clat, clon) in (

View file

@ -561,6 +561,7 @@ def test_offered_cities_match_coverage_cities() -> None:
from app.api.v1.trade_in import ( from app.api.v1.trade_in import (
COVERAGE_GREEN_CITIES, COVERAGE_GREEN_CITIES,
COVERAGE_MO_CITIES,
COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_DISPLAY,
COVERAGE_YELLOW_CITIES, COVERAGE_YELLOW_CITIES,
_fold_city, _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))} offered = {_fold_city(m) for m in re.findall(r'label:\s*"([^"]+)"', block.group(1))}
answerable = { answerable = {
_fold_city(c) _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, ( assert offered == answerable, (
@ -595,12 +601,13 @@ def test_every_coverage_city_has_a_centroid() -> None:
from app.api.v1.trade_in import ( from app.api.v1.trade_in import (
_CITY_CENTROIDS_DEG, _CITY_CENTROIDS_DEG,
COVERAGE_GREEN_CITIES, COVERAGE_GREEN_CITIES,
COVERAGE_MO_CITIES,
COVERAGE_YELLOW_CITIES, COVERAGE_YELLOW_CITIES,
_fold_city, _fold_city,
) )
centroids = {_fold_city(c) for c in _CITY_CENTROIDS_DEG} 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, ( assert _fold_city(city) in centroids, (
f"{city} есть в порогах, но нет в центроидах — по координатам он " f"{city} есть в порогах, но нет в центроидах — по координатам он "
"никогда не резолвится, и порог мёртвый" "никогда не резолвится, и порог мёртвый"

View file

@ -1,6 +1,9 @@
/** /**
* city-registry единый источник правды по городам покрытия «Меры»: * 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», получал * города в тексте адреса житель Нижнего Тагила, вводя «Ленина, 1», получал
@ -49,11 +52,12 @@ export interface OblastCity {
*/ */
readonly label: string; readonly label: string;
/** /**
* Регион: 66 Свердловская область, 77 Москва. НЕ отправляется бэкенду * Регион: 66 Свердловская область, 77 Москва, 50 Московская область.
* нужен только фронту, чтобы отличить «частичное покрытие области» от * НЕ отправляется бэкенду нужен только фронту, чтобы отличить «частичное
* отдельного региона. Большинство потребителей читают только id/label. * покрытие области» от отдельного региона. Большинство потребителей читают
* только id/label.
*/ */
readonly region: 66 | 77; readonly region: 66 | 77 | 50;
} }
// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок // ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок
@ -69,12 +73,41 @@ export const OBLAST_CITIES: readonly OblastCity[] = [
{ id: "revda", label: "Ревда", region: 66 }, { id: "revda", label: "Ревда", region: 66 },
{ id: "serov", label: "Серов", region: 66 }, { id: "serov", label: "Серов", region: 66 },
{ id: "moscow", label: "Москва", region: 77 }, { 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`. */ /** RU-имена регионов покрытия — та же пара кодов, что `OblastCity.region`. */
export const REGION_LABELS: Record<66 | 77, string> = { export const REGION_LABELS: Record<66 | 77 | 50, string> = {
66: "Свердловская область", 66: "Свердловская область",
77: "Москва", 77: "Москва",
50: "Московская область",
}; };
/** Только область (регион 66) — прежнее множество, до добавления Москвы. */ /** Только область (регион 66) — прежнее множество, до добавления Москвы. */
@ -82,8 +115,20 @@ export const OBLAST_66_CITIES: readonly OblastCity[] = OBLAST_CITIES.filter(
(c) => c.region === 66, (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, для текста подсказки и * Дефолт формы Екатеринбург (визуальный/best-guess, для текста подсказки и