From 50c1df5e0e461ea0c733bc3884c3b6b7d6f240d9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 11 Sep 2026 01:37:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(msk):=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=87=D0=B0=D0=B5=D1=82=20=D0=BF=D0=BE=20=D0=9C?= =?UTF-8?q?=D0=BE=D1=81=D0=BA=D0=B2=D0=B5=20=E2=80=94=20=D1=81=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=86=D0=B5=D0=BD=D1=82=D1=80=D0=BE=D0=B8=D0=B4?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B8=20=D1=80=D0=B0=D0=B4=D0=B8=D1=83=D1=81=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=82=D0=BE=D1=87=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Резолвер города в пробе покрытия знал только 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 Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh --- tradein-mvp/backend/app/api/v1/trade_in.py | 326 +++++++- .../tests/test_coverage_probe_endpoint.py | 769 ++++++++++++++++++ 2 files changed, 1090 insertions(+), 5 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index bc05717c..23d6cf8b 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py index 266af904..4a6b4df1 100644 --- a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py +++ b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py @@ -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='Екатеринбург' обязана резолвиться в Берёзовский