Геокодирование сделок области: ключ (регион, НП, улица) вместо голой улицы (#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`). Регионы
# без записи здесь получают `marker=None` → cross-check пропускается
# (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__)
@ -962,7 +966,12 @@ _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
# поэтому отдельная карта — по образцу `_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:
@ -1106,7 +1115,14 @@ async def _nominatim_query_city_aware(
)
if city_specified:
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`).
default_city = _region_default_city(region_code)
city_data = await _nominatim_query_multi(

View file

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

View file

@ -52,6 +52,7 @@ from __future__ import annotations
import argparse
import logging
import math
import re
import unicodedata
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.
try:
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
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.db import SessionLocal
from app.services.regions import REGIONS
logging.basicConfig(
level=logging.INFO,
@ -91,6 +94,47 @@ _LOG_EVERY = 1000
# houses (centroid) and deals (lookup) side so garbage never anchors a match.
_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
@ -109,9 +153,11 @@ _ADMIN_SEGMENT_RES = [
r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$",
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
# deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the
@ -187,63 +233,176 @@ def _strip_house_tail(segment: str) -> str:
return " ".join(keep).strip()
def _street_key(address: str | None) -> str:
"""Reduce any address to a bare-street-name key for the centroid join.
def _is_admin_segment(seg: str) -> bool:
"""Сегмент — страна/регион/район (выбрасывается целиком)."""
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:
1. NFC normalize, lowercase, ёе (deals/houses differ on ё usage).
2. Drop a trailing district marker (' · р-н ...', ' | ...').
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.
def _is_numeric_segment(seg: str) -> bool:
"""Сегмент — номер дома/квартиры/корпуса («125», «д 5», «кв 12»)."""
return bool(_NUMERIC_SEGMENT_RE.match(seg))
Returns '' when nothing usable remains (caller filters by _MIN_KEY_LEN).
"""
if not address:
def _normalize_place(value: str | None) -> str:
"""Нормализованное имя НП: lower, ё→е, без типа («г. Химки» → «химки»)."""
if not value:
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 = _WS_RE.sub(" ", s).strip()
if not s:
return ""
# 2. Drop trailing district marker (' · Октябрьский', ' | ...').
return "", ""
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()]
street_seg = ""
for seg in segments:
if any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES):
continue
street_seg = seg
break
non_admin = [seg for seg in segments if not _is_admin_segment(seg)]
if not non_admin:
return "", ""
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
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:
return ""
return place, ""
# 4. Drop apartment/corpus/строение noise inside the street segment.
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()
# 6. Strip trailing house number (keep '8 марта' style numeric streets).
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
class Centroid:
"""One street's centroid, averaged over all geocoded houses on it."""
"""Центроид одного ключа (регион, НП, улица) по домам этого ключа."""
lat: float
lon: float
house_count: int
# Максимальное удаление дома ведра от центроида, км (sanity-чек склейки).
spread_km: float = 0.0
@dataclass
@ -266,6 +427,9 @@ class DealRow:
id: int
address: str | None
# deals.city — росреестровая колонка; по области заполнена у 100% строк без
# geom и надёжнее текста адреса, поэтому переопределяет НП из адреса.
city: str | None = None
@dataclass
@ -276,6 +440,8 @@ class Stats:
geocoded: int = 0
no_street_match: int = 0
failed: int = 0
# Вёдер выброшено sanity-чеком разброса (склейка нескольких НП).
dropped_spread: int = 0
# street_key → count of deals that had no house centroid (dry-run report).
unmatched_streets: Counter[str] = field(default_factory=Counter)
@ -285,14 +451,28 @@ class Stats:
# ---------------------------------------------------------------------------
def _build_centroid_map(db: Session) -> dict[str, Centroid]:
"""Per-street centroid from houses WHERE geom IS NOT NULL.
def _build_centroid_map(
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 = (
db.execute(
text(
@ -302,32 +482,81 @@ def _build_centroid_map(db: Session) -> dict[str, Centroid]:
" AND lat IS NOT NULL "
" AND lon 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()
.all()
)
# street_key → running [lat_sum, lon_sum, n]
acc: dict[str, list[float]] = {}
acc: dict[tuple[int, str, str], list[tuple[float, float]]] = {}
for r in rows:
key = _street_key(r["address"])
if len(key) < _MIN_KEY_LEN:
key = _address_key(r["address"], region_code)
if key is None:
continue
bucket = acc.setdefault(key, [0.0, 0.0, 0.0])
bucket[0] += float(r["lat"])
bucket[1] += float(r["lon"])
bucket[2] += 1.0
acc.setdefault(key, []).append((float(r["lat"]), float(r["lon"])))
return {
key: Centroid(lat=lat_sum / n, lon=lon_sum / n, house_count=int(n))
for key, (lat_sum, lon_sum, n) in acc.items()
}
out: dict[tuple[int, str, str], Centroid] = {}
dropped = 0
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]:
"""deals needing coords (lat IS NULL) — resume-safe candidate set.
def _region_predicate(column: str = "region_code") -> str:
"""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
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 = (
db.execute(
text(
"SELECT id, address "
"SELECT id, address, city "
"FROM deals "
"WHERE lat IS NULL "
" AND address IS NOT NULL "
" AND length(trim(address)) > 0 "
" AND " + _region_predicate() + " "
"ORDER BY id "
"LIMIT CAST(:lim AS int)"
),
{"lim": limit},
{"lim": limit, "rc": region_code},
)
.mappings()
.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.
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
@ -370,9 +607,10 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) ->
" SET lat = CAST(:lat AS double precision), "
" lon = CAST(:lon AS double precision), "
" 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(
db: Session,
deals: list[DealRow],
centroids: dict[str, Centroid],
centroids: dict[tuple[int, str, str], Centroid],
*,
batch: str,
dry_run: bool,
region_code: int = _DEFAULT_REGION_CODE,
) -> Stats:
"""For each deal, look up its street centroid and UPDATE lat/lon.
@ -399,14 +638,14 @@ def _run_backfill(
stats = Stats()
for i, deal in enumerate(deals, start=1):
key = _street_key(deal.address)
centroid = centroids.get(key) if len(key) >= _MIN_KEY_LEN else None
key = _address_key(deal.address, region_code, city=deal.city)
centroid = centroids.get(key) if key is not None else None
if centroid is None:
stats.no_street_match += 1
# Track the raw key (or a sentinel) so the dry-run report can show
# 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
if dry_run and i % _LOG_EVERY == 0:
logger.info(
@ -423,7 +662,13 @@ def _run_backfill(
else:
try:
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.
db.commit()
stats.geocoded += 1
@ -454,7 +699,7 @@ def _run_backfill(
def _report_dry_run(
stats: Stats,
centroids: dict[str, Centroid],
centroids: dict[tuple[int, str, str], Centroid],
*,
total_deals_null: int,
candidates: int,
@ -475,7 +720,7 @@ def _report_dry_run(
logger.info("" * 60)
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 matched to a centroid: %d", matched)
logger.info("deals with no street match: %d", stats.no_street_match)
@ -486,7 +731,7 @@ def _report_dry_run(
projected,
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):
logger.info(" %6d %s", cnt, street)
logger.info("" * 60)
@ -497,13 +742,15 @@ def _report_dry_run(
# ---------------------------------------------------------------------------
def _count_deals_null(db: Session) -> int:
"""Full count of deals WHERE lat IS NULL — denominator for projection."""
def _count_deals_null(db: Session, region_code: int = _DEFAULT_REGION_CODE) -> int:
"""Full count of deals WHERE lat IS NULL в этом регионе — знаменатель."""
row = db.execute(
text(
"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()
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()}",
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(
"--dry-run",
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."""
args = _parse_args(argv)
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.region_code,
args.limit,
args.max_spread_km,
args.dry_run,
)
db = SessionLocal()
try:
centroids = _build_centroid_map(db)
logger.info("built centroid map: %d distinct streets", len(centroids))
centroids = _build_centroid_map(
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:
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
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))
if not deals:
logger.info("nothing to do — no deals with lat IS NULL and an address")
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:
total_null = _count_deals_null(db)
total_null = _count_deals_null(db, region_code=args.region_code)
_report_dry_run(
stats,
centroids,

View file

@ -32,8 +32,10 @@ from scripts.geocode_deals_from_houses import (
Centroid,
DealRow,
Stats,
_address_key,
_build_centroid_map,
_run_backfill,
_select_deals_without_coords,
_street_key,
_update_deal_coords,
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."""
house_rows = [
{"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)
centroids = _build_centroid_map(db)
assert set(centroids) == {"малышева"}
c = centroids["малышева"]
assert c.lat == pytest.approx(56.85)
assert c.lon == pytest.approx(60.60)
assert set(centroids) == {(66, "екатеринбург", "малышева")}
c = centroids[(66, "екатеринбург", "малышева")]
assert c.lat == pytest.approx(56.81)
assert c.lon == pytest.approx(60.52)
assert c.house_count == 2
@ -201,12 +203,15 @@ def test_build_centroid_map_groups_distinct_streets():
centroids = _build_centroid_map(db)
assert set(centroids) == {"малышева", "ленина"}
assert centroids["малышева"].house_count == 2
assert centroids["ленина"].house_count == 1
assert set(centroids) == {
(66, "екатеринбург", "малышева"),
(66, "екатеринбург", "ленина"),
}
assert centroids[(66, "екатеринбург", "малышева")].house_count == 2
assert centroids[(66, "екатеринбург", "ленина")].house_count == 1
# Малышева centroid = mean of the two Малышева rows.
assert centroids["малышева"].lat == pytest.approx(56.81)
assert centroids["ленина"].lat == pytest.approx(56.84)
assert centroids[(66, "екатеринбург", "малышева")].lat == pytest.approx(56.81)
assert centroids[(66, "екатеринбург", "ленина")].lat == pytest.approx(56.84)
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)
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():
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()
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():
"""Deal address with no house number / no type word still matches."""
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()
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():
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()
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 db.commit.call_count == 0
# 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():
"""A deal whose address yields an empty key is a no-match under a sentinel."""
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()
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():
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()
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=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()
# 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
# geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
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():
house_rows = [
{"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 = [
{"id": 1, "address": "Екатеринбург, Малышева"},
@ -386,8 +391,8 @@ def test_main_respects_limit_and_returns_geocoded():
assert len(updated) == 1
assert updated[0]["id"] == 1
# Centroid used = mean of the two house rows.
assert updated[0]["lat"] == pytest.approx(56.85)
assert updated[0]["lon"] == pytest.approx(60.60)
assert updated[0]["lat"] == pytest.approx(56.81)
assert updated[0]["lon"] == pytest.approx(60.52)
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.failed == 0
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