Геокодирование сделок области: ключ (регион, НП, улица) вместо голой улицы (#3532)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
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 3m46s
Deploy Trade-In / build-backend (push) Successful in 1m20s
Deploy Trade-In / deploy (push) Successful in 2m5s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m43s

Центроид строился по названию улицы без города — в области это схлопывало одноимённые
улицы разных городов. Ключ стал (region_code, населённый пункт, улица); НП берётся из
типа сегмента адреса, из реестра городов региона или из deals.city; при пустом НП ключ
у области отбрасывается, а не склеивается с чужим городом.

Фильтры по региону добавлены в SELECT кандидатов, в запрос домов и в UPDATE. Новый
--max-spread-km (5 км) отбрасывает бакет с разбросанными домами.

geocoder.py: поддержка региона 50 (маркер «московская» — намеренно не «москва»,
DaData-имя «Московская», новое поле Region.has_city_core=False, чтобы к адресу области
не приклеивался суффикс главного города).
This commit is contained in:
lekss361 2026-09-15 17:28:09 +00:00
parent 39996ef3fd
commit ce60fc214d
4 changed files with 545 additions and 116 deletions

View file

@ -37,7 +37,11 @@ _REGION_66 = _ALL_REGIONS[66]
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы # в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
# без записи здесь получают `marker=None` → cross-check пропускается # без записи здесь получают `marker=None` → cross-check пропускается
# (fallback на bbox-only, прежнее поведение). # (fallback на bbox-only, прежнее поведение).
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"} # 50 → "московская", а НЕ "москва": Nominatim отдаёт `state="Московская
# область"` для области и `state="Москва"` для города. Маркер "московская" не
# матчит "Москва" (подстроки нет) — результат внутри города Москвы, который
# щедрый `bbox_region` области накрывает целиком, будет честно отвергнут.
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва", 50: "московская"}
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -962,7 +966,12 @@ _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов # «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится, # хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim. # поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"} _DADATA_REGION_NAMES: dict[int, str] = {
66: SVERDLOVSK_OBLAST_REGION,
77: "Москва",
# Без типа — DaData хранит `region="Московская"`, `region_type="обл"`.
50: "Московская",
}
def _dadata_region_name(region_code: int) -> str: def _dadata_region_name(region_code: int) -> str:
@ -1106,7 +1115,14 @@ async def _nominatim_query_city_aware(
) )
if city_specified: if city_specified:
return await _nominatim_query_multi(client, query, limit, region_code=region_code) return await _nominatim_query_multi(client, query, limit, region_code=region_code)
# Город неизвестен — dual-query с суффиксом главного города региона # Город неизвестен. У региона БЕЗ города-ядра (`has_city_core=False`,
# реестр регионов; на сегодня это 50 — Московская область) суффикс главного
# города подставлять НЕЛЬЗЯ: «Луговая» есть и в Красногорске, и в Сабурово,
# и суффикс уверенно притянет чужой город — ровно ловушка #2576, только
# уровнем выше. Такому региону остаётся честный bare-запрос по viewbox.
if not _ALL_REGIONS[region_code].has_city_core:
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
# Дальше — регион с ядром: dual-query с суффиксом главного города
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`). # (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
default_city = _region_default_city(region_code) default_city = _region_default_city(region_code)
city_data = await _nominatim_query_multi( city_data = await _nominatim_query_multi(

View file

@ -71,6 +71,15 @@ class Region:
Регион без тира должен деградировать ЯВНО (потребитель Регион без тира должен деградировать ЯВНО (потребитель
спрашивает unsupported_tier_reason и логирует/маркирует), спрашивает unsupported_tier_reason и логирует/маркирует),
а не молча считать дальше без источника. а не молча считать дальше без источника.
has_city_core есть ли у региона ОДИН город-ядро, имя которого допустимо
молча подставлять в запрос геокодера, когда город не назван
(dual-query `_nominatim_query_city_aware`, ветка «город
неизвестен»). True у 66/77 (Екатеринбург / Москва там это
majority-трафик). False у 50: у области 20 сопоставимых
городов и ~970 населённых пунктов в сырье, подстановка
«Красногорск» к «Сабурово, Луговая» ровно та же ловушка
одноимённых улиц, от которой отказались в #2576.
canonical_city #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city` canonical_city #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
строк, приходящих из источника без надёжного city-поля строк, приходящих из источника без надёжного city-поля
(Росреестр по Москве отдаёт муниципальный округ/поселение (Росреестр по Москве отдаёт муниципальный округ/поселение
@ -92,6 +101,7 @@ class Region:
cities: frozenset[str] cities: frozenset[str]
enrichment_tiers: frozenset[str] enrichment_tiers: frozenset[str]
canonical_city: str | None = None canonical_city: str | None = None
has_city_core: bool = True
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool: def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
@ -460,6 +470,10 @@ REGIONS: dict[int, Region] = {
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать # муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
# нечего и незачем, в отличие от 77. # нечего и незачем, в отличие от 77.
canonical_city=None, canonical_city=None,
# Города-ядра нет (см. bbox_tight выше) — подставлять «Красногорск» в
# запрос геокодера, когда город не назван, НЕЛЬЗЯ: «Луговая» есть и в
# Красногорске, и в Сабурово, и ещё в десятке НП области.
has_city_core=False,
), ),
} }

View file

