"""Location-сервис — district-level индексы первоклассной «локации» (#948 Part B, §8.2). Промоутит район (district) в queryable сущность `location` (data/sql/146_location.sql), несущую 4 нормированных индекса: • infra — инфраструктура района • competition — конкуренция (доступное/затоваренное конкурирующее предложение) • demand — спрос (темп продаж) • future_supply — будущее давление предложения ADDITIVE (HARD CONSTRAINT #948B): `district` (string) НЕ рефакторится в FK. Этот сервис — НОВЫЙ слой РЯДОМ: ключует `location` по `district_name` (joinable по строке), переиспользуя существующие per-district forecast-функции (они #1129-кэшированы, вызов per-district дёшев). Существующий код продолжает ходить по district-строкам. ИСТОЧНИКИ ИНДЕКСОВ (reuse, не изобретаем) + НОРМАЛИЗАЦИЯ к [0,1] (None при отсутствии данных — НИКОГДА не фабрикуем 0, дух market_metrics.py / future_supply.py): • competition ← market_metrics.compute_market_metrics(db, district=…).overstock_index overstock_index = долго-экспонируемый непроданный сток / все доступные ед. ∈ [0,1] — доля «зависшего» конкурирующего предложения (в продаже > N мес без сделки). Это ИСТИННЫЙ сигнал конкуренции = доступное/затоваренное конкурирующее предложение: выше = больше конкурирующего стока простаивает = БОЛЬШЕ конкурентного давления для нового входящего игрока. УЖЕ нормирован [0,1] (доля от доступных) — берём как есть, без пере-нормировки. None когда метрика None (нет доступных лотов / пустая выборка). ОРТОГОНАЛЬНОСТЬ к demand: competition мерит ДОСТУПНОЕ/непроданное предложение, demand — скорость продаж (unit_velocity). Это разные оси (saturation supply vs sales velocity), не монотонно связанные. Раньше тут стоял sell_through_pct (доля реализованного стока) — но это absorption/heat-сигнал, монотонно растущий с продажами (коррелирован с demand=velocity) И инвертированный относительно «свободного конкурирующего стока»: высокий sell-through = МЕНЬШЕ доступной конкуренции, а не больше. overstock_index — честная, ортогональная спросу замена. • demand ← market_metrics.compute_market_metrics(db, district=…).unit_velocity, нормированный CITY-RELATIVE (как infra ниже), а НЕ фиксированной константой. unit_velocity = ед., проданных в месяц (за окно) ∈ [0, ∞). Это ТОТ ЖЕ сигнал спроса, что использует forecasting/demand_supply_forecast (base_pace). У него нет естественного абсолютного потолка → нормируем ОТНОСИТЕЛЬНО самого рынка: demand(district) = clamp01(velocity / city_reference_velocity), где city_reference_velocity = MAX скорость продаж среди всех районов прогона. «Спрос относительно самого горячего района»: busiest = 1.0, остальные пропорционально ниже. None когда unit_velocity None (нет выборки). ПОЧЕМУ relative, а НЕ absolute (#948 fix): прежняя реализация делила на жёсткую константу _DEMAND_SATURATION_UPM=50 ед./мес. На live-прогоне ВСЕ 8 районов ЕКБ продавали ≥50/мес → demand=1.0 У ВСЕХ (плоское насыщение, НОЛЬ дискриминации между районами — бесполезно как сравнительный сигнал). Абсолютная константа «гниёт»: она была неверна уже сегодня и расходилась бы дальше с ростом рынка. Относительная нормировка (как у infra: district POI / city POI) само- калибруется — всегда даёт спред для любого невырожденного распределения и НИКОГДА не требует ручной перенастройки. Выбран MAX как якорь (простейший статистик, гарантирующий дискриминацию; при 8 районах «выброс» = сигнал, а не шум — самый горячий район и ЕСТЬ потолок спроса). Вырожденные случаи: reference=0 (все честно продали 0) → demand=0.0 у всех (честный ноль, НЕ None); все скорости равны → 1.0 у всех («одинаково горячо»). • future_supply ← future_supply.compute_future_supply_pressure(db, district=…).index УЖЕ нормирован ∈ [0,1] самим сервисом (насыщение months_of_pressure). Берём как есть — НЕ пере-нормируем. None когда index None (нет supply-данных / поглощения). • infra ← analytics_queries._district_poi_score(db, district_name) / analytics_queries._city_avg_poi_score(db) _district_poi_score = средний по ЖК района weighted POI-count (метро/медицина тяжелее) в радиусе 1000м; _city_avg_poi_score — то же по всему ЕКБ (готовый нормализатор, уже существует). Нормализация: district / city, clamp [0,1] — район со средней-по-городу инфраструктурой ≈ 0.5..1.0, лучше города → clamp в 1.0. Это РЕАЛЬНЫЙ per-district infra-сигнал (НЕ NULL-deferred). None когда в районе <3 ЖК с POI (_district_poi_score вернул None) ИЛИ нет city-baseline. DISTRICT SOURCE: 8 официальных АДМИН-районов ЕКБ — через district_resolver._admin_names(db) (готовый кэшированный запрос `SELECT district_name FROM ekb_districts WHERE geom IS NOT NULL`; 'не определён' там нет — и не должно быть). Это канонический district-вокабуляр для наполнения локаций. CENTROID: derivable из ekb_districts_geom (PostGIS-полигоны, миграция 56) через ST_Centroid(geom) — наполняется в refresh_locations прямым SQL (район может не иметь geom-строки → centroid остаётся NULL, graceful). Conventions: psycopg v3 CAST(:x AS type) — НИКОГДА :x::type; SAVEPOINT per-row в refresh-цикле (backend.md); logger не print; graceful — сбой одного района не валит остальные. Детерминированно, без LLM. """ from __future__ import annotations import logging from dataclasses import dataclass from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session from app.schemas.location import LocationList, LocationOut from app.services.analytics_queries import _city_avg_poi_score, _district_poi_score from app.services.site_finder.district_resolver import _admin_names from app.services.site_finder.future_supply import compute_future_supply_pressure from app.services.site_finder.market_metrics import compute_market_metrics logger = logging.getLogger(__name__) # demand-индекс нормируется CITY-RELATIVE (велосити района / city_reference_velocity), # а не фиксированной константой — никакого _DEMAND_SATURATION_UPM (см. #948 fix в # docstring выше + normalize_demand ниже). Якорь city_reference выводится в # refresh_locations из собранных за прогон скоростей (MAX), потому константы здесь нет. # Окно метрик рынка (мес) — совпадает с дефолтом compute_market_metrics (§9.2). _MARKET_WINDOW_MONTHS: int = 6 # Горизонт будущего предложения (мес) — дефолт compute_future_supply_pressure (§9.3). _FUTURE_SUPPLY_HORIZON_MONTHS: int = 12 @dataclass(frozen=True) class LocationIndices: """4 district-level индекса (#948 §8.2). Каждый ∈ [0,1] или None (нет данных). None НИКОГДА не подменяется 0 — «нет данных» и «честный ноль» обязаны различаться (дух market_metrics.py graceful-on-thin-data). demand_index — CITY-RELATIVE (#948 fix), поэтому считается не локально по одному району, а нормировкой против опорной скорости по городу (см. normalize_demand + refresh_locations two-pass). compute_location_indices возвращает СЫРУЮ скорость района в raw_unit_velocity (входной материал для нормировки) и оставляет demand_index=None как плейсхолдер — финальное значение проставляет pass 2 refresh. """ infra_index: float | None competition_index: float | None demand_index: float | None future_supply_index: float | None # Сырая unit_velocity (ед./мес) района — None при отсутствии выборки. НЕ персистится # в колонку; служит входом для city-relative нормировки demand в refresh_locations. raw_unit_velocity: float | None = None def as_dict(self) -> dict[str, float | None]: return { "infra_index": _round_or_none(self.infra_index), "competition_index": _round_or_none(self.competition_index), "demand_index": _round_or_none(self.demand_index), "future_supply_index": _round_or_none(self.future_supply_index), } def _round_or_none(value: float | None, digits: int = 4) -> float | None: return round(value, digits) if value is not None else None # ────────────────────────────────────────────────────────────────────────────── # Pure-нормализация — без БД, полностью юнит-тестируемо. # Каждая функция graceful: None на входе → None на выходе (не 0). # ────────────────────────────────────────────────────────────────────────────── def _clamp01(value: float) -> float: """Зажать в [0,1]. PURE.""" return max(0.0, min(1.0, value)) def normalize_competition(overstock_index: float | None) -> float | None: """Индекс конкуренции = доступное/затоваренное конкурирующее предложение → [0,1]. PURE. Источник — market_metrics.overstock_index = долго-экспонируемый непроданный сток / все доступные ед. ∈ [0,1]: доля «зависшего» конкурирующего предложения. Выше = больше конкурирующего стока простаивает = БОЛЬШЕ конкурентного давления для нового входящего игрока. Это ИСТИННЫЙ сигнал конкуренции и он ОРТОГОНАЛЕН demand (скорости продаж = unit_velocity), а не коррелирован с ним (как был старый sell_through). Уже нормирован [0,1] самим market_metrics (доля от доступных) — только clamp как защита от артефактов. None → None (нет данных / нет доступных лотов, НЕ 0 — иначе «нет конкурирующего стока для замера» неотличимо от «честный ноль затоваривания»). """ if overstock_index is None: return None return _clamp01(overstock_index) def normalize_demand( unit_velocity: float | None, *, city_reference_velocity: float | None, ) -> float | None: """Индекс спроса CITY-RELATIVE: velocity района / city_reference → [0,1]. PURE. Зеркалит дисциплину normalize_infra (district / city-reference, clamp), а НЕ фиксированную константу. city_reference_velocity = опорная скорость продаж по городу (refresh_locations передаёт MAX среди всех районов прогона). Так demand ВСЕГДА дискриминирует районы и само-калибруется — см. #948 fix в module docstring (старый /50 давал demand=1.0 у всех 8 районов ЕКБ → ноль дискриминации). Semantics / edge cases (каждый покрыт юнит-тестом): • unit_velocity is None → None («нет выборки», НЕ 0 — load-bearing дисциплина модуля: «нет данных» ≠ «честный ноль»). • city_reference is None → None (нет опоры: все районы без данных). • city_reference == 0 (вырождение: все районы честно продали 0) → 0.0, НЕ None и без ZeroDivisionError (честный нулевой спрос). • unit_velocity == 0 при положительной опоре → 0.0 (честный ноль). • все скорости равны / единственный район → 1.0 («одинаково горячо»). clamp01 страхует от velocity > reference (плавающая арифметика / редкие артефакты). """ if unit_velocity is None or city_reference_velocity is None: return None if city_reference_velocity <= 0: # Вырождение: опора 0 ⇒ весь город продал 0/мес. Честный нулевой спрос у всех # (НЕ None — выборка была, market_metrics уже отличил «0 продаж» от «нет данных»), # без деления на ноль. return 0.0 return _clamp01(unit_velocity / city_reference_velocity) def normalize_infra( district_poi: float | None, city_avg_poi: float | None, ) -> float | None: """Инфра-индекс из POI района относительно POI города → [0,1]. PURE. district_poi / city_avg_poi, clamp [0,1]: район со средней-по-городу инфраструктурой ≈ 1.0 (но clamp удержит лучше-города в 1.0; район беднее города → <1.0). None если district_poi None (в районе <3 ЖК с POI) ИЛИ city-baseline None/≤0 (нет нормализатора). Никогда не 0-как-заглушка. """ if district_poi is None or city_avg_poi is None or city_avg_poi <= 0: return None return _clamp01(district_poi / city_avg_poi) # ────────────────────────────────────────────────────────────────────────────── # DB-оркестратор индексов — тонкий, graceful. Pure-нормализация выше тестируется без БД. # ────────────────────────────────────────────────────────────────────────────── def compute_location_indices(db: Session, district: str) -> LocationIndices: """Вычислить per-district индексы района (#948 §8.2) — PASS 1 материал для refresh. Переиспользует существующие per-district forecast-функции (#1129-кэшированы) и нормализует каждый локально-вычислимый результат к [0,1] (None при отсутствии данных — см. модульный docstring про нормализацию каждого индекса). НИКОГДА не фабрикует 0. demand_index НЕ вычисляется здесь: он CITY-RELATIVE (#948 fix) и требует опорной скорости по городу, доступной только после сбора всех районов. Возвращаем СЫРУЮ скорость района в raw_unit_velocity, а demand_index оставляем None (плейсхолдер); финальную city-relative нормировку проставляет refresh_locations (pass 2). Это единственный вызов compute_market_metrics на район (без O(n²)). Graceful: если источник бросает — соответствующий индекс = None (logged), остальные считаются. Никогда не валит весь расчёт из-за одного сбойного сигнала. Args: db: SQLAlchemy sync Session. district: имя района (одно из 8 админ-районов ЕКБ). Returns: LocationIndices: infra/competition/future_supply нормированы (или None); demand_index=None (заполнит pass 2); raw_unit_velocity = сырая скорость района (или None) для city-relative нормировки demand. """ competition_index: float | None = None raw_unit_velocity: float | None = None future_supply_index: float | None = None infra_index: float | None = None # ── competition + raw demand-velocity ← market_metrics (ОДИН вызов, два сигнала) ─ try: metrics = compute_market_metrics(db, district=district, window_months=_MARKET_WINDOW_MONTHS) # competition ← overstock_index (доступное/затоваренное конкурирующее # предложение) — ортогонален demand. НЕ sell_through (тот коррелирован с # velocity и инвертирован относительно «свободной конкуренции»). competition_index = normalize_competition(metrics.overstock_index) # demand нормируется city-relative в pass 2 — здесь только собираем сырьё. raw_unit_velocity = metrics.unit_velocity except Exception: logger.exception( "location_indices: market_metrics failed (district=%s) → competition/velocity None", district, ) # ── future_supply ← future_supply (уже 0..1) ──────────────────────────────── try: fsp = compute_future_supply_pressure( db, district=district, horizon_months=_FUTURE_SUPPLY_HORIZON_MONTHS ) # index уже нормирован [0,1] самим сервисом — берём как есть (None passthrough). future_supply_index = fsp.index except Exception: logger.exception( "location_indices: future_supply failed (district=%s) → future_supply None", district, ) # ── infra ← district POI / city POI ───────────────────────────────────────── try: district_poi = _district_poi_score(db, district_name=district) city_poi = _city_avg_poi_score(db) infra_index = normalize_infra(district_poi, city_poi) except Exception: logger.exception( "location_indices: infra (POI) failed (district=%s) → infra None", district ) # Plain rollback OK here (unlike begin_nested at the pass-2 upsert loop below): # compute_location_indices только вызывается из refresh_locations, единственный # caller которой — Celery-таск location_refresh.py, открывающий СВОЙ SessionLocal() # (owned session, не shared request/report-scoped). На момент этого блока (pass 1, # read-only) в транзакции ещё нет незакоммиченных write'ов — откатывать нечего, # rollback() не сиротит внешний SessionTransaction (в отличие от velocity.py:170). # Без rollback аборченная Postgres-транзакция валит СЛЕДУЮЩИЙ район в цикле pass 1 # (см. #2464) — как и все db.execute() в pass 2 upsert. db.rollback() logger.info( "location_indices: district=%s infra=%s competition=%s raw_velocity=%s future_supply=%s", district, _round_or_none(infra_index), _round_or_none(competition_index), _round_or_none(raw_unit_velocity), _round_or_none(future_supply_index), ) return LocationIndices( infra_index=infra_index, competition_index=competition_index, demand_index=None, # city-relative, заполнит pass 2 refresh_locations future_supply_index=future_supply_index, raw_unit_velocity=raw_unit_velocity, ) # ────────────────────────────────────────────────────────────────────────────── # Refresh — upsert индексов по всем районам (SAVEPOINT per-row, CAST, ON CONFLICT). # ────────────────────────────────────────────────────────────────────────────── # centroid derive из ekb_districts_geom: ST_Centroid полигона района. Подзапрос — # район может не иметь geom-строки → centroid NULL (graceful). ON CONFLICT по # district_name (UNIQUE) обновляет индексы + centroid + indices_computed_at. # psycopg v3: ВСЕ binds через CAST(:x AS type) — НИКОГДА :x::type. _UPSERT_LOCATION_SQL = text( """ INSERT INTO location ( district_name, region, centroid, infra_index, competition_index, demand_index, future_supply_index, indices_computed_at, updated_at ) VALUES ( CAST(:district_name AS text), CAST(:region AS text), ( SELECT ST_Centroid(g.geom) FROM ekb_districts_geom g WHERE g.district_name = CAST(:district_name AS text) LIMIT 1 ), CAST(:infra_index AS numeric), CAST(:competition_index AS numeric), CAST(:demand_index AS numeric), CAST(:future_supply_index AS numeric), now(), now() ) ON CONFLICT (district_name) DO UPDATE SET region = EXCLUDED.region, centroid = EXCLUDED.centroid, infra_index = EXCLUDED.infra_index, competition_index = EXCLUDED.competition_index, demand_index = EXCLUDED.demand_index, future_supply_index = EXCLUDED.future_supply_index, indices_computed_at = EXCLUDED.indices_computed_at, updated_at = now() """ ) def refresh_locations(db: Session, *, region: str | None = None) -> dict[str, Any]: """Пересчитать + upsert-нуть индексы по всем 8 админ-районам ЕКБ (#948 §8.2). Two-pass (#948 fix — demand стал CITY-RELATIVE): • Pass 1: для каждого района считаем locally-вычислимые индексы (compute_location_indices) + собираем сырую unit_velocity. ОДИН вызов compute_market_metrics на район (без O(n²)). • Между проходами: city_reference_velocity = MAX среди собранных НЕ-None скоростей (опора для city-relative нормировки demand). None если ни у одного района нет выборки → demand=None у всех (graceful). • Pass 2: normalize_demand(velocity, city_reference) на район + upsert в `location`. Идемпотентно: ON CONFLICT (district_name) обновляет индексы + centroid + indices_computed_at (повторный прогон даёт тот же результат при тех же данных). SAVEPOINT per-row (backend.md): сбойный район откатывается изолированно через begin_nested, не роняя всю транзакцию и не сбивая счётчики (#261/pzz_loader bug). db.commit() один раз в конце. Args: db: SQLAlchemy sync Session. region: метка региона в колонку `location.region` (None = ЕКБ/Свердл.). Returns: Счётчики: {"districts": N, "upserted": M, "failed": K}. """ districts = sorted(_admin_names(db)) if not districts: logger.warning( "refresh_locations: district source empty (ekb_districts geom?) — nothing to refresh" ) return {"districts": 0, "upserted": 0, "failed": 0} # ── Pass 1: per-district индексы + сырьё для city-relative demand ──────────── # Сбой compute_location_indices по одному району не валит прогон: индексы=None, # район всё равно апсертится в pass 2 (graceful, как было). per_district: list[tuple[str, LocationIndices]] = [] for district in districts: try: per_district.append((district, compute_location_indices(db, district))) except Exception as e: logger.warning( "refresh_locations: district=%s compute failed: %s — indices None", district, e ) per_district.append((district, LocationIndices(None, None, None, None))) # ── City-reference: MAX скорость среди НЕ-None (опора demand-нормировки). ──── # «Спрос относительно самого горячего района» — само-калибрующийся якорь, всегда # даёт спред (см. #948 fix в module docstring). None ⇒ ни у кого нет данных. velocities = [ idx.raw_unit_velocity for _, idx in per_district if idx.raw_unit_velocity is not None ] city_reference_velocity: float | None = max(velocities) if velocities else None logger.info( "refresh_locations: city_reference_velocity=%s (from %d non-None of %d districts)", _round_or_none(city_reference_velocity), len(velocities), len(districts), ) # ── Pass 2: city-relative demand + upsert (SAVEPOINT per-row) ─────────────── upserted = 0 failed = 0 for district, indices in per_district: try: demand_index = normalize_demand( indices.raw_unit_velocity, city_reference_velocity=city_reference_velocity ) with db.begin_nested(): db.execute( _UPSERT_LOCATION_SQL, { "district_name": district, "region": region, "infra_index": indices.infra_index, "competition_index": indices.competition_index, "demand_index": demand_index, "future_supply_index": indices.future_supply_index, }, ) upserted += 1 except Exception as e: failed += 1 logger.warning("refresh_locations: district=%s upsert failed: %s", district, e) db.commit() logger.info( "refresh_locations: districts=%d upserted=%d failed=%d", len(districts), upserted, failed, ) return {"districts": len(districts), "upserted": upserted, "failed": failed} # ────────────────────────────────────────────────────────────────────────────── # Read-helpers для GET-API (read-only сущность — без CRUD). # centroid не отдаём бинарём: ST_X/ST_Y → lon/lat (NULL если centroid NULL). # ────────────────────────────────────────────────────────────────────────────── _SELECT_COLS = """ id, district_name, region, ST_X(centroid) AS lon, ST_Y(centroid) AS lat, infra_index, competition_index, demand_index, future_supply_index, indices_computed_at, created_at, updated_at """ def _row_to_out(r: Any) -> LocationOut: return LocationOut( id=int(r["id"]), district_name=r["district_name"], region=r["region"], lon=float(r["lon"]) if r["lon"] is not None else None, lat=float(r["lat"]) if r["lat"] is not None else None, infra_index=float(r["infra_index"]) if r["infra_index"] is not None else None, competition_index=( float(r["competition_index"]) if r["competition_index"] is not None else None ), demand_index=float(r["demand_index"]) if r["demand_index"] is not None else None, future_supply_index=( float(r["future_supply_index"]) if r["future_supply_index"] is not None else None ), indices_computed_at=r["indices_computed_at"], created_at=r["created_at"], updated_at=r["updated_at"], ) def list_locations(db: Any) -> LocationList: """Все локации с индексами (8 районов — пагинация не нужна, отдаём целиком).""" rows = ( db.execute(text(f"SELECT {_SELECT_COLS} FROM location ORDER BY district_name")) .mappings() .all() ) out = [_row_to_out(r) for r in rows] return LocationList(total=len(out), rows=out) def get_location(db: Any, district_name: str) -> LocationOut | None: """Вернуть одну локацию по district_name. None если нет (роутер → 404).""" row = ( db.execute( text(f"SELECT {_SELECT_COLS} FROM location WHERE district_name = :dn"), {"dn": district_name}, ) .mappings() .first() ) if row is None: return None return _row_to_out(row)