22 города Московской области в покрытии #3536

Merged
lekss361 merged 1 commit from feat/mo-cities-coverage into main 2026-09-16 16:42:08 +00:00
4 changed files with 231 additions and 68 deletions
Showing only changes of commit f6dd426494 - Show all commits

View file

@ -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),
}

View file

@ -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 (

View file

@ -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} есть в порогах, но нет в центроидах — по координатам он "
"никогда не резолвится, и порог мёртвый"

View file

@ -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, для текста подсказки и