/analyze passes the official ЕКБ admin district (ekb_districts polygon, e.g.
'Кировский'), but objective_lots/corpus_room_month store informal micro-districts
('Втузгородок','ЖБИ') -> admin name matched 0 rows -> silent empty forecast.
Add resolve_objective_districts() (site_finder/district_resolver.py) mapping an
admin name to its clean micros via ekb_district_alias (note IS NULL), with
None -> EKB-wide fallback and raw-micro pass-through. Wire into the objective_lots
district filters of market_metrics (§9.2 stock+sales), supply_layers L1 (§9.3),
and sales_series Sources A+B (crm shares the micro vocab, prod-verified),
switching the scalar filter to psycopg3-safe = ANY(CAST(:districts AS text[])).
supply_layers L2/L3 keep the admin name (domrf_kn_objects.district_name is admin vocab).
Prod: Кировский/Ленинский/Орджоникидзевский obj_count 0 -> 32/64/31.
Tests mutation-verified non-vacuous. 192 module tests pass; ruff clean. Refs #969 #949.
144 lines
8 KiB
Python
144 lines
8 KiB
Python
"""District-resolver — мост между ДВУМЯ районными вокабулярами ЕКБ (Step 2).
|
||
|
||
Контекст бага (ground-truthed на prod, см. `data/sql/131_district_vocab_map.sql`):
|
||
• §9.x market/supply/sales метрики фильтруют `objective_lots.district` (и
|
||
`objective_corpus_room_month.district`) — это НЕФОРМАЛЬНЫЕ МИКРО-районы ЕКБ
|
||
(«Втузгородок», «ЖБИ», «Уралмаш» … — 35 distinct значений).
|
||
• Но `/analyze` резолвит у участка ОФИЦИАЛЬНЫЙ АДМИН-район из `ekb_districts`
|
||
(8 полигонов: «Кировский», «Ленинский», «Орджоникидзевский» …).
|
||
• Админ-имя → 0 строк в objective_lots → молча пустой прогноз. Напр.
|
||
market_metrics('Кировский') → obj_count=0 (а 'Академический' случайно работает,
|
||
т.к. он есть в ОБОИХ вокабулярах).
|
||
|
||
Step 1 (PR #1053, УЖЕ НА ПРОДЕ) добавил таблицу-словарь `ekb_district_alias`
|
||
(admin_district → micro_district, note). Этот модуль — Step 2: резолвер, который
|
||
разворачивает админ-имя в список микро-районов, чтобы фильтры §9.x перестали
|
||
возвращать пустоту.
|
||
|
||
Семантика `resolve_objective_districts(db, district)` — см. docstring функции.
|
||
psycopg v3: все binds через `CAST(:x AS type)`, НИКОГДА `:x::type`. Read-only SELECT.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Сентинел «район не определён» — приходит из /analyze, когда участок не попал ни в
|
||
# один из 8 полигонов ekb_districts (вне ЕКБ / без координат). Трактуем как None →
|
||
# EKB-wide fallback (без district-фильтра), а не как буквальное имя района.
|
||
_UNDETERMINED_DISTRICT: str = "не определён"
|
||
|
||
# Список 8 официальных АДМИН-районов ЕКБ (district_name из ekb_districts с непустым
|
||
# geom). Кешируем per-process: он фактически статичен (8 полигонов, меняется только
|
||
# при пере-импорте границ), а резолвер бьётся per-analyze — лишний round-trip ни к
|
||
# чему. Lazy-init на первом вызове; сбрасывается через `_reset_admin_cache()` в тестах.
|
||
_ADMIN_NAMES_CACHE: frozenset[str] | None = None
|
||
|
||
# Имена 8 админ-районов: те, у кого в ekb_districts есть непустой geom (= ekb_districts_geom
|
||
# view). 'не определён' (9-я строка-dimension без geom) сюда НЕ попадает — и не должен.
|
||
_ADMIN_NAMES_SQL = text(
|
||
"""
|
||
SELECT district_name
|
||
FROM ekb_districts
|
||
WHERE geom IS NOT NULL
|
||
"""
|
||
)
|
||
|
||
# Чистые ЕКБ-микро для одного админ-района. note IS NULL отсекает cross-city
|
||
# (Н.Тагил/Каменск, собранные в bucket «Чкаловский») и ambiguous-пометки —
|
||
# нам нужны только однозначные ЕКБ-микро (см. 131_district_vocab_map.sql).
|
||
_MICROS_FOR_ADMIN_SQL = text(
|
||
"""
|
||
SELECT micro_district
|
||
FROM ekb_district_alias
|
||
WHERE admin_district = CAST(:d AS text)
|
||
AND note IS NULL
|
||
ORDER BY micro_district
|
||
"""
|
||
)
|
||
|
||
|
||
def _admin_names(db: Session) -> frozenset[str]:
|
||
"""Множество 8 официальных админ-имён ЕКБ (с непустым geom), кеш per-process.
|
||
|
||
На ошибке БД возвращает пустой set (logged) — резолвер тогда трактует ЛЮБОЙ
|
||
вход как raw-микро (legacy pass-through), что безопаснее, чем уронить /analyze.
|
||
"""
|
||
global _ADMIN_NAMES_CACHE
|
||
if _ADMIN_NAMES_CACHE is not None:
|
||
return _ADMIN_NAMES_CACHE
|
||
try:
|
||
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
|
||
_ADMIN_NAMES_CACHE = frozenset(str(r) for r in rows if r)
|
||
except Exception:
|
||
# Не кешируем пустой результат-от-сбоя: следующий вызов попробует снова.
|
||
logger.exception("district_resolver: admin-names lookup failed")
|
||
return frozenset()
|
||
return _ADMIN_NAMES_CACHE
|
||
|
||
|
||
def _reset_admin_cache() -> None:
|
||
"""Сбросить per-process кеш админ-имён (для тестов / форс-рефреша границ)."""
|
||
global _ADMIN_NAMES_CACHE
|
||
_ADMIN_NAMES_CACHE = None
|
||
|
||
|
||
def resolve_objective_districts(db: Session, district: str | None) -> list[str] | None:
|
||
"""Развернуть district-вход в набор МИКРО-районов для фильтра objective_lots.
|
||
|
||
Решает рассинхрон вокабуляров ЕКБ (admin polygons vs informal micro-districts).
|
||
Возвращаемое значение — контракт для вызывающего фильтра:
|
||
|
||
• ``None`` → фильтровать EKB-wide (БЕЗ district-фильтра). Это происходит
|
||
когда:
|
||
- вход ``None`` или ``'не определён'`` (участок вне 8
|
||
полигонов — нечего сужать), ЛИБО
|
||
- вход — валидное админ-имя, но у него НЕТ чистых алиасов
|
||
(на практике не случается: все 8 покрыты сидом; страховка).
|
||
• ``[micro, …]`` → фильтровать ``ol.district = ANY(...)``:
|
||
- вход — одно из 8 АДМИН-имён → его чистые микро
|
||
(note IS NULL: cross-city/ambiguous исключены), ЛИБО
|
||
- вход — НЕ админ-имя (raw МИКРО, переданный внутренним
|
||
legacy-вызовом) → ``[district]`` как есть (сохраняем
|
||
прежнее поведение фильтра-по-микро).
|
||
|
||
Никогда не возвращает пустой список — вместо «0 строк гарантированно» отдаёт
|
||
``None`` (EKB-wide), чтобы прогноз не схлопывался молча в пустоту (это и есть
|
||
суть фикса).
|
||
|
||
psycopg v3: bind через CAST(:d AS text). Read-only.
|
||
"""
|
||
if district is None or district == _UNDETERMINED_DISTRICT:
|
||
return None
|
||
|
||
admin_names = _admin_names(db)
|
||
if district not in admin_names:
|
||
# Raw микро-район (legacy внутренний вызов) — фильтруем по нему как есть.
|
||
return [district]
|
||
|
||
# district — официальное админ-имя: разворачиваем в чистые ЕКБ-микро.
|
||
try:
|
||
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
|
||
micros = [str(m) for m in rows]
|
||
except Exception:
|
||
logger.exception(
|
||
"district_resolver: micro-lookup failed (admin=%s) — falling back to EKB-wide",
|
||
district,
|
||
)
|
||
return None
|
||
|
||
if not micros:
|
||
# Админ без чистых алиасов (дыра в словаре) → EKB-wide fallback, НЕ пустой набор.
|
||
logger.warning(
|
||
"district_resolver: admin '%s' has no clean micro aliases — EKB-wide fallback",
|
||
district,
|
||
)
|
||
return None
|
||
|
||
logger.debug("district_resolver: admin '%s' → micros %s", district, micros)
|
||
return micros
|