feat(msk): проба покрытия отвечает по Москве — сетка центроидов и радиус на точку

Резолвер города в пробе покрытия знал только 9 центроидов Свердловской области,
поэтому любой московский адрес получал not_covered с пустым городом при живой
когорте рядом. Замер на проде: точка Тверской, 59 объявлений в радиусе 1 км,
статус not_covered, город пустой; контроль по Екатеринбургу — ok.

Москве заведена сетка из 67 центроидов, выведенная кластеризацией нашего же
корпуса (35 552 объявления Циан, ST_ClusterKMeans), плюс 32 отрицательные точки
Подмосковья: они участвуют в конкурсе ближайшего центроида, но порога не имеют,
поэтому граница с областью проходит по конкурсу центров, а не по окружности.

Радиус стал свойством центроида. У свердловских точек прежние 25 км байт-в-байт,
у московских 8 км: круги плотной сетки складываются, и общий 25-километровый
радиус протекал вглубь области — Наро-Фоминск 9.83 км до сетки, Кубинка 17.18,
Чехов 22.25, все резолвились как «Москва».

Порог Москвы жёлтый (12), не зелёный: 200 случайных московских адресов дают
медиану когорты 14 и долю с когортой не меньше 12 равную 0.57, против 37 и 0.865
у Екатеринбурга.

Остаточная цена — 48 московских объявлений из 35 552 (0.14%) в приграничной
полосе выигрываются подмосковным центром.

Тесты: 29 контрольных районов Москвы резолвятся в «Москва», 32 города области
дают «город не определён», резолв по Свердловской области сверен с прежней
реализацией на решётке из 851 узла — расхождений 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
This commit is contained in:
bot-backend 2026-09-11 01:37:27 +03:00
parent b1727ca39c
commit 50c1df5e0e
2 changed files with 1090 additions and 5 deletions

View file

