All checks were successful
Deploy Trade-In / changes (push) Successful in 23s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy Trade-In / deploy (push) Successful in 1m53s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
588 lines
38 KiB
Python
588 lines
38 KiB
Python
"""Реестр регионов покрытия (#3051, часть 1/3 эпика #2989 «Москва»).
|
||
|
||
ЕДИНСТВЕННОЕ место, где живут границы покрытия: bbox'ы, города, city-token и
|
||
набор доступных тиров обогащения. До реестра границы лежали константами в трёх
|
||
файлах (location_index / geocoder / matching.normalize), и каждая молча
|
||
отвергла бы Москву; расширение покрытия означало правку россыпи литералов
|
||
(ровно так дыра #2656 в свежести и появилась — «в главном месте есть, в
|
||
соседних забыли»).
|
||
|
||
Регион 50 (Московская область) отложен сознательно — обоснование в #2996:
|
||
10 121 текстовое имя города против 612 у Москвы, вся мина имён — в области.
|
||
|
||
#3052: `region_for_point` резолвит по НАСТОЯЩЕЙ границе региона (полигон
|
||
OSM/Nominatim, `region_boundaries/boundaries.geojson.json`), не по
|
||
прямоугольнику `bbox_region` — прямоугольники Москвы и области пересекались
|
||
(Химки/Реутов/Котельники/Люберцы уходили в Москву), полигоны нет.
|
||
|
||
Модуль — ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают
|
||
geocoder / location_index / matching.normalize, циклы недопустимы). json/
|
||
pathlib — стандартная библиотека, листовость не нарушают.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import NamedTuple
|
||
|
||
# bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический
|
||
# geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже).
|
||
BBox = tuple[float, float, float, float]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Region:
|
||
"""Один регион покрытия продукта.
|
||
|
||
bbox_tight — ядро города: geocoder-фильтрация фуззи-матчей провайдеров
|
||
(не принять соседний город за совпадение по опечатке). У
|
||
региона БЕЗ одного центрального города (50 — область, много
|
||
сопоставимых по объёму городов, ни один не «ядро») — равен
|
||
bbox_product_core: эмпирический пояс, где данные РЕАЛЬНО
|
||
наблюдались (перцентили 0.5..99.5 координат сырья), а не
|
||
административная граница — см. REGIONS[50] и обоснование там.
|
||
bbox_wide — город + легитимное приграничье: ingest-guard координат,
|
||
ПРИШЕДШИХ ИЗВНЕ (detail-страницы площадок). Содержит tight.
|
||
У 50 — полный наблюдённый диапазон координат (min..max, без
|
||
перцентильной обрезки) вместо «город + отступ»: без своего
|
||
города отступать не от чего, поэтому граница «легитимности»
|
||
здесь тоже эмпирическая, просто менее обрезанная, чем tight.
|
||
bbox_region — генеральный bbox региона: fallback-accept для провайдеров без
|
||
структурного region-поля. Содержит wide.
|
||
bbox_product_core — гео-охват ПРОДУКТА в этом регионе: location_index
|
||
считает свои медианы/POI только внутри него, точка вне —
|
||
out_of_coverage. У 66 УЖЕ (не равен) tight: исторический bbox
|
||
location_index (56.70..56.95/60.50..60.75), синхронизирован с
|
||
EKB_BBOX Overpass-загрузчика POI основного gendesign-бэкенда
|
||
(комментарий в обе стороны, см. site_finder/poi_loader.py). У
|
||
50 — намеренно НЕ административный bbox (обещать охват там,
|
||
где нет ни одного объявления, нельзя) — эмпирический пояс
|
||
фактических данных, см. REGIONS[50].
|
||
city_token — нормализованный токен главного города (нижний регистр, е==ё
|
||
нормализует потребитель — matching.normalize). У региона без
|
||
единого центра (50) — самый объёмный по данным город, который
|
||
ОДНОВРЕМЕННО де-факто административный: см. REGIONS[50].
|
||
cities — узнаваемые города региона (для city_hint / prefix-логики
|
||
геокодера). НЕ исчерпывающий список — основные центры.
|
||
enrichment_tiers — какие тиры обогащения РЕАЛЬНО доступны региону.
|
||
Регион без тира должен деградировать ЯВНО (потребитель
|
||
спрашивает unsupported_tier_reason и логирует/маркирует),
|
||
а не молча считать дальше без источника.
|
||
canonical_city — #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
|
||
строк, приходящих из источника без надёжного city-поля
|
||
(Росреестр по Москве отдаёт муниципальный округ/поселение
|
||
вместо города — «Раменки», «Сосенское» — а не «Москва»).
|
||
None — источник несёт свой city как есть, без override
|
||
(регион 66: byte-for-byte прежнее поведение). Not-None —
|
||
потребитель (import_rosreestr_dkp) подставляет это имя
|
||
вместо city источника и НЕ фильтрует по city IS NOT NULL
|
||
(иначе на 77 теряется ~10% строк с пустым city).
|
||
"""
|
||
|
||
code: int
|
||
name: str
|
||
bbox_tight: BBox
|
||
bbox_wide: BBox
|
||
bbox_region: BBox
|
||
bbox_product_core: BBox
|
||
city_token: str
|
||
cities: frozenset[str]
|
||
enrichment_tiers: frozenset[str]
|
||
canonical_city: str | None = None
|
||
|
||
|
||
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
||
"""True если (lat, lon) внутри bbox (inclusive)."""
|
||
lat_min, lat_max, lon_min, lon_max = bbox
|
||
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
|
||
|
||
|
||
# ── #3052: настоящая граница региона (point-in-polygon вместо bbox) ─────────
|
||
#
|
||
# Источник — region_boundaries/boundaries.geojson.json: полигоны OSM/Nominatim
|
||
# (см. поля _source/_license внутри файла, лицензия ODbL требует их хранить).
|
||
# Парсим один раз на импорте модуля — 27 точек лишний JSON-parse на каждый
|
||
# вызов region_for_point был бы дороже самого ray casting.
|
||
#
|
||
# ТОЧНОСТЬ (правка регрессии первой версии #3052). Полигоны берутся БЕЗ
|
||
# упрощения (`polygon_threshold=0`, 42 755 вершин), а не с `0.005` (~500 м,
|
||
# 2 388 вершин), как в первой версии. Полукилометровое упрощение спрямляет
|
||
# ровно то, ради чего полигоны и заводились — изрезанную границу Москвы с
|
||
# областью. Сверка резолва против сохранённого region_code по проду (277 582
|
||
# объявления + 48 143 дома) на упрощённой геометрии дала: 69 точек НИ в одном
|
||
# полигоне (агломерация → None → вызывающий код `trade_in.py` подставляет
|
||
# DEFAULT_REGION_CODE, и адрес в Куркино считается по коэффициентам
|
||
# Екатеринбурга), Митино/Косино-Ухтомский/Можайский вне Москвы (27 домов),
|
||
# 731 объявление области внутри Москвы. Без упрощения этих спрямлений нет.
|
||
#
|
||
# Ring — одно кольцо GeoJSON (lon, lat) точек: внешний контур ИЛИ дыра.
|
||
# Полигон/мультиполигон региона хранится как ПЛОСКИЙ список всех его колец
|
||
# (для MultiPolygon 77 — кольца всех 10 частей вперемешку, у Polygon 50 —
|
||
# внешнее кольцо + 9 дыр). Плоский список работает благодаря even-odd
|
||
# правилу: точка внутри региона ⟺ она попадает внутрь НЕЧЁТНОГО числа колец
|
||
# из списка. Это ОДНОВРЕМЕННО корректно обрабатывает дыры (Москва — дыра в
|
||
# кольцах региона 50: попадание в кольцо-дыру снимает чётность, снятую
|
||
# внешним кольцом) и непересекающиеся части мультиполигона (попадание ровно
|
||
# в одно кольцо — нечётность не портится соседними частями, которые точка не
|
||
# задевает) — без явного разделения "внешний контур минус дыры".
|
||
Ring = list[tuple[float, float]]
|
||
|
||
_BOUNDARIES_PATH = Path(__file__).parent / "region_boundaries" / "boundaries.geojson.json"
|
||
|
||
|
||
def _flatten_rings(geometry: dict[str, object]) -> list[Ring]:
|
||
"""Все кольца геометрии (Polygon или MultiPolygon) одним плоским списком."""
|
||
coords = geometry["coordinates"]
|
||
if geometry["type"] == "Polygon":
|
||
polygons = [coords]
|
||
elif geometry["type"] == "MultiPolygon":
|
||
polygons = coords # type: ignore[assignment]
|
||
else:
|
||
raise ValueError(f"неподдержанный тип геометрии: {geometry['type']}")
|
||
rings: list[Ring] = []
|
||
for polygon in polygons:
|
||
for ring in polygon: # type: ignore[union-attr]
|
||
rings.append([(pt[0], pt[1]) for pt in ring]) # type: ignore[index]
|
||
return rings
|
||
|
||
|
||
def _load_region_boundaries() -> dict[int, list[Ring]]:
|
||
"""code региона → плоский список колец его полигона/мультиполигона."""
|
||
with _BOUNDARIES_PATH.open(encoding="utf-8") as f:
|
||
payload = json.load(f)
|
||
return {
|
||
int(code): _flatten_rings(entry["geometry"]) for code, entry in payload["regions"].items()
|
||
}
|
||
|
||
|
||
_REGION_BOUNDARIES: dict[int, list[Ring]] = _load_region_boundaries()
|
||
|
||
|
||
class _IndexedRing(NamedTuple):
|
||
"""Кольцо + предпосчёт, которым точная геометрия остаётся дешёвой.
|
||
|
||
Наивный ray casting линеен по числу вершин, а вершин после отказа от
|
||
упрощения в 18 раз больше (42 755 против 2 388) — прямым перебором это
|
||
десятки-сотни микросекунд на вызов. Два предпосчёта снимают почти всё:
|
||
|
||
lat_min/lat_max/lon_min/lon_max — bbox САМОГО кольца (не региона): у
|
||
Москвы мультиполигон из 10 частей, у области внешний контур + 9 дыр,
|
||
и точка почти всегда вне bbox большинства колец — такое кольцо
|
||
отвергается четырьмя сравнениями вместо обхода вершин.
|
||
band_h/bands — кольцо нарезано на горизонтальные полосы по широте; в
|
||
полосе лежат ТОЛЬКО те рёбра, чей диапазон широт её задевает (ребро
|
||
попадает во все свои полосы, поэтому пересечения не теряются).
|
||
Луч ray casting горизонтален, значит пересекать его могут лишь рёбра
|
||
своей полосы — обход сокращается с «все вершины кольца» до «рёбра
|
||
одной полосы» (для внешнего контура области — 23 511 → максимум 724,
|
||
типично десятки).
|
||
points — те же вершины ОДНИМ объектом с _REGION_BOUNDARIES (не копия):
|
||
нужны fallback'у по расстоянию до границы в region_for_point.
|
||
"""
|
||
|
||
lat_min: float
|
||
lat_max: float
|
||
lon_min: float
|
||
lon_max: float
|
||
band_h: float
|
||
bands: tuple[tuple[tuple[float, float, float, float], ...], ...]
|
||
points: Ring
|
||
|
||
|
||
# Целевая плотность полосы: ~4 вершины кольца на полосу. Мельче — полосы
|
||
# начинают стоить памяти и промахов кэша больше, чем экономят на обходе;
|
||
# крупнее — в полосу попадает слишком много рёбер. Потолок числа полос
|
||
# ограничивает память на самых длинных кольцах (внешний контур области).
|
||
_RING_BAND_DENSITY = 4
|
||
_RING_MAX_BANDS = 1024
|
||
|
||
|
||
def _index_ring(points: Ring) -> _IndexedRing:
|
||
"""Посчитать bbox и полосы кольца (один раз на импорте модуля)."""
|
||
lat_min = min(p[1] for p in points)
|
||
lat_max = max(p[1] for p in points)
|
||
lat_span = lat_max - lat_min
|
||
n_bands = max(1, min(_RING_MAX_BANDS, len(points) // _RING_BAND_DENSITY))
|
||
# Вырожденное по широте кольцо (lat_span == 0) в полосы не режется:
|
||
# одна полоса, band_h произвольный ненулевой — делить на 0 нельзя.
|
||
band_h = lat_span / n_bands if lat_span > 0.0 else 1.0
|
||
buckets: list[list[tuple[float, float, float, float]]] = [[] for _ in range(n_bands)]
|
||
x_prev, y_prev = points[-1]
|
||
for x, y in points:
|
||
edge = (x, y, x_prev, y_prev)
|
||
lo = int((min(y, y_prev) - lat_min) / band_h)
|
||
hi = int((max(y, y_prev) - lat_min) / band_h)
|
||
lo = 0 if lo < 0 else min(lo, n_bands - 1)
|
||
hi = 0 if hi < 0 else min(hi, n_bands - 1)
|
||
for band in range(lo, hi + 1):
|
||
buckets[band].append(edge)
|
||
x_prev, y_prev = x, y
|
||
return _IndexedRing(
|
||
lat_min=lat_min,
|
||
lat_max=lat_max,
|
||
lon_min=min(p[0] for p in points),
|
||
lon_max=max(p[0] for p in points),
|
||
band_h=band_h,
|
||
bands=tuple(tuple(b) for b in buckets),
|
||
points=points,
|
||
)
|
||
|
||
|
||
_REGION_RING_INDEX: dict[int, tuple[_IndexedRing, ...]] = {
|
||
code: tuple(_index_ring(ring) for ring in rings) for code, rings in _REGION_BOUNDARIES.items()
|
||
}
|
||
|
||
|
||
def _point_in_ring(ring: _IndexedRing, lon: float, lat: float) -> bool:
|
||
"""PNPOLY (W. R. Franklin) ray casting по рёбрам ОДНОЙ полосы кольца."""
|
||
lat_min, lat_max, lon_min, lon_max, band_h, bands, _points = ring
|
||
if lat < lat_min or lat > lat_max or lon < lon_min or lon > lon_max:
|
||
return False
|
||
band = int((lat - lat_min) / band_h)
|
||
if band >= len(bands): # lat == lat_max попадает ровно на верхнюю кромку
|
||
band = len(bands) - 1
|
||
inside = False
|
||
for xi, yi, xj, yj in bands[band]:
|
||
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
|
||
inside = not inside
|
||
return inside
|
||
|
||
|
||
def _point_in_region_polygon(code: int, lat: float, lon: float) -> bool:
|
||
"""True если (lat, lon) внутри настоящей границы региона `code`.
|
||
|
||
even-odd по всем кольцам сразу (см. комментарий выше про плоский список).
|
||
Региона без загруженной границы — граничит только по bbox (см. вызов в
|
||
region_for_point), сюда такой код не попадает."""
|
||
inside = False
|
||
for ring in _REGION_RING_INDEX[code]:
|
||
if _point_in_ring(ring, lon, lat):
|
||
inside = not inside
|
||
return inside
|
||
|
||
|
||
def _distance_to_boundary(code: int, lat: float, lon: float) -> float:
|
||
"""Расстояние от точки до ближайшего ребра границы региона `code`.
|
||
|
||
В градусах широты (1° ≈ 111 км) по ПЛОСКОМУ приближению с поправкой
|
||
cos(lat) на сжатие долготы: значение сравнивается только с таким же
|
||
значением другого региона-кандидата на тех же ~десятках метров, где
|
||
геодезия и плоскость расходятся на доли процента.
|
||
|
||
Путь редкий (только щель между полигонами соседних регионов, см.
|
||
region_for_point), поэтому обход честный — по всем рёбрам, без полос.
|
||
Стоимость срезается оценкой снизу по bbox кольца: кольцо, чей bbox уже
|
||
дальше найденного минимума, не может дать ребра ближе.
|
||
"""
|
||
lat_scale = math.cos(math.radians(lat))
|
||
px = lon * lat_scale
|
||
best = math.inf
|
||
ranked: list[tuple[float, _IndexedRing]] = []
|
||
for ring in _REGION_RING_INDEX[code]:
|
||
dx = max(ring.lon_min - lon, 0.0, lon - ring.lon_max) * lat_scale
|
||
dy = max(ring.lat_min - lat, 0.0, lat - ring.lat_max)
|
||
ranked.append((dx * dx + dy * dy, ring))
|
||
for lower_bound, ring in sorted(ranked, key=lambda item: item[0]):
|
||
if lower_bound >= best:
|
||
break
|
||
ax, ay = ring.points[-1]
|
||
ax *= lat_scale
|
||
for bx_raw, by in ring.points:
|
||
bx = bx_raw * lat_scale
|
||
dx, dy = bx - ax, by - ay
|
||
if dx or dy:
|
||
t = ((px - ax) * dx + (lat - ay) * dy) / (dx * dx + dy * dy)
|
||
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
|
||
ex = ax + t * dx - px
|
||
ey = ay + t * dy - lat
|
||
else: # выродившееся в точку ребро (дубль вершины в OSM)
|
||
ex, ey = ax - px, ay - lat
|
||
dist = ex * ex + ey * ey
|
||
if dist < best:
|
||
best = dist
|
||
ax, ay = bx, by
|
||
return math.sqrt(best)
|
||
|
||
|
||
# Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator).
|
||
TIER_AVITO_IMV = "avito_imv"
|
||
TIER_YANDEX_VALUATION = "yandex_valuation"
|
||
TIER_CIAN_VALUATION = "cian_valuation"
|
||
TIER_QUARTER_INDEX = "quarter_index"
|
||
TIER_SBER_INDEX = "sber_index"
|
||
|
||
REGIONS: dict[int, Region] = {
|
||
66: Region(
|
||
code=66,
|
||
name="Свердловская область",
|
||
# Значения — байт-в-байт прежние литералы из location_index/geocoder
|
||
# (менять их здесь = менять поведение, это отдельное решение):
|
||
# tight был geocoder.EKB_BBOX_TIGHT, wide — EKB_BBOX_WIDE,
|
||
# region — OBLAST66_BBOX, product_core — bbox location_index.
|
||
bbox_tight=(56.65, 56.95, 60.40, 60.85),
|
||
bbox_wide=(56.6, 57.1, 60.3, 60.9),
|
||
bbox_region=(55.8, 62.2, 56.7, 66.6),
|
||
bbox_product_core=(56.70, 56.95, 60.50, 60.75),
|
||
city_token="екатеринбург",
|
||
cities=frozenset(
|
||
{
|
||
"екатеринбург",
|
||
"нижний тагил",
|
||
"каменск-уральский",
|
||
"первоуральск",
|
||
"верхняя пышма",
|
||
"серов",
|
||
"новоуральск",
|
||
"асбест",
|
||
"полевской",
|
||
"ревда",
|
||
"берёзовский",
|
||
"березовский",
|
||
"среднеуральск",
|
||
"верхняя салда",
|
||
"качканар",
|
||
"краснотурьинск",
|
||
"алапаевск",
|
||
"ирбит",
|
||
"сухой лог",
|
||
"богданович",
|
||
"заречный",
|
||
"невьянск",
|
||
"сысерть",
|
||
"арамиль",
|
||
}
|
||
),
|
||
enrichment_tiers=frozenset(
|
||
{
|
||
TIER_AVITO_IMV,
|
||
TIER_YANDEX_VALUATION,
|
||
TIER_CIAN_VALUATION,
|
||
TIER_QUARTER_INDEX,
|
||
TIER_SBER_INDEX,
|
||
}
|
||
),
|
||
),
|
||
77: Region(
|
||
code=77,
|
||
name="Москва",
|
||
# Ядро — Москва в пределах МКАД (55.57..55.91 / 37.36..37.85) с тем же
|
||
# запасом на фуззи, что у ЕКБ-tight относительно города.
|
||
bbox_tight=(55.55, 55.95, 37.30, 37.90),
|
||
# Продукт-ядро Москвы = tight (МКАД): медианы/POI считаем в ядре,
|
||
# Новая Москва дозреет отдельным решением вместе с данными.
|
||
# Wide — МКАД + ближний пояс (Зеленоград ловится region-bbox'ом).
|
||
bbox_wide=(55.45, 56.05, 37.10, 38.10),
|
||
# Генеральный — Москва в административных границах, включая Новую
|
||
# Москву (юго-запад до ~55.14) и Зеленоград (север до ~56.05).
|
||
bbox_region=(55.10, 56.10, 36.80, 38.10),
|
||
bbox_product_core=(55.55, 55.95, 37.30, 37.90),
|
||
city_token="москва",
|
||
cities=frozenset({"москва", "зеленоград", "троицк", "щербинка", "московский"}),
|
||
# Тиров обогащения у Москвы пока НЕТ ни одного: IMV/квартальный индекс/
|
||
# sber_index покрывают регион 66. Пустое множество здесь — не заглушка,
|
||
# а ФАКТ, который потребители обязаны озвучивать (см. класс-докстринг).
|
||
enrichment_tiers=frozenset(),
|
||
# #3051: Росреестр по Москве отдаёт в city муниципальный округ/поселение
|
||
# ("муниципальный округ Раменки", "поселение Сосенское"), не сам город —
|
||
# import_rosreestr_dkp подставляет каноничное имя вместо city источника.
|
||
canonical_city="Москва",
|
||
),
|
||
50: Region(
|
||
code=50,
|
||
name="Московская область",
|
||
# У области НЕТ города-ядра (в отличие от 66/77) — 20 сопоставимых по
|
||
# объёму городов-спутников. Поэтому tight/wide/product_core здесь не
|
||
# «город + отступ», а ЭМПИРИЧЕСКИЙ пояс данных: разброс координат
|
||
# подмосковного сырья Циан (45 294 строки, отбор по городскому
|
||
# поддомену ссылки ≠ www, замер на дату добавления региона):
|
||
# полный диапазон: lat 54.673..56.762, lon 35.920..39.888
|
||
# перцентили 0.5..99.5: lat 54.834..56.728, lon 36.193..39.545
|
||
# tight = product_core = перцентильный пояс (без выбросов из хвоста
|
||
# распределения — то немногое, что уверенно наблюдали). wide = полный
|
||
# диапазон (min..max) — легитимное приграничье для ingest-guard шире
|
||
# tight, но всё ещё эмпирическое, не административное.
|
||
bbox_tight=(54.834, 56.728, 36.193, 39.545),
|
||
bbox_wide=(54.673, 56.762, 35.920, 39.888),
|
||
# region — административный bbox МО целиком (fallback-accept должен
|
||
# покрывать всю область, а не только пояс, где уже есть данные):
|
||
# lat 54.20..56.96, lon 35.14..40.21.
|
||
bbox_region=(54.20, 56.96, 35.14, 40.21),
|
||
# product_core НЕ равен bbox_region: location_index не должен обещать
|
||
# медианы там, где по факту нет ни одного объявления (deals=0,
|
||
# listings=0 на дату добавления — импорт 411 056 сделок Росреестра из
|
||
# FDW идёт отдельным PR). Равен tight — см. выше.
|
||
bbox_product_core=(54.834, 56.728, 36.193, 39.545),
|
||
# Красногорск: и самый объёмный город по факту сырья (см. cities ниже,
|
||
# по убыванию объёма), и де-факто административный центр региона —
|
||
# Правительство Московской области физически размещается в Красногорске
|
||
# с 2013 г. (Москва как формальный административный центр — экстра-
|
||
# территориальна и уже занята регионом 77). Единственный кандидат,
|
||
# обоснованный ОБОИМИ критериями сразу.
|
||
city_token="красногорск",
|
||
cities=frozenset(
|
||
{
|
||
"красногорск",
|
||
"балашиха",
|
||
"видное",
|
||
"люберцы",
|
||
"звенигород",
|
||
"химки",
|
||
"мытищи",
|
||
"подольск",
|
||
"одинцово",
|
||
"солнечногорск",
|
||
"домодедово",
|
||
"королёв",
|
||
"королев",
|
||
"котельники",
|
||
"дмитров",
|
||
"электросталь",
|
||
"реутов",
|
||
"щёлково",
|
||
"щелково",
|
||
"серпухов",
|
||
"ногинск",
|
||
"железнодорожный",
|
||
}
|
||
),
|
||
# Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный
|
||
# индекс/кадастр/POI не заведены (проверено — frozenset() пуст
|
||
# намеренно, не заглушка). Ряд Сбериндекса по области с 12.09.2026 в
|
||
# карте _SBER_REGION_SERIES эстиматора есть, но тиром он от этого не
|
||
# становится: у Москвы набор тиров тоже пуст, а свой ряд она читает —
|
||
# поправка по времени идёт мимо enrichment_tiers (TIER_SBER_INDEX нигде
|
||
# за пределами этого реестра не спрашивают).
|
||
enrichment_tiers=frozenset(),
|
||
# Источники по области несут настоящий city (Химки, Балашиха — не
|
||
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
||
# нечего и незачем, в отличие от 77.
|
||
canonical_city=None,
|
||
),
|
||
}
|
||
|
||
DEFAULT_REGION_CODE = 66
|
||
|
||
|
||
def _bbox_area(bbox: BBox) -> float:
|
||
"""Грубая «площадь» bbox в кв. градусах (lat_range * lon_range).
|
||
|
||
Не учитывает сжатие долготы на широте (cos(lat)) — не нужно: значение
|
||
используется ТОЛЬКО чтобы сравнить специфичность bbox'ов разного порядка
|
||
(город vs область), не как настоящая площадь в км²."""
|
||
lat_min, lat_max, lon_min, lon_max = bbox
|
||
return (lat_max - lat_min) * (lon_max - lon_min)
|
||
|
||
|
||
# Порядок обхода для region_for_point: от САМОГО специфичного (маленький
|
||
# bbox_region) к самому общему — НЕ sorted(REGIONS) по числовому коду.
|
||
#
|
||
# #3052: сам резолв региона идёт по настоящему полигону (see
|
||
# _point_in_region_polygon), не по прямоугольнику — реальные админ-границы
|
||
# 50 и 77 не пересекаются (Москва вырезана дырой из полигона области), так
|
||
# что для КОНЕЧНОГО результата порядок обхода больше не обязателен: у точки
|
||
# есть ровно один полигон-кандидат, bbox какого региона ни проверяй первым.
|
||
# Порядок остаётся не как костыль корректности, а как ДЕШЁВЫЙ предварительный
|
||
# отсев: bbox_region(50) (Московская область целиком, lat 54.20..56.96/
|
||
# lon 35.14..40.21) геометрически СОДЕРЖИТ bbox_region(77) (Москва,
|
||
# 55.10..56.10/36.80..38.10) как прямоугольники, а 50 < 77 по числовому коду.
|
||
# Если проверять регионы в порядке `sorted(REGIONS)`, для точки в центре
|
||
# Москвы bbox-отсев региона 50 пройдёт ПЕРВЫМ и завернёт в дорогой
|
||
# point-in-polygon по 1 227 точкам области раньше, чем дело дойдёт до
|
||
# компактного полигона Москвы (386 точек) — лишняя работа на каждый вызов,
|
||
# не баг результата (полигон 50 всё равно отвергнет точку — она в дыре), но
|
||
# systematic overhead на самом частом случае (Москва/область — соседи).
|
||
#
|
||
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает и это без
|
||
# ручного списка: чем компактнее регион, тем раньше его bbox-отсев и (при
|
||
# совпадении) полигон проверяют, поэтому вложенный по bbox регион (77 внутри
|
||
# 50) почти всегда получает свою точку дешевле, а новый регион сам встанет в
|
||
# верную позицию по своей площади.
|
||
_POINT_LOOKUP_ORDER: tuple[int, ...] = tuple(
|
||
sorted(REGIONS, key=lambda code: (_bbox_area(REGIONS[code].bbox_region), code))
|
||
)
|
||
|
||
|
||
def region_for_point(lat: float, lon: float) -> Region | None:
|
||
"""Регион покрытия, которому принадлежит точка, или None (вне охвата).
|
||
|
||
Два уровня отсева на каждого кандидата (порядок — `_POINT_LOOKUP_ORDER`,
|
||
компактный bbox_region раньше обширного, см. комментарий там):
|
||
1. `is_within_bbox` по `bbox_region` — дешёвый прямоугольный предфильтр,
|
||
НЕ финальный ответ (прямоугольники Москвы и области пересекаются).
|
||
2. Полигон (`_point_in_region_polygon`) — настоящая граница, решает
|
||
результат. Если bbox прошёл, а полигон точку не принял (точка в
|
||
прямоугольнике области, но не в её реальных границах — то есть,
|
||
например, внутри вырезанной дыры Москвы), идём к следующему
|
||
кандидату, а не возвращаем None сразу.
|
||
|
||
None — ТОЛЬКО когда точка вне bbox ВСЕХ регионов (Пермь, Таллин). Если
|
||
хоть один прямоугольник её принял, а все полигоны отвергли, ответом
|
||
становится кандидат, к ГРАНИЦЕ которого точка ближе
|
||
(`_distance_to_boundary`). Иначе такая точка досталась бы вызывающему коду
|
||
как None, а тот (`trade_in.py`: `target_region.code if target_region else
|
||
DEFAULT_REGION_CODE`) молча подставил бы регион по умолчанию — адрес в
|
||
агломерации посчитался бы по коэффициентам Екатеринбурга. Щели между
|
||
полигонами соседних регионов неизбежны (у Москвы и области это независимо
|
||
оцифрованные в OSM линии, совпадающие с точностью до метров, а не одна
|
||
общая), и закрывать их возвратом к прямоугольнику нельзя — прямоугольники
|
||
перекрываются и врут ровно там, ради чего #3052 и делался.
|
||
|
||
Региона без загруженного полигона в реестре нет (все REGIONS покрыты
|
||
boundaries.geojson.json) — на практике до fallback-ветки дело не доходит,
|
||
но она есть, чтобы новый регион без границы не «пропадал» молча, а
|
||
работал по старому bbox-поведению до того, как для него добавят полигон.
|
||
"""
|
||
near_miss: list[int] = []
|
||
for code in _POINT_LOOKUP_ORDER:
|
||
region = REGIONS[code]
|
||
if not is_within_bbox(lat, lon, region.bbox_region):
|
||
continue
|
||
if code not in _REGION_RING_INDEX or _point_in_region_polygon(code, lat, lon):
|
||
return region
|
||
near_miss.append(code)
|
||
if not near_miss:
|
||
return None
|
||
if len(near_miss) == 1:
|
||
return REGIONS[near_miss[0]]
|
||
nearest = min(near_miss, key=lambda code: _distance_to_boundary(code, lat, lon))
|
||
return REGIONS[nearest]
|
||
|
||
|
||
def region_by_city(city: str | None) -> Region | None:
|
||
"""Регион, в чьём списке городов есть `city` (нормализованный нижний
|
||
регистр, е/ё не различаются). None — город не узнан ни одним регионом.
|
||
|
||
В отличие от `region_for_point`, здесь нет геометрической вложенности —
|
||
сравнение точное (токен строки), не bbox-containment, поэтому порядок по
|
||
коду региона не создаёт баг ordering'а САМ ПО СЕБЕ. Он МОГ бы сломаться,
|
||
если бы одно имя города оказалось в `cities` двух регионов (тогда побеждал
|
||
бы меньший код) — список городов 50 сверен вручную с `cities` регионов 66
|
||
и 77, пересечений нет (закреплено test_no_city_name_duplicated_across_regions
|
||
в tests/test_3051_region_registry_moscow_oblast.py).
|
||
"""
|
||
if not city:
|
||
return None
|
||
token = " ".join(city.lower().replace("ё", "е").split())
|
||
for code in sorted(REGIONS):
|
||
r = REGIONS[code]
|
||
if token in r.cities or any(token == c.replace("ё", "е") for c in r.cities):
|
||
return r
|
||
return None
|
||
|
||
|
||
def unsupported_tier_reason(region: Region, tier: str) -> str | None:
|
||
"""None — тир доступен региону; иначе готовая строка для лога/маркера.
|
||
|
||
Смысл — «регион без тира деградирует явно»: вызывающий код обязан
|
||
залогировать/пометить причину, а не молча посчитать без источника.
|
||
"""
|
||
if tier in region.enrichment_tiers:
|
||
return None
|
||
return (
|
||
f"тир '{tier}' недоступен региону {region.code} ({region.name}) — "
|
||
f"оценка идёт без него; доступные тиры: "
|
||
f"{sorted(region.enrichment_tiers) or 'нет ни одного'}"
|
||
)
|