diff --git a/tradein-mvp/backend/tests/test_msk_sweep_registry.py b/tradein-mvp/backend/tests/test_msk_sweep_registry.py index ce44a28e..03f9ef2f 100644 --- a/tradein-mvp/backend/tests/test_msk_sweep_registry.py +++ b/tradein-mvp/backend/tests/test_msk_sweep_registry.py @@ -21,6 +21,9 @@ from scraper_kit.orchestration.pipeline import ( MOSCOW_ANCHOR_RADIUS_M, MOSCOW_ANCHORS, MOSCOW_CORE_BBOX_DEG, + MOSCOW_OBLAST_ANCHOR_RADIUS_M, + MOSCOW_OBLAST_CITY_ANCHORS, + MOSCOW_OBLAST_CLUSTER_ANCHORS, MULTI_CITY_SLUGS, get_city_anchor_point, get_city_anchors, @@ -179,6 +182,36 @@ def test_moscow_oblast_anchors_are_satellite_cities() -> None: assert len(combined) == len(MOSCOW_ANCHORS) + len(anchors) +def test_moscow_oblast_anchors_are_two_measured_layers() -> None: + """Оба слоя на месте и не перепутаны: города-спутники + кластеры лот-массы.""" + anchors = get_city_anchors("moskovskaya_oblast") + assert anchors is not None + assert anchors == [*MOSCOW_OBLAST_CITY_ANCHORS, *MOSCOW_OBLAST_CLUSTER_ANCHORS] + # Слой 2 добирает то, чего не дают города: без него покрытие лот-массы 73.8%. + assert len(MOSCOW_OBLAST_CLUSTER_ANCHORS) > 0 + cities = {name for _lat, _lon, name in MOSCOW_OBLAST_CITY_ANCHORS} + clusters = {name for _lat, _lon, name in MOSCOW_OBLAST_CLUSTER_ANCHORS} + assert not (cities & clusters), "подписи слоёв пересеклись — якорь задублирован" + + +def test_moscow_oblast_anchors_lie_inside_region_50_bbox() -> None: + """Якорь вне области собрал бы чужой регион и проштамповал бы его как 50. + + Границы — bbox_region региона 50 из backend/app/services/regions.py (дубль: + kit не имеет права импортировать app.*). + """ + lat_min, lat_max, lon_min, lon_max = 54.20, 56.96, 35.14, 40.21 + for lat, lon, name in [*MOSCOW_OBLAST_CITY_ANCHORS, *MOSCOW_OBLAST_CLUSTER_ANCHORS]: + assert lat_min <= lat <= lat_max, f"{name}: широта вне области" + assert lon_min <= lon <= lon_max, f"{name}: долгота вне области" + + +def test_moscow_oblast_radius_is_the_one_coverage_was_measured_at() -> None: + """95.2% лот-массы посчитаны при 10 км; меньший radius_m в расписании даст дыры.""" + assert MOSCOW_OBLAST_ANCHOR_RADIUS_M == 10000 + assert MOSCOW_OBLAST_ANCHOR_RADIUS_M > MOSCOW_ANCHOR_RADIUS_M + + def test_moscow_stamp_radius_covers_core_diagonal() -> None: """15 км по умолчанию отрезали бы легитимную Москву — у ядра полудиагональ 29.1 км.""" _lat_min, lat_max, _lon_min, lon_max = MOSCOW_CORE_BBOX_DEG diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 2fad6c5a..f9ad6e60 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -338,32 +338,88 @@ def _build_moscow_anchor_grid() -> list[tuple[float, float, str]]: MOSCOW_ANCHORS: list[tuple[float, float, str]] = _build_moscow_anchor_grid() -# Московская область: по якорю на крупный город-спутник. Координаты — WGS84-центры -# НП, взятые из ЕДИНСТВЕННОГО места репозитория, где они уже есть и перепроверены -# замером: `_COVERAGE_NEGATIVE_CENTROIDS_DEG` в backend/app/api/v1/trade_in.py -# (тот же запрет на импорт app.* — значения продублированы, править нужно оба места). +# Московская область. Двухслойная сетка якорей; оба слоя измерены, ни одна координата +# не выдумана. # -# TODO(msk): порядок — по величине города, а НЕ по числу лотов: замера «сколько -# лотов у какого города области» (в области всего 60 231 лот, cian region=4593) нет -# ни в репозитории, ни в прод-корпусе, а гадать ранжирование нечем. Крупные города -# области, которых нет в источнике координат (Домодедово, Раменское, Щёлково, -# Сергиев Посад, Ногинск, Электросталь, Серпухов, Коломна, Орехово-Зуево, -# Наро-Фоминск), СОЗНАТЕЛЬНО не заведены — выдуманные координаты хуже отсутствующих; -# добавлять их следует вместе с записью в _COVERAGE_NEGATIVE_CENTROIDS_DEG по общему -# замеру. +# Слой 1 — города-спутники (22). Координаты первых 12 продублированы из +# `_COVERAGE_NEGATIVE_CENTROIDS_DEG` в backend/app/api/v1/trade_in.py (тот же запрет +# на импорт app.* — править нужно оба места); остальные 10 (Домодедово … Наро-Фоминск) +# взяты прямым запросом к Nominatim /search (city=<НП>, state=Московская область) +# 15.09.2026, все вернулись как place=city/town. +# +# Слой 2 — кластеры лот-массы (22). Это НЕ центры НП, а центроиды активных объявлений +# области в ячейках 0.05°, добранные жадным покрытием поверх слоя 1; подпись — ближайший +# НП по обратному геокодированию (zoom=14), она справочная. Слой нужен потому, что одних +# городов мало: замер на проде 15.09.2026 по 70 996 активным объявлениям region_code=50 +# показал, что 22 городских якоря радиусом 10 км накрывают лишь 73.8% лот-массы, а вместе +# со вторым слоем — 95.2%. +# +# Порядок в обоих слоях — по УБЫВАНИЮ измеренной лот-массы (в скобках у каждой строки: +# слой 1 — активных лотов в 10 км от якоря, слой 2 — прирост покрытия, который этот якорь +# дал жадному алгоритму). Это и есть замер, которого раньше не было. +# +# ВАЖНО: покрытие считано при радиусе MOSCOW_OBLAST_ANCHOR_RADIUS_M. Расписание, которое +# передаст меньший radius_m, получит дыры — 95.2% относятся именно к 10 км. +# +# Оговорка о смещении: лот-масса взята из УЖЕ собранного корпуса (разовая загрузка +# 10-12.09.2026). Там, где разовый сбор не добрал, масса занижена, поэтому 95.2% — +# оценка сверху по тому, что видно, а не доказательство полноты по генеральной +# совокупности. Первый регулярный прогон обязан пересчитать покрытие по своему улову. +MOSCOW_OBLAST_ANCHOR_RADIUS_M = 10000 + +MOSCOW_OBLAST_CITY_ANCHORS: list[tuple[float, float, str]] = [ + (55.6767, 37.8931, "Люберцы"), # 7429 + (55.7614, 37.8564, "Реутов"), # 6824 + (55.7444, 38.0128, "Железнодорожный"), # 6465 + (55.7969, 37.9386, "Балашиха"), # 6429 + (55.8317, 37.3300, "Красногорск"), # 5556 + (55.9142, 37.8256, "Королёв"), # 5208 + (55.9116, 37.7308, "Мытищи"), # 4805 + (55.8894, 37.4450, "Химки"), # 4701 + (55.5519, 37.7133, "Видное"), # 4574 + (55.6789, 37.2639, "Одинцово"), # 4077 + (55.9206, 37.9916, "Щёлково"), # 4031 + (55.9386, 37.5100, "Долгопрудный"), # 3506 + (55.8554, 38.4412, "Ногинск"), # 2820 + (55.7845, 38.4449, "Электросталь"), # 2462 + (55.4312, 37.5450, "Подольск"), # 2453 + (55.4368, 37.7681, "Домодедово"), # 2059 + (56.3153, 38.1360, "Сергиев Посад"), # 1889 + (55.5709, 38.2282, "Раменское"), # 1344 + (55.0939, 38.7670, "Коломна"), # 1118 + (54.9155, 37.4196, "Серпухов"), # 1059 + (55.8062, 38.9810, "Орехово-Зуево"), # 824 + (55.3843, 36.7235, "Наро-Фоминск"), # 802 +] + +MOSCOW_OBLAST_CLUSTER_ANCHORS: list[tuple[float, float, str]] = [ + (55.9395, 37.1805, "Рузино"), # +1528 + (56.0837, 37.9229, "Лесной"), # +1253 + (55.2779, 38.7415, "Воскресенск"), # +1002 + (55.5588, 37.0775, "Апрелевка"), # +974 + (55.7860, 36.9874, "Писково"), # +928 + (56.2883, 37.4825, "Яхрома"), # +887 + (56.3704, 36.6872, "Клин"), # +865 + (55.6113, 37.9765, "Октябрьский"), # +825 + (56.1460, 37.0592, "Солнечногорск"), # +616 + (55.8732, 38.1862, "Осеево"), # +597 + (55.3705, 39.0622, "Егорьевск"), # +587 + (54.8393, 38.1760, "Кашира"), # +585 + (55.7768, 38.7096, "Павловский Посад"), # +494 + (55.1608, 37.4732, "Чехов"), # +493 + (56.0920, 37.4972, "Некрасовский"), # +440 + (55.5773, 39.5299, "Шатура"), # +397 + (56.7358, 37.1643, "Дубна"), # +392 + (55.9134, 36.8293, "Истра"), # +389 + (56.4933, 38.1792, "Богородское"), # +312 + (54.9619, 39.0273, "Луховицы"), # +228 + (55.7715, 37.2308, "Ильинское-Усово"), # +224 + (56.2552, 37.9754, "Хотьково"), # +207 +] + MOSCOW_OBLAST_ANCHORS: list[tuple[float, float, str]] = [ - (55.7969, 37.9386, "Балашиха"), - (55.8894, 37.4450, "Химки"), - (55.4312, 37.5450, "Подольск"), - (55.9116, 37.7308, "Мытищи"), - (55.9142, 37.8256, "Королёв"), - (55.6767, 37.8931, "Люберцы"), - (55.8317, 37.3300, "Красногорск"), - (55.6789, 37.2639, "Одинцово"), - (55.9386, 37.5100, "Долгопрудный"), - (55.5519, 37.7133, "Видное"), - (55.7614, 37.8564, "Реутов"), - (55.7444, 38.0128, "Железнодорожный"), + *MOSCOW_OBLAST_CITY_ANCHORS, + *MOSCOW_OBLAST_CLUSTER_ANCHORS, ] # Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66).