@ -2776,15 +2776,206 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
COVERAGE_GREEN_MIN_N = 8
COVERAGE_YELLOW_MIN_N = 12
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
# тестом test_offered_cities_match_coverage_cities), а Москвы в том дропдауне
# нет и быть не должно — там выбирают город Свердловской области. Порог при
# этом нужен, и живёт он здесь, в том же _COVERAGE_CITY_THRESHOLDS.
#
# Тир — ЖЁЛТЫЙ (12), не зелёный. Замер на проде 10.09.2026: 200 случайных
# московских объявлений, когорта считалась тем же предикатом, что сама проба
# (см. SQL в coverage_probe ниже) — медиана когорты 14, p25 = 8, доля точек с
# когортой >= 12 равна 0.57, с когортой >= 8 равна 0.77. Контроли той же
# метрикой: Екатеринбург (зелёный, порог 8) — медиана 37, доля >= 12 равна
# 0.865; Нижний Тагил (жёлтый, порог 12) — медиана 11, доля >= 12 равна 0.473.
# То есть по плотности когорты Москва втрое реже зелёного эталона и стоит
# вплотную к жёлтому: с порогом 8 «ok» получали бы 77% адресов, но за этим «ok»
# стояла бы заметно менее надёжная оценка. Плюс модельная сторона для Москвы
# ещё не откалибрована (время правится свердловским рядом СберИндекса, полоса
# цен одна на весь город), так что 43% адресов честнее показать как thin.
COVERAGE_MOSCOW_MIN_N = COVERAGE_YELLOW_MIN_N
# Ключ центроида и display-имя города — РАЗНЫЕ вещи, и здесь это видно яснее
# всего: у Москвы 67 центроидов (сетка, см. `_MOSCOW_GRID_DEG` ниже), а наружу,
# в поле `city` ответа, обязано уходить одно имя «Москва» и один порог. Иначе
# повторится ровно тот баг, что был с Берёзовским: человек получает в ответе
# название района вместо своего города.
COVERAGE_MOSCOW_DISPLAY = "Москва"
# СЕТКА МОСКОВСКИХ ЦЕНТРОИДОВ (11.09.2026).
#
# Откуда координаты. Это не справочник районов и не ручной подбор: точки
# получены кластеризацией НАШЕГО ЖЕ корпуса объявлений средствами PostGIS —
# `ST_ClusterKMeans(ST_Transform(geom, 32637), K) OVER ()` по 35 552 активным
# вторичным объявлениям region_code=77 (geom заполнена у 100%), центроид
# кластера = `ST_Centroid(ST_Collect(...))` в UTM 37N, округление до 4 знаков.
# Поэтому точки стоят там, где реально живут объявления, а не там, где на карте
# нарисован центр района. Подписи в ключах — человеческие ярлыки для читаемости
# (ближайший район/поселение), на резолв они не влияют никак.
#
# Почему точек 67. Кластеризация дала K=56: порог качества был «меньше 2%
# московских объявлений уходит к подмосковному центроиду», свип по K на тех же
# данных — 24 → 6.06%, 30 → 4.02%, 36 → 3.67%, 42 → 3.46%, 48 → 2.23%,
# 54 → 2.12%, 56 → 1.79%, 60 → 1.64%, 75 → 0.84%, 90 → 0.45%, и K=56 — первое
# значение, берущее 2%. Из этих 56 один кластер выброшен как артефакт геокода,
# и 12 точек добавлены вручную по замеру 11.09.2026 (жилые районы, целиком
# проигрывавшие конкурс подмосковной точке): 56 1 + 12 = 67.
#
# Выброшенный призрак. «Москва (Восточный, эксклав)» 56.0087/37.7960: n=1,
# отрыв от остальной сетки 17.9 км. Адрес объявления — «ВАО, р-н Восточный, 4»,
# а настоящий район Восточный лежит на 55.81/37.85: геокодер промахнулся
# на 22 км. Точка стояла в 3.06 км от Пушкино и раздавала ему имя «Москва».
# Взамен добавлена «Москва (Восточный)» по фактическому центру девяти
# объявлений района. Проверены ВСЕ кластеры с n<60 либо изоляцией >12 км
# (9 штук, адреса подняты с прода) — артефакт ровно один. Второй кандидат,
# «Москва (юг ТАО)» 55.2643/37.1033 (n=1, адрес без улицы и дома =
# settlement-fallback геокодера), ОСТАВЛЕН намеренно: точка стоит внутри ТАО,
# при радиусе 8 км не достаёт ни до одного города области, а снос стоил бы
# покрытия анклава.
#
# Где проходит граница с областью. Двумя механизмами сразу, и нужны оба.
# (1) Конкурс ближайшего центроида: 32 подмосковные точки
# (`_COVERAGE_NEGATIVE_CENTROIDS_DEG` ниже) участвуют в поиске ближайшего, но
# порога не имеют, поэтому адрес, для которого выиграл подмосковный центр,
# получает «город не определён». (2) Радиус, который теперь СВОЙСТВО ЦЕНТРОИДА
# (`_CENTROID_RADIUS_KM` ниже), а не одна константа на всю страну: у московских
# точек он 8 км, у свердловских остались прежние 25.
#
# Почему радиус пришлось сделать свойством точки. 25 км были рассчитаны на ОДИН
# центроид в центре города — круг вокруг Кремля. У плотной сетки круги
# СКЛАДЫВАЮТСЯ, и объединение 67 кругов по 25 км — это уже не «Москва с
# запасом», а пятно, накрывающее половину области. Наро-Фоминск, Кубинка и Чехов
# резолвились как «Москва» именно так: до ближайшей точки сетки им 9.83, 17.18
# и 22.25 км (расстояния посчитаны _haversine_km этого же модуля, сфера
# R=6371 — замер на эллипсоиде PostGIS даёт на 0.1-0.5 км больше), то есть
# внутрь общего круга они попадали, а собственной
# отрицательной точки у них нет. Прежнее утверждение в этом месте — будто
# радиус 25 км «больше не ограничение, запас четырёхкратный» — было ложным:
# ограничение не исчезло, оно поменяло знак. Раньше радиус резал покрытие
# изнутри (адрес дальше 25 км от Кремля терял город), теперь протекал наружу.
#
# Откуда 8 км. Свип по радиусу на итоговой сетке, тот же корпус (35 552
# активных вторичных объявления, region_code=77): R = 5, 6, 7 и 8 дают
# ОДИНАКОВЫЙ результат — 0.00% объявлений вне радиуса, 29/29 московских
# контрольных адресов резолвятся в «Москву», 32/32 областных дают «не
# определён». Верхний край плато жёсткий: при R=10 внутрь входит Наро-Фоминск
# (9.83 км — город БЕЗ отрицательной точки), при R=12 — ещё Голицыно и
# Лыткарино. Берём верхний край: 8 км — это 3.5 км запаса на адреса вне
# корпуса (худший московский контроль — Рублёво, 4.44 км до сетки) и всё ещё
# на 1.83 км ниже потолка 9.83. Брать меньше нечем оправдать: по корпусу
# выигрыша нет, а запас на дырки в сетке тает.
#
# Остаточная цена. 48 московских объявлений из 35 552 = 0.14% (на прежней
# конфигурации — 636 = 1.79%) всё ещё выигрываются подмосковным центром:
# Молжаниновский 15, Реутов 8, Подольск 7, Долгопрудный 7, Мытищи 7,
# Красногорск 3, Королёв 1. Крупнейший кусок снимается 13-й точкой
# «Молжаниновский» 55.9480/37.3478 (0.14% → 0.09%) — она в замере посчитана,
# но в список не внесена, чтобы конфигурация здесь совпадала с той, на которой
# прогнаны оба контрольных набора. Одно объявление не берётся ничем:
# 56.0087/37.7960, тот самый кривой геокод, — лечится перегеокодированием
# строки адреса, а не сеткой.
#
# Не перегенерируйте кластеризацию перед правкой: ST_ClusterKMeans без сида
# недетерминирован, разброс между прогонами ±0.2 п.п. Список ниже перепроверен
# отдельным прогоном именно в том виде, в каком он здесь записан.
_MOSCOW_GRID_DEG: dict[str, tuple[float, float]] = {
"Москва (Рогово, ТАО)": (55.2146, 37.0695),
"Москва (юг ТАО)": (55.2643, 37.1033),
"Москва (Вороновское, ТАО)": (55.3159, 37.1759),
"Москва (Кленовское, ТАО)": (55.3343, 37.3299),
"Москва (ТАО у Подольска)": (55.3569, 37.4010),
"Москва (Шишкин Лес, ТАО)": (55.4202, 37.1687),
"Москва (Щапово, ТАО)": (55.4212, 37.3979),
"Москва (Новофёдоровское, ТАО)": (55.4307, 36.8675),
"Москва (Троицк, юг)": (55.4568, 37.2867),
"Москва (Остафьево, НАО)": (55.4760, 37.5249),
"Москва (Киевский, ТАО)": (55.4884, 36.9199),
"Москва (Троицк)": (55.4953, 37.3150),
"Москва (Щербинка, НАО)": (55.5046, 37.5578),
"Москва (Ватутинки, НАО)": (55.5187, 37.3605),
"Москва (Первомайское, ТАО)": (55.5405, 37.1599),
"Москва (Коммунарка, НАО)": (55.5474, 37.4927),
"Москва (Филимонковское, НАО)": (55.5568, 37.3188),
"Москва (Северное Бутово)": (55.5615, 37.5697),
"Москва (Саларьево, НАО)": (55.5862, 37.4568),
"Москва (Марушкино, НАО)": (55.5933, 37.1658),
"Москва (Бирюлёво Западное)": (55.5945, 37.6447),
"Москва (Внуковское, НАО)": (55.6030, 37.3796),
"Москва (Ясенево)": (55.6138, 37.5535),
"Москва (Зябликово)": (55.6225, 37.7303),
"Москва (Ново-Переделкино)": (55.6317, 37.3215),
"Москва (Нагорный)": (55.6425, 37.6245),
"Москва (Солнцево)": (55.6455, 37.3966),
"Москва (Тропарёво-Никулино)": (55.6576, 37.4684),
"Москва (Люблино)": (55.6696, 37.7461),
"Москва (Обручевский)": (55.6711, 37.5432),
"Москва (Даниловский)": (55.6921, 37.6397),
"Москва (Некрасовка)": (55.7033, 37.9093),
"Москва (Раменки)": (55.7048, 37.4870),
"Москва (Кузьминки)": (55.7143, 37.7723),
"Москва (Кунцево)": (55.7281, 37.4239),
"Москва (Якиманка)": (55.7290, 37.6055),
"Москва (Таганский)": (55.7370, 37.6845),
"Москва (Пресненский)": (55.7486, 37.5282),
"Москва (Новогиреево)": (55.7545, 37.8107),
"Москва (Тверской)": (55.7655, 37.6038),
"Москва (Хорошёво-Мнёвники)": (55.7657, 37.4668),
"Москва (Преображенское)": (55.7886, 37.7045),
"Москва (Хорошёвский)": (55.7951, 37.5373),
"Москва (Строгино)": (55.7982, 37.3859),
"Москва (Северное Измайлово)": (55.8071, 37.7881),
"Москва (Останкинский)": (55.8086, 37.6174),
"Москва (Покровское-Стрешнево)": (55.8203, 37.4470),
"Москва (Митино)": (55.8467, 37.3877),
"Москва (Отрадное)": (55.8558, 37.5796),
"Москва (Ховрино)": (55.8581, 37.4924),
"Москва (Бабушкинский)": (55.8633, 37.6740),
"Москва (Лианозово)": (55.8944, 37.5622),
"Москва (Куркино — Молжаниново)": (55.9169, 37.3837),
"Москва (Зеленоград, юг)": (55.9793, 37.1620),
"Москва (Зеленоград, север)": (55.9958, 37.2147),
# Кластер «Москва (Восточный, эксклав)» (56.0087, 37.7960) ВЫБРОШЕН
# 11.09.2026 как артефакт геокода — см. «Выброшенный призрак» выше.
#
# Ниже — 12 точек, добавленных тем же замером вручную: жилые районы
# Москвы, которые целиком проигрывали конкурс ближайшего подмосковной
# точке. В комментарии — сколько объявлений возвращает точка и кому они
# уходили (прод-корпус, is_active AND region_code=77).
"Москва (Восточный)": (55.8194, 37.8740), # +9, уходили к Балашихе
"Москва (Левобережный)": (55.8713, 37.4592), # +91, к Химкам
"Москва (Орехово-Борисово Южное)": (55.5986, 37.7233), # +80, к Развилке
"Москва (Северный)": (55.9278, 37.5411), # +79, к Долгопрудному
"Москва (Митино-запад)": (55.8410, 37.3507), # +78, к Красногорску
"Москва (Ивановское)": (55.7717, 37.8326), # +65, к Реутову
"Москва (Жулебино)": (55.6885, 37.8521), # +62, к Люберцам
"Москва (Можайский-запад)": (55.7008, 37.3936), # +50, к Немчиновке
"Москва (Новокосино)": (55.7385, 37.8576), # +44, к Реутову
"Москва (Капотня)": (55.6341, 37.7981), # +29, к Дзержинскому
"Москва (Куркино)": (55.8895, 37.3980), # +20, к Химкам
"Москва (Некрасовка-восток)": (55.6838, 37.9216), # +13, к Люберцам
}
# Все ключи сетки — один display «Москва» и один порог. Кортеж выводится из
# самого словаря, чтобы список ключей физически не мог разойтись с сеткой.
COVERAGE_MOSCOW_CENTROID_KEYS = tuple(_MOSCOW_GRID_DEG)
def _fold_city(name: str) -> str:
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
# Ключ — folded ИМЯ ЦЕНТРОИДА, значение — (display-имя города, порог). Для
# свердловских городов ключ и display совпадают, для московских центроидов —
# нет (все 67 ключей сетки дают «Москва»).
_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(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
for k in COVERAGE_MOSCOW_CENTROID_KEYS
},
}
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
@ -2807,7 +2998,15 @@ _COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
# статичного справочника из 8 географических центров населённых пунктов —
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
# рядом с порогами, которые они резолвят.
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
# Радиусы матчинга. Радиус — СВОЙСТВО ЦЕНТРОИДА (таблица `_CENTROID_RADIUS_KM`
# ниже, применяется в `_resolve_coverage_city`), потому что 25 км осмысленны
# ровно для конфигурации «один центроид на город», а у Москвы центроидов 67 и
# их круги складываются — разбор в комментарии над `_MOSCOW_GRID_DEG`.
# Свердловские точки радиуса не меняли: у каждой из них один центр на город,
# складываться нечему, а замер, которым выбраны эти 25 км, к сетке отношения
# не имеет.
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дефолт: один центроид на город, дальше — not_covered
COVERAGE_MOSCOW_MATCH_RADIUS_KM = 8.0 # сетка из 67 точек; верхний край плато 5..8, потолок 9.83
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Екатеринбург": (56.8389, 60.6057),
@ -2819,8 +3018,99 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Первоуральск": (56.9083, 59.9483),
"Ревда": (56.7986, 59.9298),
"Серов": (59.6047, 60.5772),
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
# Как сетка получена, почему точек именно столько, где проходит граница
# с областью и какова остаточная цена — см. большой комментарий над
# `_MOSCOW_GRID_DEG`. Здесь только подстановка: координаты и ключи живут
# в одном месте, чтобы их нельзя было рассинхронизировать.
**_MOSCOW_GRID_DEG,
}
# ОТРИЦАТЕЛЬНЫЕ центроиды: подмосковные города и посёлки, попадающие внутрь
# радиуса московских точек. Список 11.09.2026 перепроверен на итоговой
# конфигурации (67 точек сетки, радиус 8 км): все 32 точки живы — ни одну
# не перехватывает московский центроид, в самой точке выигрывает она сама,
# и ответ остаётся «город не определён». Внутрь 8 км от какой-нибудь точки
# сетки попадают 13 из них (Химки, Реутов, Люберцы, Котельники, Дзержинский,
# Красногорск, Долгопрудный, Мытищи, Видное, Одинцово, Подольск, Апрелевка,
# Селятино) — каждая выигрывает собственным центром. Ближайшая пара после
# добавления новых точек: «Митино-запад» в 1.6 км от Красногорска. Участвуют
# в конкурсе ближайшего центроида наравне с городами из
# `_CITY_CENTROIDS_DEG`, но НАМЕРЕННО отсутствуют в `_COVERAGE_CITY_THRESHOLDS`:
# центроид без порога = «город не определён», тот же ответ, что для точки
# в чистом поле. Обоснование и цена — в комментарии «ГРАНИЦА С ОБЛАСТЬЮ» выше.
#
# Координаты — WGS84, общедоступные центры НП; список закрытый и расширяется
# только по замеру (точка вне радиуса всех московских бесполезна, точка внутри
# Москвы сожгла бы ещё часть покрытия). Ключи здесь и в `_CITY_CENTROIDS_DEG`
# не должны пересекаться — это проверяется тестом.
#
# Города области, которые НЕ попадают ни в один московский радиус, здесь и не
# нужны: Наро-Фоминск (9.83 км до сетки), Кубинка (17.18), Чехов (22.25) отдают
# «город не определён» просто потому, что вне 8 км. При прежних 25 км все трое
# резолвились как «Москва» — это и был дефект, ради которого радиус переехал
# в свойство центроида.
_COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Андреевка": (55.9772, 37.1100),
"Менделеево": (56.0333, 37.2333),
"Сходня": (55.9500, 37.3000),
"Чёрная Грязь": (55.9667, 37.3167),
"Лунёво": (56.0000, 37.3500),
"Поварово": (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),
}
# Радиус матчинга по центроиду: ключ — folded имя центроида, значение — км.
# Чего здесь нет — берёт `COVERAGE_CITY_MATCH_RADIUS_KM` (25 км), то есть все
# свердловские точки. Таблица выводится из самих словарей координат, чтобы
# радиус физически не мог разойтись со списком точек.
#
# Почему у отрицательных точек радиус бесконечный. Отрицательный центроид
# ничего не «накрывает»: он влияет на ответ только там, где он ближе всех
# (в своей ячейке Вороного), а выигравший центроид без порога даёт «город не
# определён» на ЛЮБОМ расстоянии — обе ветки `_resolve_coverage_city` (вышли
# за радиус / нет порога) возвращают один и тот же ответ. Поэтому конечное
# число здесь было бы декорацией: поведение оно не меняет, но читалось бы как
# «дальше отрицательная точка перестаёт действовать», чего не происходит.
# math.inf говорит правду — границу отрицательной точки задаёт геометрия
# соседей, а не радиус, и попытка «подрезать» её числом ничего не вернёт
# Москве, зато спрячет этот факт от следующего читателя.
_CENTROID_RADIUS_KM: dict[str, float] = {
**{_fold_city(k): COVERAGE_MOSCOW_MATCH_RADIUS_KM for k in COVERAGE_MOSCOW_CENTROID_KEYS},
**{_fold_city(k): math.inf for k in _COVERAGE_NEGATIVE_CENTROIDS_DEG},
}
def _centroid_radius_km(centroid_key: str) -> float:
"""Радиус матчинга КОНКРЕТНОГО центроида в км (см. `_CENTROID_RADIUS_KM`)."""
return _CENTROID_RADIUS_KM.get(_fold_city(centroid_key), COVERAGE_CITY_MATCH_RADIUS_KM)
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
@ -2836,23 +3126,49 @@ def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
в пределах СОБСТВЕННОГО радиуса этого центроида (`_centroid_radius_km`:
25 км у свердловских городов, 8 км у точек московской сетки почему так,
см. комментарий над `_MOSCOW_GRID_DEG`); иначе город не определён.
Детерминированно
и без участия клиента см. комментарий над `_CITY_CENTROIDS_DEG` про то,
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
больше НЕ допускаются в качестве источника истины.
В конкурсе ближайшего участвуют И отрицательные центроиды
(`_COVERAGE_NEGATIVE_CENTROIDS_DEG`) подмосковные точки без порога.
Выигравший центроид без порога означает «город не определён»: так граница
Москвы и области проходит по конкурсу центров, а не по кругу радиуса
(см. «ГРАНИЦА С ОБЛАСТЬЮ» над `_CITY_CENTROIDS_DEG`).
Возвращается display-имя из `_COVERAGE_CITY_THRESHOLDS`, а НЕ ключ
центроида: у Москвы 67 центроидов на один город, и наружу все они обязаны
отдавать «Москва» с одним порогом (см. `COVERAGE_MOSCOW_CENTROID_KEYS`).
"""
nearest_city: str | None = None
nearest_km = math.inf
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
for city, (clat, clon) in (
*_CITY_CENTROIDS_DEG.items(),
*_COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
):
distance_km = _haversine_km(lat, lon, clat, clon)
if distance_km < nearest_km:
nearest_km = distance_km
nearest_city = city
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
# Радиус берётся у ПОБЕДИТЕЛЯ конкурса, а не общий: круги плотной сетки
# складываются, и один глобальный радиус либо режет Свердловскую область,
# либо протекает из Москвы в область (см. `_CENTROID_RADIUS_KM`).
if nearest_city is None or nearest_km > _centroid_radius_km(nearest_city):
return "", 0, False
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
# .get(), а не индексирование: ближайшим мог оказаться отрицательный
# центроид (или новый центроид, для которого забыли завести порог) — это
# «город не определён», а не KeyError и 500 на живом адресе.
entry = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(nearest_city))
if entry is None:
return "", 0, False
display, threshold = entry
return display, threshold, True

View file

@ -304,6 +304,775 @@ def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None:
assert data["threshold"] == 0
# ── Москва: сетка из 67 центроидов, одно display-имя, жёлтый порог ───────────
# Кремль — исторический центр; в сетке ему соответствует точка «Тверской».
_MOSCOW_CENTER = {"lat": 55.7558, "lon": 37.6173}
# Ватутинки, ТиНАО: ~32 км от Кремля. Точка существует, чтобы ловить дальнюю
# от центра часть Новой Москвы, а не центральные ячейки сетки.
_NEW_MOSCOW = {"lat": 55.5008, "lon": 37.3800}
# Зеленоград: эксклав в ~37 км от Кремля и в ~46 км от ТиНАО — без собственных
# точек сетки был бы not_covered.
_ZELENOGRAD = {"lat": 55.9825, "lon": 37.1814}
# Контрольные точки ВНУТРИ МКАД (замер 11.09.2026). До перехода на сетку
# половина из них проигрывала подмосковному центроиду и отдавала «город не
# определён» — это и был дефект, ради которого сетка заведена. Проверяем
# ПОВЕДЕНИЕ резолвера на координатах, а не содержимое константы.
_INSIDE_MKAD = {
"Новогиреево": (55.7520, 37.8140),
"Некрасовка": (55.7020, 37.9200),
"Тушино": (55.8267, 37.4358),
"Северное Бутово": (55.5680, 37.5700),
"Солнцево": (55.6350, 37.3900),
"Ясенево": (55.6060, 37.5330),
"Митино": (55.8450, 37.3630),
"Марьино": (55.6500, 37.7440),
"Измайлово": (55.7890, 37.7510),
"Бирюлёво Восточное": (55.5900, 37.6800),
"Печатники": (55.6900, 37.7300),
"Куркино": (55.8925, 37.3810),
}
# Владимирская область, ~180 км от Кремля и ~200 км от ТиНАО: «посреди поля»
# уже с московской стороны страны — новые центроиды не должны были расширить
# покрытие вглубь ЦФО.
_FAR_FROM_MOSCOW = {"lat": 55.5, "lon": 40.5}
def test_moscow_center_ok_at_yellow_threshold(trade_in_app: FastAPI) -> None:
"""Центр Москвы → city='Москва', порог 12 (жёлтый тир), n=12 → ok."""
db = _db_mock_returning(_row(12, 20.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_MOSCOW_CENTER})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["city"] == "Москва"
assert data["threshold"] == 12
assert data["status"] == "ok"
def test_moscow_is_yellow_not_green(trade_in_app: FastAPI) -> None:
"""n=8 в Москве — это thin, а не ok: тир жёлтый (замер 10.09.2026, медиана
когорты 14 против 37 у зелёного Екатеринбурга, доля когорт >= 12 0.57)."""
db = _db_mock_returning(_row(8, 20.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_MOSCOW_CENTER})
data = resp.json()
assert data["status"] == "thin"
assert data["threshold"] == 12
def test_new_moscow_resolves_to_display_name_moscow(trade_in_app: FastAPI) -> None:
"""ТиНАО ловится своей точкой сетки, но наружу уходит «Москва».
Ключ центроида («Москва (Ватутинки, НАО)») наружу не просачивается иначе
повторился бы баг Берёзовского: человек видит в ответе чужое название.
"""
db = _db_mock_returning(_row(12, 25.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NEW_MOSCOW})
data = resp.json()
assert data["city"] == "Москва"
assert data["threshold"] == 12
assert data["status"] == "ok"
def test_zelenograd_resolves_to_display_name_moscow(trade_in_app: FastAPI) -> None:
"""Зеленоград — свои точки сетки, тоже с display-именем «Москва»."""
db = _db_mock_returning(_row(12, 25.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_ZELENOGRAD})
data = resp.json()
assert data["city"] == "Москва"
assert data["threshold"] == 12
def test_inside_mkad_points_resolve_to_moscow(trade_in_app: FastAPI) -> None:
"""Все контрольные точки внутри МКАД → «Москва» с жёлтым порогом 12.
Регрессия на дефект «сетки нет»: с тремя центроидами Митино, Марьино,
Печатники, Некрасовка и Новогиреево проигрывали подмосковным точкам
(Химки, Реутов, Развилка) и отдавали «город не определён».
"""
from app.api.v1.trade_in import _resolve_coverage_city
for name, (lat, lon) in _INSIDE_MKAD.items():
assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True), (
f"{name} внутри МКАД, но резолвится не в Москву"
)
def test_inside_mkad_points_answer_through_the_endpoint(trade_in_app: FastAPI) -> None:
"""Те же точки через ручку: 200, city='Москва', threshold=12."""
db = _db_mock_returning(_row(12, 20.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
for name, (lat, lon) in _INSIDE_MKAD.items():
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()
assert data["city"] == "Москва", name
assert data["threshold"] == 12, name
assert data["status"] == "ok", name
def test_every_moscow_grid_point_resolves_to_moscow() -> None:
"""В каждой точке сетки резолвер отдаёт «Москву» — мёртвых точек нет.
Точка, у которой ближайшим оказался подмосковный центроид, была бы мёртвой
строкой: её кластер объявлений всё равно ушёл бы в «город не определён».
"""
from app.api.v1.trade_in import _MOSCOW_GRID_DEG, _resolve_coverage_city
assert len(_MOSCOW_GRID_DEG) == 67
for key, (lat, lon) in _MOSCOW_GRID_DEG.items():
assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True), key
def test_far_from_moscow_centroids_still_not_covered(trade_in_app: FastAPI) -> None:
"""Точка посреди поля в 180 км от Москвы → not_covered, city=""."""
db = _db_mock_returning(_row(40, 30.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_FROM_MOSCOW})
data = resp.json()
assert data["status"] == "not_covered"
assert data["city"] == ""
assert data["threshold"] == 0
def test_every_centroid_key_has_a_threshold() -> None:
"""Центроид без порога — это KeyError и 500 в ручке, а не «город не найден».
Инвариант появился вместе с московскими центроидами: ключ центроида и
display-имя города теперь разные вещи, и промах в одном из трёх ключей
молча превратился бы в падение ручки на реальном московском адресе.
"""
from app.api.v1.trade_in import (
_CITY_CENTROIDS_DEG,
_COVERAGE_CITY_THRESHOLDS,
_fold_city,
)
for key in _CITY_CENTROIDS_DEG:
assert _fold_city(key) in _COVERAGE_CITY_THRESHOLDS, (
f"центроид {key} не имеет порога — резолв по координатам упадёт с KeyError"
)
def test_all_moscow_centroids_share_one_display_and_threshold() -> None:
"""Все 56 ключей сетки → одно имя «Москва» и один порог 12.
Проверяем не список координат, а результат резолва в каждой точке сетки:
ключ («Москва (Митино)» и т.п.) наружу просочиться не должен.
"""
from app.api.v1.trade_in import (
_CITY_CENTROIDS_DEG,
_COVERAGE_CITY_THRESHOLDS,
COVERAGE_MOSCOW_CENTROID_KEYS,
COVERAGE_MOSCOW_MIN_N,
_fold_city,
_resolve_coverage_city,
)
assert COVERAGE_MOSCOW_MIN_N == 12
assert len(COVERAGE_MOSCOW_CENTROID_KEYS) == 67
assert len(set(COVERAGE_MOSCOW_CENTROID_KEYS)) == 67, "ключи сетки должны быть уникальны"
for key in COVERAGE_MOSCOW_CENTROID_KEYS:
assert _COVERAGE_CITY_THRESHOLDS[_fold_city(key)] == ("Москва", 12)
lat, lon = _CITY_CENTROIDS_DEG[key]
assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True)
def test_sverdlovsk_resolution_untouched_by_moscow_centroids(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Свердловская область не изменилась ни на байт после добавления Москвы.
Сравниваем резолв на полной таблице центроидов с резолвом на той же
таблице БЕЗ московских ключей по всем свердловским центроидам, по
нескольким точкам между ними и по заведомо далёкой точке.
"""
from app.api.v1 import trade_in as trade_in_module
full = trade_in_module._CITY_CENTROIDS_DEG
moscow_keys = {
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
}
without_moscow = {
k: v for k, v in full.items() if trade_in_module._fold_city(k) not in moscow_keys
}
assert len(without_moscow) == len(full) - 67
points = [
*without_moscow.values(),
(56.75, 60.70), # южная окраина Екатеринбурга
(56.95, 60.65), # между ЕКБ и Верхней Пышмой
(57.35, 60.20), # между Тагилом и ЕКБ — вне радиуса обоих
(58.0424, 65.2711), # Тавда, далеко от всего
]
after = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", without_moscow)
before = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
assert after == before
# ── Подмосковье: отрицательные центроиды забирают область у Москвы ────────────
# Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов
# (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву».
_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),
"Истра": (55.9167, 36.8667),
}
def test_moscow_oblast_cities_resolve_to_no_city() -> None:
"""Подмосковье → «город не определён»: данных по области мы не собираем."""
from app.api.v1.trade_in import _resolve_coverage_city
for name, (lat, lon) in _MOSCOW_OBLAST_CITIES.items():
assert _resolve_coverage_city(lat, lon) == ("", 0, False), (
f"{name} — Московская область, но резолвится в город"
)
def test_every_negative_centroid_resolves_to_no_city() -> None:
"""Каждая точка списка отрицательных центроидов — не город.
Проверяем ВЕСЬ список, а не выборку: точка, случайно оказавшаяся ближе
к московскому центроиду, чем к себе самой, была бы мёртвой строкой.
"""
from app.api.v1.trade_in import (
_COVERAGE_NEGATIVE_CENTROIDS_DEG,
_resolve_coverage_city,
)
assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 32
for name, (lat, lon) in _COVERAGE_NEGATIVE_CENTROIDS_DEG.items():
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name
def test_negative_centroids_have_no_threshold_and_no_key_collision() -> None:
"""Отрицательные центроиды не должны попасть в таблицу порогов.
И не должны пересекаться по ключу с настоящими городами иначе один
словарь молча перекрыл бы другой в конкурсе ближайшего.
"""
from app.api.v1.trade_in import (
_CITY_CENTROIDS_DEG,
_COVERAGE_CITY_THRESHOLDS,
_COVERAGE_NEGATIVE_CENTROIDS_DEG,
_fold_city,
)
negative = {_fold_city(k) for k in _COVERAGE_NEGATIVE_CENTROIDS_DEG}
positive = {_fold_city(k) for k in _CITY_CENTROIDS_DEG}
assert negative & positive == set()
assert negative & set(_COVERAGE_CITY_THRESHOLDS) == set()
def test_moscow_itself_survives_negative_centroids(trade_in_app: FastAPI) -> None:
"""Опорные московские точки по-прежнему дают «Москву» и жёлтый порог."""
from app.api.v1.trade_in import _resolve_coverage_city
for point in (_MOSCOW_CENTER, _NEW_MOSCOW, _ZELENOGRAD):
assert _resolve_coverage_city(point["lat"], point["lon"]) == ("Москва", 12, True)
db = _db_mock_returning(_row(12, 20.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
for point in (_MOSCOW_CENTER, _NEW_MOSCOW, _ZELENOGRAD):
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **point})
assert resp.status_code == 200, resp.text
assert resp.json()["city"] == "Москва"
def test_moscow_oblast_city_does_not_500_on_the_endpoint(trade_in_app: FastAPI) -> None:
"""Химки через ручку: 200 и not_covered, а не 500 от KeyError по порогу."""
db = _db_mock_returning(_row(40, 30.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
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()
assert data["status"] == "not_covered"
assert data["city"] == ""
assert data["threshold"] == 0
def test_nearest_centroid_without_threshold_does_not_break_the_endpoint(
trade_in_app: FastAPI,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ближайший центроид без порога → «город не определён», а не 500.
Раньше порог брался прямым индексированием `_COVERAGE_CITY_THRESHOLDS`,
и любой центроид без порога (отрицательный или просто забытый при
добавлении города) ронял ручку на живом адресе.
"""
from app.api.v1 import trade_in as trade_in_module
monkeypatch.setattr(
trade_in_module,
"_CITY_CENTROIDS_DEG",
{
**trade_in_module._CITY_CENTROIDS_DEG,
"Город Без Порога": (_BASE_PAYLOAD["lat"], _BASE_PAYLOAD["lon"]),
},
)
assert trade_in_module._resolve_coverage_city(_BASE_PAYLOAD["lat"], _BASE_PAYLOAD["lon"]) == (
"",
0,
False,
)
db = _db_mock_returning(_row(40, 30.0))
_override(trade_in_app, db)
resp = TestClient(trade_in_app).post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "not_covered"
assert resp.json()["city"] == ""
def test_sverdlovsk_resolution_untouched_by_negative_centroids(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Свердловская область не изменилась ни на байт от подмосковных точек.
Сравниваем резолв на полной таблице с резолвом БЕЗ отрицательных
центроидов по всем свердловским центроидам, точкам между ними и заведомо
далёкой точке. Плюс прямая проверка расстояний: ближайшая подмосковная
точка к любому свердловскому центроиду дальше, чем сам центроид (0 км).
"""
from app.api.v1 import trade_in as trade_in_module
sverdlovsk = {
k: v
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
if trade_in_module._fold_city(k)
not in {
trade_in_module._fold_city(m) for m in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
}
}
assert len(sverdlovsk) == 9
points = [
*sverdlovsk.values(),
(56.75, 60.70), # южная окраина Екатеринбурга
(56.95, 60.65), # между ЕКБ и Верхней Пышмой
(57.35, 60.20), # между Тагилом и ЕКБ — вне радиуса обоих
(58.0424, 65.2711), # Тавда, далеко от всего
]
after = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
monkeypatch.setattr(trade_in_module, "_COVERAGE_NEGATIVE_CENTROIDS_DEG", {})
before = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
assert after == before
negatives = dict(_MOSCOW_OBLAST_CITIES)
for city, (clat, clon) in sverdlovsk.items():
nearest_negative_km = min(
trade_in_module._haversine_km(clat, clon, nlat, nlon)
for nlat, nlon in negatives.values()
)
assert nearest_negative_km > trade_in_module.COVERAGE_CITY_MATCH_RADIUS_KM, (
f"подмосковная точка в {nearest_negative_km:.0f} км от {city}"
)
# ── Радиус как свойство центроида: контрольные наборы замера 11.09.2026 ──────
# Московский контрольный набор (29 адресов). Не выборка «для красоты»: сюда
# входят все четыре точки, которые в замере оказались худшими по запасу до
# сетки (Рублёво 4.05 км, Внуково 3.64, Медведково 2.78, Бибирево 2.75),
# анклавы (Зеленоград, Восточный, Куркино, Капотня, Новокосино) и по одному
# адресу с каждой стороны МКАД. Проверяется ПОВЕДЕНИЕ резолвера на
# координатах, а не содержимое констант.
_MOSCOW_CONTROLS = {
"Тверской": (55.7558, 37.6173),
"Рублёво": (55.7530, 37.3680),
"Внуково": (55.6270, 37.2860),
"Северное Медведково": (55.8880, 37.6480),
"Бибирево": (55.8930, 37.5900),
"Капотня": (55.6390, 37.7950),
"Новокосино": (55.7400, 37.8600),
"Куркино": (55.8925, 37.3810),
"Восточный": (55.8130, 37.8600),
"Ховрино": (55.8730, 37.4870),
"Северное Тушино": (55.8560, 37.4390),
"Митино": (55.8450, 37.3630),
"Зеленоград": (55.9825, 37.1814),
"Ватутинки (НАО)": (55.5008, 37.3800),
"Некрасовка": (55.7020, 37.9200),
"Новогиреево": (55.7520, 37.8140),
"Ивановское": (55.7750, 37.8330),
"Жулебино": (55.6900, 37.8550),
"Марьино": (55.6500, 37.7440),
"Братеево": (55.6350, 37.7400),
"Орехово-Борисово Южное": (55.6000, 37.7250),
"Бирюлёво Восточное": (55.5900, 37.6800),
"Северное Бутово": (55.5680, 37.5700),
"Ясенево": (55.6060, 37.5330),
"Солнцево": (55.6350, 37.3900),
"Можайский": (55.7050, 37.4000),
"Строгино": (55.8020, 37.4020),
"Северный": (55.9250, 37.5450),
"Троицк (ТАО)": (55.4853, 37.3050),
}
# Областной контрольный набор (32 города). 16 из них имеют собственный
# отрицательный центроид и выигрывают им; остальные 16 держатся только
# радиусом московских точек — включая Наро-Фоминск (9.95 км до ближайшей
# точки сетки), Кубинку (17.2) и Чехов (22.3), которые при глобальных 25 км
# резолвились как «Москва». Это и есть дефект #1.
_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),
"Немчиновка": (55.7050, 37.3450),
"Развилка": (55.5842, 37.7392),
"Томилино": (55.6528, 37.9472),
"Барвиха": (55.7333, 37.2333),
}
# Точки, добавленные замером 11.09.2026: жилые районы, целиком проигрывавшие
# конкурс подмосковной точке. Проверяются по координатам из самого модуля,
# ключ здесь — только адрес строки.
_ADDED_GRID_KEYS = (
"Москва (Восточный)",
"Москва (Левобережный)",
"Москва (Орехово-Борисово Южное)",
"Москва (Северный)",
"Москва (Митино-запад)",
"Москва (Ивановское)",
"Москва (Жулебино)",
"Москва (Можайский-запад)",
"Москва (Новокосино)",
"Москва (Капотня)",
"Москва (Куркино)",
"Москва (Некрасовка-восток)",
)
# Артефакт геокода, выброшенный из сетки: адрес «ВАО, р-н Восточный, 4»,
# посаженный геокодером на 22 км мимо настоящего Восточного и раздававший
# имя «Москва» Пушкино в 3.06 км.
_PHANTOM_EXCLAVE = (56.0087, 37.7960)
def _legacy_resolve(lat: float, lon: float, radius_km: float = 25.0) -> tuple[str, int, bool]:
"""Прежний резолвер: тот же конкурс ближайшего, но ОДИН глобальный радиус.
Нужен, чтобы тесты сравнивали ПОВЕДЕНИЕ, а не пересказывали константы:
им проверяется и что дефект #1 действительно существовал, и что
Свердловская область от перехода на радиус-свойство не сдвинулась.
"""
from app.api.v1 import trade_in as trade_in_module
nearest, nearest_km = None, float("inf")
for city, (clat, clon) in (
*trade_in_module._CITY_CENTROIDS_DEG.items(),
*trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
):
distance_km = trade_in_module._haversine_km(lat, lon, clat, clon)
if distance_km < nearest_km:
nearest_km, nearest = distance_km, city
if nearest is None or nearest_km > radius_km:
return "", 0, False
entry = trade_in_module._COVERAGE_CITY_THRESHOLDS.get(trade_in_module._fold_city(nearest))
return ("", 0, False) if entry is None else (entry[0], entry[1], True)
def test_whole_moscow_control_set_resolves_to_moscow() -> None:
"""Все 29 московских контролей → «Москва», жёлтый порог 12."""
from app.api.v1.trade_in import _resolve_coverage_city
assert len(_MOSCOW_CONTROLS) == 29
failed = {
name: _resolve_coverage_city(lat, lon)
for name, (lat, lon) in _MOSCOW_CONTROLS.items()
if _resolve_coverage_city(lat, lon) != ("Москва", 12, True)
}
assert failed == {}
def test_whole_moscow_control_set_answers_through_the_endpoint(trade_in_app: FastAPI) -> None:
"""Те же 29 адресов через ручку: 200, city='Москва', threshold=12."""
db = _db_mock_returning(_row(12, 20.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
for name, (lat, lon) in _MOSCOW_CONTROLS.items():
resp = client.post(
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon}
)
assert resp.status_code == 200, resp.text
assert resp.json()["city"] == "Москва", name
assert resp.json()["threshold"] == 12, name
def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastAPI) -> None:
"""Все 32 областных контроля → «город не определён», и ручка отдаёт 200."""
from app.api.v1.trade_in import _resolve_coverage_city
assert len(_MOSCOW_OBLAST_CONTROLS) == 32
failed = {
name: _resolve_coverage_city(lat, lon)
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items()
if _resolve_coverage_city(lat, lon) != ("", 0, False)
}
assert failed == {}
db = _db_mock_returning(_row(40, 30.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items():
resp = client.post(
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon}
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "not_covered", name
assert resp.json()["city"] == "", name
def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
"""Дефект #1 предметно: Наро-Фоминск, Кубинка, Чехов.
С одним глобальным радиусом 25 км круги плотной сетки складывались, и все
трое получали «Москву», хотя до ближайшей точки сетки им 9.95, 17.2 и
22.3 км. С радиусом-свойством (8 км у московских точек) «не определён».
"""
from app.api.v1.trade_in import _resolve_coverage_city
for name in ("Наро-Фоминск", "Кубинка", "Чехов"):
lat, lon = _MOSCOW_OBLAST_CONTROLS[name]
assert _legacy_resolve(lat, lon) == ("Москва", 12, True), (
f"{name}: тест перестал воспроизводить дефект, сравнивать не с чем"
)
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name
def test_radius_is_a_property_of_the_centroid_not_a_global_constant() -> None:
"""Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской.
Наро-Фоминск в 9.95 км от ближайшей точки московской сетки «не
определён». Точка в 20 км южнее центра Екатеринбурга (то есть ДАЛЬШЕ)
«Екатеринбург». Глобальной константой такое поведение невыразимо.
"""
from app.api.v1 import trade_in as trade_in_module
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()
)
assert 8.0 < km_to_grid < 25.0, f"{km_to_grid:.2f} км — контроль потерял смысл"
assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False)
ekb_lat, ekb_lon = trade_in_module._CITY_CENTROIDS_DEG["Екатеринбург"]
south_lat = ekb_lat - 20.0 / 111.19
km_to_ekb = trade_in_module._haversine_km(south_lat, ekb_lon, ekb_lat, ekb_lon)
assert km_to_ekb > km_to_grid, "свердловская проверка должна быть ДАЛЬШЕ московской"
assert trade_in_module._resolve_coverage_city(south_lat, ekb_lon) == ("Екатеринбург", 8, True)
def test_sverdlovsk_resolution_identical_to_one_global_radius() -> None:
"""Свердловская область байт-в-байт: 25 км ей никто не менял.
Сравнение поведения, а не констант: густая решётка по всей области плюс
кольца по 24.5 и 25.5 км вокруг каждого свердловского центра (там, где
граница радиуса и проходит) ответ нового резолвера обязан совпасть
с ответом прежнего, у которого радиус был один глобальный.
"""
import math
from app.api.v1 import trade_in as trade_in_module
points: list[tuple[float, float]] = []
lat = 55.0
while lat <= 60.5:
lon = 57.0
while lon <= 66.0:
points.append((round(lat, 4), round(lon, 4)))
lon += 0.25
lat += 0.25
moscow_keys = {
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
}
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
}
assert len(sverdlovsk) == 9
for clat, clon in sverdlovsk.values():
for km in (24.5, 25.5):
dlat = km / 111.19
dlon = km / (111.19 * math.cos(math.radians(clat)))
points += [
(clat + dlat, clon),
(clat - dlat, clon),
(clat, clon + dlon),
(clat, clon - dlon),
]
mismatched = [
(lat, lon)
for lat, lon in points
if trade_in_module._resolve_coverage_city(lat, lon) != _legacy_resolve(lat, lon)
]
assert mismatched == [], f"{len(mismatched)} точек Свердловской изменили резолв"
assert len(points) > 800
def test_phantom_exclave_no_longer_gives_moscow_to_pushkino(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой».
Возвращаем призрак в таблицу Пушкино снова резолвится в «Москву»: это
доказывает, что дефект чинит именно выброс точки, а не что-нибудь ещё.
"""
from app.api.v1 import trade_in as trade_in_module
pushkino = _MOSCOW_OBLAST_CONTROLS["Пушкино"]
assert trade_in_module._resolve_coverage_city(*_PHANTOM_EXCLAVE) != ("Москва", 12, True)
assert trade_in_module._resolve_coverage_city(*pushkino) == ("", 0, False)
ghost_key = "Москва (Восточный, эксклав)"
monkeypatch.setattr(
trade_in_module,
"_CITY_CENTROIDS_DEG",
{**trade_in_module._CITY_CENTROIDS_DEG, ghost_key: _PHANTOM_EXCLAVE},
)
# Возвращаем призрак целиком, как он жил: координаты, порог «Москвы»
# и московский радиус — иначе проверялась бы не сетка, а таблица порогов.
monkeypatch.setattr(
trade_in_module,
"_COVERAGE_CITY_THRESHOLDS",
{
**trade_in_module._COVERAGE_CITY_THRESHOLDS,
trade_in_module._fold_city(ghost_key): (
trade_in_module.COVERAGE_MOSCOW_DISPLAY,
trade_in_module.COVERAGE_MOSCOW_MIN_N,
),
},
)
monkeypatch.setattr(
trade_in_module,
"_CENTROID_RADIUS_KM",
{
**trade_in_module._CENTROID_RADIUS_KM,
trade_in_module._fold_city(ghost_key): trade_in_module.COVERAGE_MOSCOW_MATCH_RADIUS_KM,
},
)
assert trade_in_module._resolve_coverage_city(*pushkino) == ("Москва", 12, True)
def test_added_grid_points_return_districts_lost_to_negative_centroids(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Каждая добавленная точка что-то возвращает: без неё район теряется.
Для всех 12 точек: в самой точке резолвер отдаёт «Москву», а если убрать
её из таблицы «город не определён» (район уходит подмосковному центру).
Мёртвых строк в добавке нет.
"""
from app.api.v1 import trade_in as trade_in_module
full = trade_in_module._CITY_CENTROIDS_DEG
for key in _ADDED_GRID_KEYS:
lat, lon = full[key]
assert trade_in_module._resolve_coverage_city(lat, lon) == ("Москва", 12, True), key
monkeypatch.setattr(
trade_in_module,
"_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} ничего не возвращает — строка мёртвая"
)
monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", full)
def test_negative_centroid_radius_is_unbounded_and_endpoint_survives_it(
trade_in_app: FastAPI,
) -> None:
"""У отрицательной точки радиус бесконечный, и ручка на этом не падает.
Отрицательный центроид действует ровно там, где он ближайший, и на любом
расстоянии означает «город не определён»: точка в глухом углу области,
где ближайший центр подмосковный и дальше 25 км, отдаёт not_covered
и 200, а не 500 по отсутствующему порогу.
"""
from app.api.v1 import trade_in as trade_in_module
far_lat, far_lon = 55.30, 38.75
nearest_km, nearest_name = min(
(trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name)
for name, (clat, clon) in (
*trade_in_module._CITY_CENTROIDS_DEG.items(),
*trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
)
)
assert nearest_name in trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG
assert nearest_km > trade_in_module.COVERAGE_CITY_MATCH_RADIUS_KM
assert trade_in_module._centroid_radius_km(nearest_name) == float("inf")
assert trade_in_module._resolve_coverage_city(far_lat, far_lon) == ("", 0, False)
db = _db_mock_returning(_row(40, 30.0))
_override(trade_in_app, db)
resp = TestClient(trade_in_app).post(
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": far_lat, "lon": far_lon}
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "not_covered"
assert resp.json()["city"] == ""
def test_city_hint_does_not_change_threshold_or_status(trade_in_app: FastAPI) -> None:
"""city_hint — чисто информационное поле (повторная проверка #2894): точка в
Берёзовском + city_hint='Екатеринбург' обязана резолвиться в Берёзовский