@ -52,6 +52,7 @@ from __future__ import annotations
import argparse import argparse
import logging import logging
import math
import re import re
import unicodedata import unicodedata
from collections import Counter from collections import Counter
@ -66,11 +67,13 @@ from sqlalchemy.orm import Session
# matches the pattern from backfill_houses_dadata.py) and as a stand-alone file. # matches the pattern from backfill_houses_dadata.py) and as a stand-alone file.
try: try:
from app.core.db import SessionLocal # type: ignore[import-not-found] from app.core.db import SessionLocal # type: ignore[import-not-found]
from app.services.regions import REGIONS # type: ignore[import-not-found]
except ImportError: # pragma: no cover — fallback for adhoc invocation except ImportError: # pragma: no cover — fallback for adhoc invocation
import sys import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.services.regions import REGIONS
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -91,6 +94,47 @@ _LOG_EVERY = 1000
# houses (centroid) and deals (lookup) side so garbage never anchors a match. # houses (centroid) and deals (lookup) side so garbage never anchors a match.
_MIN_KEY_LEN = 3 _MIN_KEY_LEN = 3
# Default region. 66 (Свердловская обл.) — единственный регион, на котором
# скрипт исторически работал; остаётся умолчанием, чтобы существующие вызовы
# без флага вели себя ровно как раньше.
_DEFAULT_REGION_CODE = 66
# Sanity-порог разброса домов ВНУТРИ одного ключа (км). Ключ — (регион, НП,
# улица), так что легитимный разброс — это длина одной улицы: даже проспект в
# большом городе редко длиннее 10-15 км, а типичная улица — 1-3 км. Разброс
# больше порога означает, что в ведро слиплись ОДНОИМЁННЫЕ улицы разных
# населённых пунктов (или один НП распознан двумя написаниями) — среднее таких
# точек даёт правдоподобную координату ПОСЕРЕДИНЕ между городами, и ошибка
# тихая. Такое ведро выбрасывается целиком: дыра в покрытии честнее, чем
# сделка, посаженная в поле между Клином и Серпуховом.
_DEFAULT_MAX_SPREAD_KM = 5.0
# Классификация comma-сегмента адреса (см. `_split_place_street`).
# Тип населённого пункта в начале сегмента: «г Химки», «пос. Голубое»,
# «д Сабурово», «рп Оболенск», «снт Заря». За типом ОБЯЗАН идти буквенный
# токен — иначе «д 5» (номер дома) распознался бы как деревня.
_LOCALITY_TYPE_RE = re.compile(
r"^(?:"
r"г|гор|город|пгт|рп|дп|снт|днп|тер"
r"|п|пос|поселок|посёлок|п/ст"
r"|д|дер|деревня|с|село|сл|слобода|ст|станция|х|хутор|аул"
r")\.?\s+(?=[а-яё])",
flags=re.UNICODE,
)
# Сегмент — номер дома/квартиры/корпуса, а не имя: «125», «д 5», «кв 12»,
# «корп 3», «литера А». Такие сегменты не могут быть ни НП, ни улицей.
_NUMERIC_SEGMENT_RE = re.compile(
r"^(?:"
r"д|дом|к|кор|корп|корпус|стр|строение|кв|квартира|литера?|уч|участок"
r"|пом|помещение|оф|офис|бокс|гараж"
# Хвост `$` обязателен: сегмент считается номером, только если КРОМЕ
# числа в нём ничего нет. Иначе «8 марта» (числовое имя улицы) было бы
# принято за номер дома и улица потерялась бы целиком.
r")?\.?\s*\d+[а-яё]?(?:\s*[/-]\s*\d+[а-яё]?)?$",
flags=re.UNICODE,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Street-key normalization — the crux of match rate # Street-key normalization — the crux of match rate
@ -109,9 +153,11 @@ _ADMIN_SEGMENT_RES = [
r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$", r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$",
flags=re.UNICODE, flags=re.UNICODE,
), ),
re.compile(r"^(?:г|гор|город)\.?\s+[а-яё][а-яё-]+$", flags=re.UNICODE),
re.compile(r"^екатеринбург$", flags=re.UNICODE),
] ]
# NB: сегменты-НАСЕЛЁННЫЕ ПУНКТЫ («г. Екатеринбург», «екатеринбург») здесь
# больше НЕ выбрасываются — они несут вторую половину ключа и разбираются
# `_locality_name` по реестру регионов. Хардкод `^екатеринбург$` уехал туда же:
# список городов региона живёт в `REGIONS[region_code].cities`, а не здесь.
# Street-type token at the START of the street segment. Stripped because # Street-type token at the START of the street segment. Stripped because
# deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the # deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the
@ -187,63 +233,176 @@ def _strip_house_tail(segment: str) -> str:
return " ".join(keep).strip() return " ".join(keep).strip()
def _street_key(address: str | None) -> str: def _is_admin_segment(seg: str) -> bool:
"""Reduce any address to a bare-street-name key for the centroid join. """Сегмент — страна/регион/район (выбрасывается целиком)."""
return any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES)
Both sides must produce the SAME key or the join under-matches:
'Екатеринбург, ул. Малышева, 125' 'малышева'
'г Екатеринбург, улица Малышева' 'малышева'
'Екатеринбург, Малышева' 'малышева'
'Свердловская обл., Екатеринбург, ул. Большакова, 17' 'большакова'
'улица Яскина, 12 · р-н Октябрьский' 'яскина'
'г. Екатеринбург, проспект Ленина, 50' 'ленина'
'Екатеринбург, ул. 8 Марта, 100' '8 марта'
Steps: def _is_numeric_segment(seg: str) -> bool:
1. NFC normalize, lowercase, ёе (deals/houses differ on ё usage). """Сегмент — номер дома/квартиры/корпуса («125», «д 5», «кв 12»)."""
2. Drop a trailing district marker (' · р-н ...', ' | ...'). return bool(_NUMERIC_SEGMENT_RE.match(seg))
3. Split on commas; drop leading segments that are admin chunks
(Россия / region / district / город / Екатеринбург). The first
non-admin segment is the street segment.
4. Drop apartment/corpus/строение noise inside that segment.
5. Strip the street-type token at the start (ул/улица/проспект/...).
6. Strip the trailing house number, preserving numeric street names.
7. Collapse whitespace.
Returns '' when nothing usable remains (caller filters by _MIN_KEY_LEN).
""" def _normalize_place(value: str | None) -> str:
if not address: """Нормализованное имя НП: lower, ё→е, без типа («г. Химки» → «химки»)."""
if not value:
return "" return ""
v = unicodedata.normalize("NFC", value).lower().replace("ё", "е")
v = _WS_RE.sub(" ", v).strip()
v = _LOCALITY_TYPE_RE.sub("", v)
return _WS_RE.sub(" ", v).strip(" .,")
def _locality_name(seg: str, cities: frozenset[str]) -> str | None:
"""Имя НП, если сегмент — населённый пункт, иначе None.
Два признака: явный тип («г Химки», «д Сабурово», «снт Заря») имя берём
как есть; ИЛИ голое имя, которое реестр региона знает как город
(`REGIONS[region_code].cities`). Голое незнакомое имя здесь НЕ считается
НП иначе «Малышева, 125» прочиталось бы как НП «Малышева»; такой случай
ловит позиционный fallback в `_split_place_street`.
"""
m = _LOCALITY_TYPE_RE.match(seg)
if m:
name = seg[m.end() :]
else:
if _STREET_TYPE_RE.match(seg) or _is_numeric_segment(seg):
return None
if _WS_RE.sub(" ", seg).strip() not in cities:
return None
name = seg
name = _WS_RE.sub(" ", name).strip(" .,")
return name or None
def _split_place_street(
address: str | None, region_code: int = _DEFAULT_REGION_CODE
) -> tuple[str, str]:
"""Разложить адрес на (населённый пункт, улица) — обе половины ключа.
ПОЧЕМУ НП обязан быть в ключе. Раньше функция звалась `_street_key` и
возвращала ГОЛОЕ имя улицы, выбрасывая НП. Для одного города (скрипт жил
ЕКБ-only, с хардкодом `^екатеринбург$`) это безвредно. Для Московской
области тихая катастрофа: «Ленина» / «Центральная» / «Советская» есть
почти в каждом из ~970 НП области, дома со всех таких улиц слиплись бы в
одно ведро, а среднее их координат правдоподобная точка ПОСЕРЕДИНЕ между
городами. Сделка уезжает на десятки километров, и ни одна проверка этого не
замечает: координата валидная, внутри области, рядом есть дома.
Разбор:
1. NFC, lower, ёе, снять хвост-район (' · р-н ...', ' | ...').
2. Порезать на comma-сегменты, выбросить страну/регион/район.
3. Первый сегмент-НП (`_locality_name`) place, улицу ищем ПОСЛЕ него.
4. Улица первый не-числовой сегмент из остатка.
5. Fallback «НП без типа и вне реестра» («Сабурово, Луговая» ровно
формат deals.address по области): если НП не нашёлся, а не-числовых
сегментов >= 2 и первый не начинается с типа улицы первый считается
НП, второй улицей.
6. Улицу чистим как раньше: квартира/корпус, тип улицы, номер дома.
Неизвестный `region_code` -> KeyError реестра, не молчаливый ''.
"""
cities = REGIONS[region_code].cities
if not address:
return "", ""
s = unicodedata.normalize("NFC", address).lower().replace("ё", "е") s = unicodedata.normalize("NFC", address).lower().replace("ё", "е")
s = _WS_RE.sub(" ", s).strip() s = _WS_RE.sub(" ", s).strip()
if not s: if not s:
return "" return "", ""
# 2. Drop trailing district marker (' · Октябрьский', ' | ...').
s = _DISTRICT_SUFFIX_RE.sub("", s) s = _DISTRICT_SUFFIX_RE.sub("", s)
# 3. Split on commas, drop leading admin segments. Each segment is matched
# whole, so a non-admin street segment is never partially eaten.
segments = [seg.strip() for seg in s.split(",") if seg.strip()] segments = [seg.strip() for seg in s.split(",") if seg.strip()]
street_seg = "" non_admin = [seg for seg in segments if not _is_admin_segment(seg)]
for seg in segments: if not non_admin:
if any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES): return "", ""
continue
street_seg = seg place = ""
rest = non_admin
for i, seg in enumerate(non_admin):
name = _locality_name(seg, cities)
if name:
place = name
rest = non_admin[i + 1 :]
break break
street_seg = next((seg for seg in rest if not _is_numeric_segment(seg)), "")
if not place:
usable = [seg for seg in non_admin if not _is_numeric_segment(seg)]
if len(usable) >= 2 and not _STREET_TYPE_RE.match(usable[0]):
place = _WS_RE.sub(" ", usable[0]).strip(" .,")
street_seg = usable[1]
if not street_seg: if not street_seg:
return "" return place, ""
# 4. Drop apartment/corpus/строение noise inside the street segment.
street_seg = _APT_SUFFIX_RE.sub("", street_seg).strip() street_seg = _APT_SUFFIX_RE.sub("", street_seg).strip()
# 5. Strip the street-type token if present.
street_seg = _STREET_TYPE_RE.sub("", street_seg).strip() street_seg = _STREET_TYPE_RE.sub("", street_seg).strip()
# 6. Strip trailing house number (keep '8 марта' style numeric streets).
street_seg = _strip_house_tail(street_seg) street_seg = _strip_house_tail(street_seg)
return place, _WS_RE.sub(" ", street_seg).strip()
return _WS_RE.sub(" ", street_seg).strip()
def _street_key(address: str | None, region_code: int = _DEFAULT_REGION_CODE) -> str:
"""Только уличная половина ключа. Как ключ join'а САМА ПО СЕБЕ не годится
(одноимённые улицы разных НП) см. `_address_key`; оставлена для отчётов
и тестов нормализации улицы.
Примеры (region 66):
'Екатеринбург, ул. Малышева, 125' -> 'малышева'
'г Екатеринбург, улица Малышева' -> 'малышева'
'Свердловская обл., Екатеринбург, ул. Большакова, 17' -> 'большакова'
'улица Яскина, 12 · р-н Октябрьский' -> 'яскина'
'Екатеринбург, ул. 8 Марта, 100' -> '8 марта'
"""
return _split_place_street(address, region_code)[1]
def _address_key(
address: str | None,
region_code: int = _DEFAULT_REGION_CODE,
city: str | None = None,
) -> tuple[int, str, str] | None:
"""Составной ключ join'а: (регион, населённый пункт, улица). None — мусор.
`city` (у deals колонка заполнена на 100% и надёжнее текста адреса)
переопределяет НП, разобранный из адреса.
Пустой НП: у региона С городом-ядром (66) подставляется `city_token`
ровно историческое поведение «всё, что без города, это Екатеринбург». У
региона БЕЗ ядра (50) подставлять нечего, и ключ отбрасывается: сделка без
распознанного НП лучше останется без координат, чем сядет в случайный
город области.
"""
place, street = _split_place_street(address, region_code)
from_city = _normalize_place(city)
if from_city:
place = from_city
if len(street) < _MIN_KEY_LEN:
return None
if not place:
region = REGIONS[region_code]
if not region.has_city_core:
return None
place = region.city_token
if len(place) < _MIN_KEY_LEN:
return None
return (region_code, place, street)
def _spread_km(points: list[tuple[float, float]], lat_c: float, lon_c: float) -> float:
"""Максимальное удаление точки ведра от его центроида, км (equirectangular).
Проекция плоская на масштабе одного НП (единицы километров) ошибка
сотые доли процента, а формула дешевле haversine на каждом из десятков
тысяч домов.
"""
worst = 0.0
cos_lat = math.cos(math.radians(lat_c))
for lat, lon in points:
dy = (lat - lat_c) * 111.32
dx = (lon - lon_c) * 111.32 * cos_lat
worst = max(worst, math.hypot(dx, dy))
return worst
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -253,11 +412,13 @@ def _street_key(address: str | None) -> str:
@dataclass @dataclass
class Centroid: class Centroid:
"""One street's centroid, averaged over all geocoded houses on it.""" """Центроид одного ключа (регион, НП, улица) по домам этого ключа."""
lat: float lat: float
lon: float lon: float
house_count: int house_count: int
# Максимальное удаление дома ведра от центроида, км (sanity-чек склейки).
spread_km: float = 0.0
@dataclass @dataclass
@ -266,6 +427,9 @@ class DealRow:
id: int id: int
address: str | None address: str | None
# deals.city — росреестровая колонка; по области заполнена у 100% строк без
# geom и надёжнее текста адреса, поэтому переопределяет НП из адреса.
city: str | None = None
@dataclass @dataclass
@ -276,6 +440,8 @@ class Stats:
geocoded: int = 0 geocoded: int = 0
no_street_match: int = 0 no_street_match: int = 0
failed: int = 0 failed: int = 0
# Вёдер выброшено sanity-чеком разброса (склейка нескольких НП).
dropped_spread: int = 0
# street_key → count of deals that had no house centroid (dry-run report). # street_key → count of deals that had no house centroid (dry-run report).
unmatched_streets: Counter[str] = field(default_factory=Counter) unmatched_streets: Counter[str] = field(default_factory=Counter)
@ -285,14 +451,28 @@ class Stats:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _build_centroid_map(db: Session) -> dict[str, Centroid]: def _build_centroid_map(
"""Per-street centroid from houses WHERE geom IS NOT NULL. db: Session,
region_code: int = _DEFAULT_REGION_CODE,
max_spread_km: float = _DEFAULT_MAX_SPREAD_KM,
) -> dict[tuple[int, str, str], Centroid]:
"""Центроиды по ключу (регион, НП, улица) из houses с координатами.
We read raw (address, lat, lon) and aggregate in Python so the street-key Фильтр региона обязателен: без него карта строится по домам ВСЕХ регионов,
derivation is the SAME code path as the deals side pushing it into SQL и прогон по одному региону тихо тащит чужие координаты.
would require duplicating the regex logic in plpgsql and risk drift.
8,600 rows is trivial to hold in memory. `houses.region_code` добавлен поздней миграцией (272_houses_region_code.sql)
и у части строк NULL. Отбрасывать их нельзя (это ударило бы по покрытию 66),
поэтому строка без региона принимается по географии если её координаты
внутри `bbox_region` реестра. Это именно гео-проверка, а не догадка о
происхождении строки.
Агрегация в Python, а не в SQL: вывод ключа должен быть ОДНИМ И ТЕМ ЖЕ
кодом на обеих сторонах join'а, дублировать регексы в plpgsql — верный
дрейф.
""" """
region = REGIONS[region_code]
lat_min, lat_max, lon_min, lon_max = region.bbox_region
rows = ( rows = (
db.execute( db.execute(
text( text(
@ -303,31 +483,80 @@ def _build_centroid_map(db: Session) -> dict[str, Centroid]:
" AND lon IS NOT NULL " " AND lon IS NOT NULL "
" AND address IS NOT NULL " " AND address IS NOT NULL "
" AND length(trim(address)) > 0 " " AND length(trim(address)) > 0 "
) " AND ( region_code = CAST(:rc AS smallint) "
" OR ( region_code IS NULL "
" AND lat BETWEEN CAST(:lat_min AS double precision) "
" AND CAST(:lat_max AS double precision) "
" AND lon BETWEEN CAST(:lon_min AS double precision) "
" AND CAST(:lon_max AS double precision) ) )"
),
{
"rc": region_code,
"lat_min": lat_min,
"lat_max": lat_max,
"lon_min": lon_min,
"lon_max": lon_max,
},
) )
.mappings() .mappings()
.all() .all()
) )
# street_key → running [lat_sum, lon_sum, n] acc: dict[tuple[int, str, str], list[tuple[float, float]]] = {}
acc: dict[str, list[float]] = {}
for r in rows: for r in rows:
key = _street_key(r["address"]) key = _address_key(r["address"], region_code)
if len(key) < _MIN_KEY_LEN: if key is None:
continue continue
bucket = acc.setdefault(key, [0.0, 0.0, 0.0]) acc.setdefault(key, []).append((float(r["lat"]), float(r["lon"])))
bucket[0] += float(r["lat"])
bucket[1] += float(r["lon"])
bucket[2] += 1.0
return { out: dict[tuple[int, str, str], Centroid] = {}
key: Centroid(lat=lat_sum / n, lon=lon_sum / n, house_count=int(n)) dropped = 0
for key, (lat_sum, lon_sum, n) in acc.items() for key, points in acc.items():
} n = len(points)
lat_c = sum(lat for lat, _ in points) / n
lon_c = sum(lon for _, lon in points) / n
spread = _spread_km(points, lat_c, lon_c)
if spread > max_spread_km:
# Одна «улица» шириной в десятки километров — это не улица, а
# слипшиеся одноимённые улицы разных НП (или один НП, записанный
# двумя способами). Среднее таких точек — координата в поле между
# городами; отдавать её сделке нельзя, ведро выбрасывается.
dropped += 1
logger.warning(
"centroid bucket dropped: key=%s houses=%d spread=%.1f km > %.1f km",
key,
n,
spread,
max_spread_km,
)
continue
out[key] = Centroid(lat=lat_c, lon=lon_c, house_count=n, spread_km=spread)
if dropped:
logger.warning(
"sanity: %d/%d вёдер выброшено по разбросу > %.1f km",
dropped,
len(acc),
max_spread_km,
)
return out
def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]: def _region_predicate(column: str = "region_code") -> str:
"""deals needing coords (lat IS NULL) — resume-safe candidate set. """SQL-предикат «строка принадлежит региону :rc» для deals.
`deals.region_code` проставлен миграцией 177_deals_city_region.sql; строки,
существовавшие ДО неё, по построению екатеринбургские (в скрипт импорта
был зашит префикс 'Екатеринбург, '), поэтому NULL засчитывается региону 66
и только ему. Для любого другого региона NULL не кандидат.
"""
return f"( {column} = CAST(:rc AS int) OR ( {column} IS NULL AND CAST(:rc AS int) = 66 ) )"
def _select_deals_without_coords(
db: Session, limit: int, region_code: int = _DEFAULT_REGION_CODE
) -> list[DealRow]:
"""deals needing coords (lat IS NULL) в пределах ОДНОГО региона.
Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful
UPDATE sets lat NOT NULL, dropping the row out on the next run. UPDATE sets lat NOT NULL, dropping the row out on the next run.
@ -335,20 +564,21 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
rows = ( rows = (
db.execute( db.execute(
text( text(
"SELECT id, address " "SELECT id, address, city "
"FROM deals " "FROM deals "
"WHERE lat IS NULL " "WHERE lat IS NULL "
" AND address IS NOT NULL " " AND address IS NOT NULL "
" AND length(trim(address)) > 0 " " AND length(trim(address)) > 0 "
" AND " + _region_predicate() + " "
"ORDER BY id " "ORDER BY id "
"LIMIT CAST(:lim AS int)" "LIMIT CAST(:lim AS int)"
), ),
{"lim": limit}, {"lim": limit, "rc": region_code},
) )
.mappings() .mappings()
.all() .all()
) )
return [DealRow(id=r["id"], address=r["address"]) for r in rows] return [DealRow(id=r["id"], address=r["address"], city=r.get("city")) for r in rows]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -356,7 +586,14 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) -> None: def _update_deal_coords(
db: Session,
*,
deal_id: int,
lat: float,
lon: float,
region_code: int = _DEFAULT_REGION_CODE,
) -> None:
"""UPDATE deals SET lat/lon + geocode_tried_at=NOW(); geom auto-fills. """UPDATE deals SET lat/lon + geocode_tried_at=NOW(); geom auto-fills.
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
@ -371,8 +608,9 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) ->
" lon = CAST(:lon AS double precision), " " lon = CAST(:lon AS double precision), "
" geocode_tried_at = NOW() " " geocode_tried_at = NOW() "
" WHERE id = CAST(:id AS bigint) " " WHERE id = CAST(:id AS bigint) "
" AND " + _region_predicate()
), ),
{"id": deal_id, "lat": lat, "lon": lon}, {"id": deal_id, "lat": lat, "lon": lon, "rc": region_code},
) )
@ -384,10 +622,11 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) ->
def _run_backfill( def _run_backfill(
db: Session, db: Session,
deals: list[DealRow], deals: list[DealRow],
centroids: dict[str, Centroid], centroids: dict[tuple[int, str, str], Centroid],
*, *,
batch: str, batch: str,
dry_run: bool, dry_run: bool,
region_code: int = _DEFAULT_REGION_CODE,
) -> Stats: ) -> Stats:
"""For each deal, look up its street centroid and UPDATE lat/lon. """For each deal, look up its street centroid and UPDATE lat/lon.
@ -399,14 +638,14 @@ def _run_backfill(
stats = Stats() stats = Stats()
for i, deal in enumerate(deals, start=1): for i, deal in enumerate(deals, start=1):
key = _street_key(deal.address) key = _address_key(deal.address, region_code, city=deal.city)
centroid = centroids.get(key) if len(key) >= _MIN_KEY_LEN else None centroid = centroids.get(key) if key is not None else None
if centroid is None: if centroid is None:
stats.no_street_match += 1 stats.no_street_match += 1
# Track the raw key (or a sentinel) so the dry-run report can show # Track the raw key (or a sentinel) so the dry-run report can show
# which streets we're missing. Empty key → '<no-street-parsed>'. # which streets we're missing. Empty key → '<no-street-parsed>'.
stats.unmatched_streets[key or "<no-street-parsed>"] += 1 stats.unmatched_streets["/".join(key[1:]) if key else "<no-street-parsed>"] += 1
stats.processed += 1 stats.processed += 1
if dry_run and i % _LOG_EVERY == 0: if dry_run and i % _LOG_EVERY == 0:
logger.info( logger.info(
@ -423,7 +662,13 @@ def _run_backfill(
else: else:
try: try:
with db.begin_nested(): with db.begin_nested():
_update_deal_coords(db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon) _update_deal_coords(
db,
deal_id=deal.id,
lat=centroid.lat,
lon=centroid.lon,
region_code=region_code,
)
# Per-row commit so resume picks up exactly where we crashed. # Per-row commit so resume picks up exactly where we crashed.
db.commit() db.commit()
stats.geocoded += 1 stats.geocoded += 1
@ -454,7 +699,7 @@ def _run_backfill(
def _report_dry_run( def _report_dry_run(
stats: Stats, stats: Stats,
centroids: dict[str, Centroid], centroids: dict[tuple[int, str, str], Centroid],
*, *,
total_deals_null: int, total_deals_null: int,
candidates: int, candidates: int,
@ -475,7 +720,7 @@ def _report_dry_run(
logger.info("" * 60) logger.info("" * 60)
logger.info("DRY-RUN SUMMARY (no DB writes)") logger.info("DRY-RUN SUMMARY (no DB writes)")
logger.info("distinct streets with a house centroid: %d", distinct_streets) logger.info("distinct (region, НП, улица) keys with a house centroid: %d", distinct_streets)
logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned) logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned)
logger.info("deals matched to a centroid: %d", matched) logger.info("deals matched to a centroid: %d", matched)
logger.info("deals with no street match: %d", stats.no_street_match) logger.info("deals with no street match: %d", stats.no_street_match)
@ -486,7 +731,7 @@ def _report_dry_run(
projected, projected,
match_rate * 100.0, match_rate * 100.0,
) )
logger.info("top-10 unmatched deal streets (by row count):") logger.info("top-10 unmatched deal keys НП/улица (by row count):")
for street, cnt in stats.unmatched_streets.most_common(10): for street, cnt in stats.unmatched_streets.most_common(10):
logger.info(" %6d %s", cnt, street) logger.info(" %6d %s", cnt, street)
logger.info("" * 60) logger.info("" * 60)
@ -497,13 +742,15 @@ def _report_dry_run(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _count_deals_null(db: Session) -> int: def _count_deals_null(db: Session, region_code: int = _DEFAULT_REGION_CODE) -> int:
"""Full count of deals WHERE lat IS NULL — denominator for projection.""" """Full count of deals WHERE lat IS NULL в этом регионе — знаменатель."""
row = db.execute( row = db.execute(
text( text(
"SELECT count(*) AS n FROM deals " "SELECT count(*) AS n FROM deals "
"WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0 " "WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0 "
) " AND " + _region_predicate()
),
{"rc": region_code},
).first() ).first()
return int(row[0]) if row else 0 return int(row[0]) if row else 0
@ -527,6 +774,25 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
default=f"deals_geo_{date.today().isoformat()}", default=f"deals_geo_{date.today().isoformat()}",
help="Log batch label (does not affect DB filters — logs only).", help="Log batch label (does not affect DB filters — logs only).",
) )
p.add_argument(
"--region-code",
type=int,
default=_DEFAULT_REGION_CODE,
choices=sorted(REGIONS),
help=(
"Регион прогона (default %(default)s). Отбирает и обновляет ТОЛЬКО "
"строки этого региона; входит в ключ join'а."
),
)
p.add_argument(
"--max-spread-km",
type=float,
default=_DEFAULT_MAX_SPREAD_KM,
help=(
"Порог разброса домов внутри ключа (default %(default)s км). Ведро "
"с большим разбросом — склейка нескольких НП, оно выбрасывается."
),
)
p.add_argument( p.add_argument(
"--dry-run", "--dry-run",
action="store_true", action="store_true",
@ -543,30 +809,47 @@ def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns the number of deals geocoded this run.""" """CLI entry point. Returns the number of deals geocoded this run."""
args = _parse_args(argv) args = _parse_args(argv)
logger.info( logger.info(
"starting batch=%s limit=%s dry_run=%s", "starting batch=%s region=%s limit=%s max_spread_km=%s dry_run=%s",
args.batch, args.batch,
args.region_code,
args.limit, args.limit,
args.max_spread_km,
args.dry_run, args.dry_run,
) )
db = SessionLocal() db = SessionLocal()
try: try:
centroids = _build_centroid_map(db) centroids = _build_centroid_map(
logger.info("built centroid map: %d distinct streets", len(centroids)) db, region_code=args.region_code, max_spread_km=args.max_spread_km
)
logger.info(
"built centroid map: %d distinct keys (region %s)", len(centroids), args.region_code
)
if not centroids: if not centroids:
logger.warning("no house centroids — houses table has no geocoded rows; nothing to do") logger.warning(
"no house centroids for region %s — houses has no geocoded rows there; "
"nothing to do",
args.region_code,
)
return 0 return 0
deals = _select_deals_without_coords(db, args.limit) deals = _select_deals_without_coords(db, args.limit, region_code=args.region_code)
logger.info("loaded deals without coords: %d", len(deals)) logger.info("loaded deals without coords: %d", len(deals))
if not deals: if not deals:
logger.info("nothing to do — no deals with lat IS NULL and an address") logger.info("nothing to do — no deals with lat IS NULL and an address")
return 0 return 0
stats = _run_backfill(db, deals, centroids, batch=args.batch, dry_run=args.dry_run) stats = _run_backfill(
db,
deals,
centroids,
batch=args.batch,
dry_run=args.dry_run,
region_code=args.region_code,
)
if args.dry_run: if args.dry_run:
total_null = _count_deals_null(db) total_null = _count_deals_null(db, region_code=args.region_code)
_report_dry_run( _report_dry_run(
stats, stats,
centroids, centroids,

View file

@ -32,8 +32,10 @@ from scripts.geocode_deals_from_houses import (
Centroid, Centroid,
DealRow, DealRow,
Stats, Stats,
_address_key,
_build_centroid_map, _build_centroid_map,
_run_backfill, _run_backfill,
_select_deals_without_coords,
_street_key, _street_key,
_update_deal_coords, _update_deal_coords,
main, main,
@ -178,16 +180,16 @@ def test_build_centroid_map_averages_houses_on_one_street():
"""Two houses on Малышева → centroid is the mean of their coords.""" """Two houses on Малышева → centroid is the mean of their coords."""
house_rows = [ house_rows = [
{"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50}, {"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
{"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.90, "lon": 60.70}, {"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.82, "lon": 60.54},
] ]
db, _ = _make_db_mock(house_rows=house_rows) db, _ = _make_db_mock(house_rows=house_rows)
centroids = _build_centroid_map(db) centroids = _build_centroid_map(db)
assert set(centroids) == {"малышева"} assert set(centroids) == {(66, "екатеринбург", "малышева")}
c = centroids["малышева"] c = centroids[(66, "екатеринбург", "малышева")]
assert c.lat == pytest.approx(56.85) assert c.lat == pytest.approx(56.81)
assert c.lon == pytest.approx(60.60) assert c.lon == pytest.approx(60.52)
assert c.house_count == 2 assert c.house_count == 2
@ -201,12 +203,15 @@ def test_build_centroid_map_groups_distinct_streets():
centroids = _build_centroid_map(db) centroids = _build_centroid_map(db)
assert set(centroids) == {"малышева", "ленина"} assert set(centroids) == {
assert centroids["малышева"].house_count == 2 (66, "екатеринбург", "малышева"),
assert centroids["ленина"].house_count == 1 (66, "екатеринбург", "ленина"),
}
assert centroids[(66, "екатеринбург", "малышева")].house_count == 2
assert centroids[(66, "екатеринбург", "ленина")].house_count == 1
# Малышева centroid = mean of the two Малышева rows. # Малышева centroid = mean of the two Малышева rows.
assert centroids["малышева"].lat == pytest.approx(56.81) assert centroids[(66, "екатеринбург", "малышева")].lat == pytest.approx(56.81)
assert centroids["ленина"].lat == pytest.approx(56.84) assert centroids[(66, "екатеринбург", "ленина")].lat == pytest.approx(56.84)
def test_build_centroid_map_skips_unparseable_address(): def test_build_centroid_map_skips_unparseable_address():
@ -218,7 +223,7 @@ def test_build_centroid_map_skips_unparseable_address():
db, _ = _make_db_mock(house_rows=house_rows) db, _ = _make_db_mock(house_rows=house_rows)
centroids = _build_centroid_map(db) centroids = _build_centroid_map(db)
assert set(centroids) == {"малышева"} assert set(centroids) == {(66, "екатеринбург", "малышева")}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -228,7 +233,7 @@ def test_build_centroid_map_skips_unparseable_address():
def test_run_backfill_matched_street_issues_update_with_centroid(): def test_run_backfill_matched_street_issues_update_with_centroid():
deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125") deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125")
centroids = {"малышева": Centroid(lat=56.838, lon=60.586, house_count=3)} centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.838, lon=60.586, house_count=3)}
db, updated = _make_db_mock() db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False) stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False)
@ -247,7 +252,7 @@ def test_run_backfill_matched_street_issues_update_with_centroid():
def test_run_backfill_deal_with_only_street_name_matches(): def test_run_backfill_deal_with_only_street_name_matches():
"""Deal address with no house number / no type word still matches.""" """Deal address with no house number / no type word still matches."""
deal = DealRow(id=7, address="Екатеринбург, Малышева") deal = DealRow(id=7, address="Екатеринбург, Малышева")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)} centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock() db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False) stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
@ -264,7 +269,7 @@ def test_run_backfill_deal_with_only_street_name_matches():
def test_run_backfill_unmatched_street_no_update(): def test_run_backfill_unmatched_street_no_update():
deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1") deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)} centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock() db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False) stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
@ -275,13 +280,13 @@ def test_run_backfill_unmatched_street_no_update():
assert updated == [] assert updated == []
assert db.commit.call_count == 0 assert db.commit.call_count == 0
# Unmatched street tracked for the dry-run report. # Unmatched street tracked for the dry-run report.
assert stats.unmatched_streets["несуществующая"] == 1 assert stats.unmatched_streets["екатеринбург/несуществующая"] == 1
def test_run_backfill_unparseable_deal_tracked_as_sentinel(): def test_run_backfill_unparseable_deal_tracked_as_sentinel():
"""A deal whose address yields an empty key is a no-match under a sentinel.""" """A deal whose address yields an empty key is a no-match under a sentinel."""
deal = DealRow(id=5, address="Екатеринбург") deal = DealRow(id=5, address="Екатеринбург")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)} centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock() db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False) stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
@ -298,7 +303,7 @@ def test_run_backfill_unparseable_deal_tracked_as_sentinel():
def test_run_backfill_dry_run_issues_no_update(): def test_run_backfill_dry_run_issues_no_update():
deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1") deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)} centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock() db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True) stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True)
@ -319,7 +324,7 @@ def test_run_backfill_db_write_failure_isolated_to_row():
DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"), DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"),
DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"), DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"),
] ]
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)} centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock() db, updated = _make_db_mock()
# Make the FIRST UPDATE raise, the rest succeed. # Make the FIRST UPDATE raise, the rest succeed.
@ -359,7 +364,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
assert "geocode_tried_at = NOW()" in sql_str assert "geocode_tried_at = NOW()" in sql_str
# geom must NOT be set manually — the deals_set_geom_trg trigger fills it. # geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
assert "geom" not in sql_str assert "geom" not in sql_str
assert binds == {"id": 3, "lat": 56.1, "lon": 60.2} assert binds == {"id": 3, "lat": 56.1, "lon": 60.2, "rc": 66}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -370,7 +375,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
def test_main_respects_limit_and_returns_geocoded(): def test_main_respects_limit_and_returns_geocoded():
house_rows = [ house_rows = [
{"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50}, {"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
{"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.90, "lon": 60.70}, {"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.82, "lon": 60.54},
] ]
deal_rows = [ deal_rows = [
{"id": 1, "address": "Екатеринбург, Малышева"}, {"id": 1, "address": "Екатеринбург, Малышева"},
@ -386,8 +391,8 @@ def test_main_respects_limit_and_returns_geocoded():
assert len(updated) == 1 assert len(updated) == 1
assert updated[0]["id"] == 1 assert updated[0]["id"] == 1
# Centroid used = mean of the two house rows. # Centroid used = mean of the two house rows.
assert updated[0]["lat"] == pytest.approx(56.85) assert updated[0]["lat"] == pytest.approx(56.81)
assert updated[0]["lon"] == pytest.approx(60.60) assert updated[0]["lon"] == pytest.approx(60.52)
def test_main_dry_run_writes_nothing(): def test_main_dry_run_writes_nothing():
@ -432,3 +437,114 @@ def test_stats_unmatched_streets_counter_defaults_empty():
assert s.no_street_match == 0 assert s.no_street_match == 0
assert s.failed == 0 assert s.failed == 0
assert s.unmatched_streets.most_common(3) == [] assert s.unmatched_streets.most_common(3) == []
# ---------------------------------------------------------------------------
# Одноимённые улицы разных НП + однорегиональность прогона (fix/oblast-deal-geocoding)
# ---------------------------------------------------------------------------
def test_address_key_distinguishes_same_street_in_different_localities():
"""«Луговая» в Сабурово и «Луговая» в Красногорске — РАЗНЫЕ ключи.
Ровно та ловушка, ради которой ключ стал составным: до этого обе улицы
сливались в одно ведро 'луговая', и центроид садился между городами.
"""
saburovo = _address_key("Сабурово, Луговая", 50)
krasnogorsk = _address_key("Красногорск, Луговая", 50)
assert saburovo == (50, "сабурово", "луговая")
assert krasnogorsk == (50, "красногорск", "луговая")
assert saburovo != krasnogorsk
def test_address_key_distinguishes_same_street_in_different_regions():
"""Регион — тоже часть ключа: «Ленина» в Химках ≠ «Ленина» в Екатеринбурге."""
assert _address_key("г Химки, ул Ленина, 5", 50) == (50, "химки", "ленина")
assert _address_key("Екатеринбург, ул. Ленина, 5", 66) == (66, "екатеринбург", "ленина")
def test_address_key_region_without_city_core_refuses_nameless_locality():
"""У области НП обязателен: без него ключа нет (а не «пусть будет Красногорск»)."""
assert _address_key("Луговая, 5", 50) is None
# У 66 город-ядро есть — историческое поведение сохранено.
assert _address_key("Малышева, 125", 66) == (66, "екатеринбург", "малышева")
def test_build_centroid_map_separates_same_street_of_two_localities():
"""Дома с одноимённых улиц двух НП не усредняются в одну точку."""
house_rows = [
{"address": "Московская обл., д Сабурово, ул Луговая, 1", "lat": 55.40, "lon": 37.60},
{"address": "Московская обл., д Сабурово, ул Луговая, 3", "lat": 55.41, "lon": 37.61},
{"address": "Московская обл., г Химки, ул Луговая, 2", "lat": 55.89, "lon": 37.43},
]
db, _ = _make_db_mock(house_rows=house_rows)
centroids = _build_centroid_map(db, region_code=50)
assert set(centroids) == {(50, "сабурово", "луговая"), (50, "химки", "луговая")}
assert centroids[(50, "сабурово", "луговая")].house_count == 2
assert centroids[(50, "сабурово", "луговая")].lat == pytest.approx(55.405)
assert centroids[(50, "химки", "луговая")].lat == pytest.approx(55.89)
def test_build_centroid_map_drops_bucket_with_huge_spread():
"""Ведро с разбросом в десятки км — склейка НП, выбрасывается целиком."""
house_rows = [
{"address": "Московская обл., г Химки, ул Ленина, 1", "lat": 55.89, "lon": 37.43},
# Тот же ключ, но точка в 60+ км — так выглядит склейка двух НП.
{"address": "Московская обл., г Химки, ул Ленина, 2", "lat": 55.30, "lon": 38.20},
]
db, _ = _make_db_mock(house_rows=house_rows)
assert _build_centroid_map(db, region_code=50) == {}
# Порог поднят выше разброса → ведро остаётся.
kept = _build_centroid_map(db, region_code=50, max_spread_km=100.0)
assert set(kept) == {(50, "химки", "ленина")}
def test_centroid_query_filters_by_region():
"""Карта центроидов строится по домам ОДНОГО региона, а не по всем."""
db, _ = _make_db_mock(house_rows=[])
_build_centroid_map(db, region_code=50)
args, _kw = db.execute.call_args
sql_str = str(args[0])
assert "FROM houses" in sql_str
assert "region_code = CAST(:rc AS smallint)" in sql_str
assert args[1]["rc"] == 50
def test_candidate_select_and_update_are_scoped_to_one_region():
"""Прогон по региону 50 не выбирает и не обновляет строки другого региона."""
db, _unused = _make_db_mock(
deal_rows=[{"id": 7, "address": "Сабурово, Луговая", "city": "Сабурово"}]
)
deals = _select_deals_without_coords(db, 10, region_code=50)
args, _kw = db.execute.call_args
select_sql = str(args[0])
assert "FROM deals" in select_sql
assert "region_code = CAST(:rc AS int)" in select_sql
assert args[1]["rc"] == 50
assert deals[0].city == "Сабурово"
_update_deal_coords(db, deal_id=7, lat=55.4, lon=37.6, region_code=50)
args, _kw = db.execute.call_args
update_sql = str(args[0])
assert "UPDATE deals" in update_sql
assert "region_code = CAST(:rc AS int)" in update_sql
assert args[1]["rc"] == 50
def test_run_backfill_uses_deal_city_column_for_the_locality():
"""deals.city (заполнена на 100% по области) переопределяет НП из адреса."""
centroids = {(50, "сабурово", "луговая"): Centroid(lat=55.4, lon=37.6, house_count=2)}
db, updated = _make_db_mock()
deals = [DealRow(id=11, address="Луговая", city="Сабурово")]
stats = _run_backfill(db, deals, centroids, batch="t", dry_run=False, region_code=50)
assert stats.geocoded == 1
assert updated[0]["lat"] == pytest.approx(55.4)
assert updated[0]["rc"] == 50