Cold §22 forecast measured ~215-233s on prod: §9.x layers re-execute the same horizon/segment-invariant DB loads with identical args hundreds of times per report (profiled: get_competitors x69, market_metrics x124, get_monthly_macro x290). Add a per-report ContextVar cache (forecast_cache(), opened once in the orchestrator) + @cached(key_builder) on the expensive §9.x loaders so each unique load runs ONCE and reuses the same frozen, read-only instance. Output is byte-identical (memoized producers are frozen dataclasses / read-only Pydantic, callers never mutate; cache is per-report, discarded on exit; no-op outside the report build). No concurrency, no signature changes. - forecast_request_cache.py: ContextVar cache + cached() decorator (no-op outside context, reentrant, _MISS sentinel for cached None) - @cached on competitors/future_supply/market_metrics/macro_series/ sales_series/macro_coefficient/demand_normalization/regression loaders - orchestrator: wrap build_site_finder_report in forecast_cache() - 58 tests: key discrimination (call-counting regression guard), no-op-outside, per-report isolation, reentrancy, frozen-producer canary, amplification proof (real get_monthly_macro xN->1) code-reviewer APPROVE (keys correct, mutation-safe, output identical). 1265 forecast/cache tests green. No new deps. Refs #1129.
448 lines
27 KiB
Python
448 lines
27 KiB
Python
"""Future-supply-pressure service — индекс будущего давления предложения (ТЗ §9.3).
|
||
|
||
#950 (Site Finder v2 / GG-форсайт, EPIC 6 «3-layer склад предложения») — Step 6,
|
||
финальный compute-слой. Это **измерительный слой**: детерминированный композит,
|
||
который оценивает, насколько грядущее предложение (скрытое + будущее) будет
|
||
конкурировать с нашим перспективным объектом в районе — ОТНОСИТЕЛЬНО того, как
|
||
быстро рынок поглощает сток. Считается НА ЛЕТУ (НЕ хранится в БД — см. м.125
|
||
docstring: «Future-supply-pressure считается на лету (#950 Step6), тут НЕ хранится»).
|
||
|
||
Принцип: **детерминированно, без LLM** — чистый set-based SQL + арифметика
|
||
(зеркало `market_metrics.py` / `supply_layers.py`). NO LLM-импортов нигде.
|
||
|
||
──────────────────────────────────────────────────────────────────────────────
|
||
Источники:
|
||
• Предложение по слоям — VIEW `v_supply_layers_latest` (м.125), НЕ базовая таблица
|
||
и НЕ пересчёт через supply_layers.compute_all_layers. ПОЧЕМУ именно view:
|
||
Layer3 включает строки `graddoc_stub` (#956) и `insider_manual`, которые
|
||
живут ТОЛЬКО в таблице (их нельзя вывести запросом → compute_all_layers их
|
||
НЕ вернёт). Читая view, мы честно учитываем эти невыводимые future-строки.
|
||
View отдаёт свежий снапшот на логический ключ (max snapshot_date, детерм.
|
||
tiebreak) — нам не нужно самим дедупить снапшоты.
|
||
• Поглощение рынка — reuse `market_metrics.compute_market_metrics(db, district=…)`:
|
||
берём `absorption_rate` (ед./мес ÷ доступные) и `months_of_supply` (доступные ÷
|
||
ед./мес) и ВЫВОДИМ месячное поглощение в штуках. НЕ пересчитываем absorption
|
||
с нуля — единый источник истины по скорости рынка (ТЗ §9.2).
|
||
|
||
Композит (ТЗ §9.3):
|
||
months_of_pressure = (hidden_units + future_units_by_horizon) / monthly_absorption
|
||
— сколько МЕСЯЦЕВ дополнительного конкурирующего предложения в очереди при
|
||
текущем темпе поглощения. Интерпретируемая «сырая» величина.
|
||
index ∈ [0,1] = насыщающее преобразование months_of_pressure (см. _saturating_index)
|
||
— выше = больше будущего давления.
|
||
|
||
Graceful-on-thin-data (КРИТИЧНО, зеркало market_metrics / supply_layers): пустой
|
||
район / нет поглощения / нет supply-данных → index=None, confidence='low', НИКОГДА
|
||
не crash и НИКОГДА деления на ноль. Детерминированно (без рандома).
|
||
|
||
ПРИМЕЧАНИЕ по prod-состоянию: пока worker #950 Step5 не наполнил `supply_layers`
|
||
(на prod таблица пуста до первого запуска по понедельникам 06:00), view вернёт 0
|
||
строк → open/hidden/future = 0 → months_of_pressure=None → index=None,
|
||
confidence='low'. Это валидный graceful-результат, не ошибка.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from collections.abc import Mapping, Sequence
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from typing import Any, Literal
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.services.forecast_request_cache import cached
|
||
from app.services.site_finder.district_resolver import resolve_objective_districts
|
||
from app.services.site_finder.market_metrics import compute_market_metrics
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _future_supply_key(
|
||
db: Session,
|
||
*,
|
||
district: str | None,
|
||
horizon_months: int = 12,
|
||
premise_kind: str = "квартира",
|
||
) -> tuple[Any, ...]:
|
||
"""Ключ кэша §22-форсайта для compute_future_supply_pressure (#1129).
|
||
|
||
ГОРИЗОНТ-ЗАВИСИМ: `_horizon_weight` взвешивает L3 по horizon_months → горизонт
|
||
обязан быть в ключе (иначе все горизонты схлопнулись бы в один — это ИЗМЕНИЛО бы
|
||
результат). + district + premise_kind. `db` не в ключе.
|
||
"""
|
||
return (district, horizon_months, premise_kind)
|
||
|
||
Confidence = Literal["high", "medium", "low"]
|
||
|
||
# Слои склада предложения (м.125): 1=open (в продаже), 2=hidden (запас ПД),
|
||
# 3=future (ещё не на рынке). Давление формируют скрытый + будущий слои.
|
||
_LAYER_OPEN: int = 1
|
||
_LAYER_HIDDEN: int = 2
|
||
_LAYER_FUTURE: int = 3
|
||
|
||
# Насыщение индекса: months_of_pressure, при котором индекс достигает 1.0.
|
||
# 18 мес = 1.5× «здорового» цикла поглощения ЕКБ (market_metrics._OVERSTOCK_MONTHS_
|
||
# THRESHOLD = 12): полтора цикла очереди будущего предложения сверх текущего стока —
|
||
# это уже полное насыщение давления. Выбран линейный clamp (а НЕ 1-exp): он
|
||
# интерпретируем (index=0.5 ⇔ ровно 9 мес очереди), тривиально монотонен и зеркалит
|
||
# clamp-дисциплину competitors._geo_proximity / _stage_at_horizon. Tunable.
|
||
_PRESSURE_SATURATION_MONTHS: float = 18.0
|
||
|
||
# Вес L3-строки с НЕИЗВЕСТНОЙ датой выхода (expected_online_date IS NULL). Тайминг
|
||
# неизвестен → не можем сказать, попадёт ли объём в горизонт. Нейтральные 0.5 (как
|
||
# competitors._NEUTRAL): не игнорируем объём целиком (это занизило бы давление) и не
|
||
# засчитываем его полностью (это завысило бы). Зеркало graceful-нейтрали ТЗ §15.
|
||
_NULL_HORIZON_WEIGHT: float = 0.5
|
||
|
||
# Порядок уверенности для MIN-агрегации компонентных confidence (хуже = ниже).
|
||
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
|
||
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FutureSupplyPressure:
|
||
"""Индекс будущего давления предложения ТЗ §9.3 для района (считается на лету).
|
||
|
||
Все величины детерминированы. `months_of_pressure` и `index` = None при недостатке
|
||
данных (никогда 0-как-заглушка и никогда исключение). `index` ∈ [0,1] когда задан:
|
||
выше = больше грядущего конкурирующего предложения относительно скорости рынка.
|
||
"""
|
||
|
||
# ── Контекст ──────────────────────────────────────────────────────────────
|
||
district: str | None
|
||
horizon_months: int
|
||
premise_kind: str
|
||
confidence: Confidence # MIN компонентных confidence (тонкий L2/L3 честно тянет вниз)
|
||
|
||
# ── Сырые входы (объёмы предложения по слоям) ─────────────────────────────
|
||
open_units: int # Σ layer1 (в продаже) — контекст, в давление НЕ входит
|
||
hidden_units: int # Σ layer2 (скрытый запас ПД)
|
||
future_units_by_horizon: float # Σ layer3, взвешенный по попаданию в горизонт
|
||
|
||
# ── Поглощение (из market_metrics) ────────────────────────────────────────
|
||
monthly_absorption_units: float | None # ед./мес, поглощаемых рынком
|
||
|
||
# ── Композит ──────────────────────────────────────────────────────────────
|
||
months_of_pressure: float | None # (hidden + future) / поглощение, мес
|
||
index: float | None # насыщающее преобразование → [0,1]
|
||
|
||
def as_dict(self) -> dict[str, Any]:
|
||
return {
|
||
"district": self.district,
|
||
"horizon_months": self.horizon_months,
|
||
"premise_kind": self.premise_kind,
|
||
"confidence": self.confidence,
|
||
"index": _round_or_none(self.index, 3),
|
||
"breakdown": self.breakdown(),
|
||
}
|
||
|
||
def breakdown(self) -> dict[str, Any]:
|
||
"""Explainability-разбивка (ТЗ §16) — зеркало competitors.relevance_breakdown.
|
||
|
||
Все составляющие округлены. Показывает, ИЗ ЧЕГО собран index: объёмы по
|
||
слоям, месячное поглощение, сырые месяцы давления и нормированный индекс.
|
||
"""
|
||
return {
|
||
"open_units": self.open_units,
|
||
"hidden_units": self.hidden_units,
|
||
"future_units_by_horizon": _round_or_none(self.future_units_by_horizon, 1),
|
||
"monthly_absorption_units": _round_or_none(self.monthly_absorption_units, 2),
|
||
"months_of_pressure": _round_or_none(self.months_of_pressure, 1),
|
||
"index": _round_or_none(self.index, 3),
|
||
}
|
||
|
||
|
||
def _round_or_none(value: float | None, digits: int) -> float | None:
|
||
return round(value, digits) if value is not None else None
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Pure-арифметика — без БД, полностью юнит-тестируемо.
|
||
# Каждая функция graceful: пустой/нулевой вход → None/нейтраль (не 0, не ZeroDivision).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _horizon_weight(
|
||
expected_online_date: date | None,
|
||
today: date,
|
||
horizon_months: int,
|
||
) -> float:
|
||
"""Вес L3-объёма по попаданию в горизонт ∈ {0.0, _NULL_HORIZON_WEIGHT, 1.0}.
|
||
|
||
Инверсия идеи competitors._stage_at_horizon: там — сколько ОСТАНЕТСЯ к горизонту;
|
||
здесь — сколько будущего предложения ВЫЙДЕТ онлайн в пределах горизонта и потому
|
||
будет конкурировать.
|
||
• дата ≤ today + horizon → 1.0 (выйдет в горизонте — полностью давит)
|
||
• дата > today + horizon → 0.0 (выйдет позже — в этом горизонте не давит)
|
||
• дата IS NULL → _NULL_HORIZON_WEIGHT (тайминг неизвестен → нейтраль)
|
||
horizon_months ≤ 0 трактуем как «нет окна» → только NULL-нейтраль/будущее=0.
|
||
|
||
Реализовано на относительных месяцах (а не date-арифметике календаря) —
|
||
детерминированно и тривиально тестируемо: считаем целые месяцы между today и
|
||
датой; > horizon_months → вне окна.
|
||
"""
|
||
if expected_online_date is None:
|
||
return _NULL_HORIZON_WEIGHT
|
||
months_until = _months_between(today, expected_online_date)
|
||
# Уже онлайн (дата в прошлом) или в пределах горизонта → давит полностью.
|
||
if months_until <= horizon_months:
|
||
return 1.0
|
||
return 0.0
|
||
|
||
|
||
def _months_between(start: date, end: date) -> int:
|
||
"""Целое число месяцев от start до end (может быть отрицательным, если end < start).
|
||
|
||
Календарные месяцы (год*12 + месяц), без учёта дня — достаточно для horizon-bucket
|
||
(«этот объём выйдет в пределах N месяцев?»). Детерминированно.
|
||
"""
|
||
return (end.year - start.year) * 12 + (end.month - start.month)
|
||
|
||
|
||
def _months_of_pressure(
|
||
hidden_units: float | None,
|
||
future_units_by_horizon: float | None,
|
||
monthly_absorption_units: float | None,
|
||
) -> float | None:
|
||
"""months_of_pressure = (hidden + future) / месячное поглощение.
|
||
|
||
Сколько месяцев дополнительного конкурирующего предложения стоит в очереди при
|
||
текущем темпе поглощения рынка. Guard: нет поглощения (None/≤0) → None (давление
|
||
неизмеримо, НЕ ∞ и НЕ 0 — иначе «нет данных о рынке» не отличить от «нулевого
|
||
давления»). Любой None-объём трактуем как 0 в сумме (отсутствие слоя ≠ нет данных
|
||
о рынке): если ОБА слоя пусты — сумма 0 → давление 0.0 (валидный «нет очереди»).
|
||
"""
|
||
if monthly_absorption_units is None or monthly_absorption_units <= 0:
|
||
return None
|
||
queued = (hidden_units or 0.0) + (future_units_by_horizon or 0.0)
|
||
return float(queued) / float(monthly_absorption_units)
|
||
|
||
|
||
def _saturating_index(months_of_pressure: float | None) -> float | None:
|
||
"""Насыщающее преобразование months_of_pressure → index ∈ [0,1].
|
||
|
||
Линейный clamp: min(1.0, months / _PRESSURE_SATURATION_MONTHS). Монотонно
|
||
неубывающее по months_of_pressure, насыщается в 1.0 на _PRESSURE_SATURATION_MONTHS,
|
||
clamp снизу в 0.0 (на случай отрицательных артефактов). None на входе → None.
|
||
Детерминированно.
|
||
"""
|
||
if months_of_pressure is None:
|
||
return None
|
||
if _PRESSURE_SATURATION_MONTHS <= 0:
|
||
# Деградация без падения: нулевой масштаб → любое положительное давление = max.
|
||
return 1.0 if months_of_pressure > 0 else 0.0
|
||
return max(0.0, min(1.0, months_of_pressure / _PRESSURE_SATURATION_MONTHS))
|
||
|
||
|
||
def _monthly_absorption_units(
|
||
absorption_rate: float | None,
|
||
months_of_supply: float | None,
|
||
n_available: int | None,
|
||
) -> float | None:
|
||
"""Месячное поглощение в ШТУКАХ из метрик market_metrics (без пересчёта с нуля).
|
||
|
||
Два эквивалентных пути (берём первый доступный, оба из ТЗ §9.2):
|
||
• available / months_of_supply (months_of_supply = available ÷ ед./мес)
|
||
• absorption_rate * available (absorption_rate = ед./мес ÷ available)
|
||
Предпочитаем months_of_supply (прямое деление, меньше накопленной ошибки округл.).
|
||
Нет доступного стока или метрик → None (поглощение неизмеримо, НЕ 0). Guard всех
|
||
делений на ноль/None.
|
||
"""
|
||
if not n_available or n_available <= 0:
|
||
return None
|
||
available = float(n_available)
|
||
if months_of_supply is not None and months_of_supply > 0:
|
||
return available / months_of_supply
|
||
if absorption_rate is not None and absorption_rate > 0:
|
||
return absorption_rate * available
|
||
return None
|
||
|
||
|
||
def _min_confidence(values: Sequence[Confidence | None]) -> Confidence:
|
||
"""Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab.
|
||
|
||
Тонкий L3/L2 или low-поглощение честно роняют общий confidence в 'low'. None в
|
||
списке игнорируем (компонент не дал сигнала). Пустой/весь-None вход → 'low'
|
||
(нет ни одного надёжного сигнала). Только whitelisted 'high|medium|low'.
|
||
"""
|
||
ranks = [_CONFIDENCE_RANK[v] for v in values if v is not None]
|
||
if not ranks:
|
||
return "low"
|
||
return _RANK_TO_CONFIDENCE[min(ranks)]
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# SQL — читаем VIEW v_supply_layers_latest (м.125), все bind-параметры через
|
||
# CAST(:x AS type) (psycopg v3; НИКОГДА :x::type).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
# Все свежие строки склада предложения для района из VIEW (НЕ базовой таблицы):
|
||
# view уже отдаёт по одной строке на логический ключ (свежий снапшот) → дедуп не наш.
|
||
# КРИТИЧНО читать view, а не supply_layers.compute_all_layers: L3 graddoc_stub (#956)
|
||
# / insider_manual живут только в таблице и невыводимы пересчётом. Берём построчно
|
||
# (layer / units / expected_online_date / confidence) — взвешивание L3 по горизонту
|
||
# делаем в Python (per-row дата vs today+horizon), агрегацию L1/L2 — тоже в Python.
|
||
#
|
||
# District-фильтр — по НАБОРУ имён (:names), не по скаляру: view МИКС-вокабулярный.
|
||
# Layer1 (open) строки ключуются неформальным МИКРО-районом (objective_lots.district,
|
||
# напр. «Втузгородок»/«ЖБИ»), а Layer2/3 (hidden/future) — АДМИН-районом (domrf
|
||
# district_name, напр. «Кировский»). Скалярный фильтр по админ-имени промахивался бы
|
||
# мимо ВСЕХ L1 микро-строк → open=0 → ложный дефицит. Резолвер разворачивает админ →
|
||
# микро, а сам админ добавляем в набор для L2/3 (см. compute_future_supply_pressure).
|
||
# Bug: парцель 66:41:0702048:27 (Кировский) давал projected_supply=0 → ложный BUY.
|
||
# psycopg v3: ANY(CAST(:names AS text[])) — массивный bind, НИКОГДА :x::type.
|
||
_SUPPLY_LAYERS_SQL = text(
|
||
"""
|
||
SELECT
|
||
layer,
|
||
units_estimate,
|
||
expected_online_date,
|
||
confidence
|
||
FROM v_supply_layers_latest
|
||
WHERE (
|
||
CAST(:has_district AS boolean) IS FALSE
|
||
OR district_name = ANY(CAST(:names AS text[]))
|
||
)
|
||
"""
|
||
)
|
||
|
||
|
||
@cached(_future_supply_key, label="compute_future_supply_pressure")
|
||
def compute_future_supply_pressure(
|
||
db: Session,
|
||
*,
|
||
district: str | None,
|
||
horizon_months: int = 12,
|
||
premise_kind: str = "квартира",
|
||
) -> FutureSupplyPressure:
|
||
"""Вычислить индекс будущего давления предложения ТЗ §9.3 для района (на лету).
|
||
|
||
Шаги:
|
||
0. Резолвить district (админ-имя ЕКБ) → набор имён для МИКС-вокабулярного view:
|
||
resolve_objective_districts даёт МИКРО-районы (ключ L1-строк), к ним добавляем
|
||
сам админ (ключ L2/3-строк). None → EKB-wide (без фильтра).
|
||
1. Прочитать ВСЕ свежие supply-строки района из VIEW `v_supply_layers_latest`
|
||
(включает невыводимые L3 graddoc_stub/insider_manual).
|
||
2. Агрегировать: open=Σ L1, hidden=Σ L2, future=Σ L3·горизонт-вес (NULL-дата →
|
||
нейтраль 0.5). Собрать компонентные confidence.
|
||
3. Поглощение — reuse compute_market_metrics(db, district=…): вывести месячное
|
||
поглощение в штуках из months_of_supply / absorption_rate (НЕ с нуля).
|
||
4. months_of_pressure = (hidden + future) / поглощение → насыщающий index [0,1].
|
||
5. confidence = MIN(компонентные supply-confidence + market_metrics.confidence).
|
||
|
||
Возвращает FutureSupplyPressure ВСЕГДА (даже на пустых данных): тогда index=None,
|
||
months_of_pressure=None, confidence='low'. Никогда не бросает, никогда деления на 0.
|
||
"""
|
||
# ── 0. District → набор имён для МИКС-вокабулярного view ───────────────────
|
||
# view ключует L1-строки МИКРО-районом (objective_lots.district), а L2/3 —
|
||
# АДМИН-районом (domrf district_name). resolve_objective_districts разворачивает
|
||
# админ-имя в чистые микро (для L1); сам админ добавляем в набор (для L2/3). Для
|
||
# raw-микро входа резолвер вернёт [micro] → names=[micro] (legacy сохранён). None
|
||
# (вне 8 полигонов / 'не определён') → has_district=False → EKB-wide без фильтра.
|
||
# micros is None ⇔ EKB-wide (вход None/'не определён' ИЛИ админ без чистых алиасов);
|
||
# зеркало market_metrics has_district. Иначе micros — непустой набор микро-районов.
|
||
micros = resolve_objective_districts(db, district)
|
||
names: list[str] = []
|
||
if micros is not None and district is not None:
|
||
# dedup сохраняя порядок: микро (L1) + сам админ-район (L2/3). Инлайн-проверка
|
||
# district is not None (а не через has_district-флаг) сужает тип для mypy —
|
||
# resolver вернул бы None на None-входе, так что эта ветка ⇔ has_district.
|
||
names = list(dict.fromkeys([*micros, district]))
|
||
has_district = bool(names)
|
||
|
||
# ── 1. Supply-строки района из VIEW (graceful: сбой/пусто → []) ────────────
|
||
rows = _query_supply_rows(
|
||
db, {"has_district": has_district, "names": names, "district": district}
|
||
)
|
||
|
||
# ── 2. Агрегация по слоям + сбор компонентных confidence ──────────────────
|
||
today = date.today()
|
||
open_units = 0
|
||
hidden_units = 0
|
||
future_units_by_horizon = 0.0
|
||
supply_confidences: list[Confidence | None] = []
|
||
for r in rows:
|
||
layer = int(r["layer"]) if r["layer"] is not None else 0
|
||
units = int(r["units_estimate"]) if r["units_estimate"] is not None else 0
|
||
conf = _coerce_confidence(r["confidence"])
|
||
if layer == _LAYER_OPEN:
|
||
open_units += units
|
||
supply_confidences.append(conf)
|
||
elif layer == _LAYER_HIDDEN:
|
||
hidden_units += units
|
||
supply_confidences.append(conf)
|
||
elif layer == _LAYER_FUTURE:
|
||
weight = _horizon_weight(r["expected_online_date"], today, horizon_months)
|
||
future_units_by_horizon += units * weight
|
||
supply_confidences.append(conf)
|
||
|
||
# ── 3. Поглощение — reuse market_metrics (единый источник скорости рынка) ──
|
||
metrics = compute_market_metrics(
|
||
db, district=district, window_months=6, premise_kind=premise_kind
|
||
)
|
||
monthly_absorption = _monthly_absorption_units(
|
||
metrics.absorption_rate, metrics.months_of_supply, metrics.n_available
|
||
)
|
||
|
||
# ── 4. Композит: months_of_pressure → насыщающий index ────────────────────
|
||
mop = _months_of_pressure(hidden_units, future_units_by_horizon, monthly_absorption)
|
||
index = _saturating_index(mop)
|
||
|
||
# ── 5. confidence = MIN(supply-компоненты + market_metrics.confidence) ─────
|
||
confidence = _min_confidence([*supply_confidences, metrics.confidence])
|
||
|
||
logger.info(
|
||
"future_supply: district=%s horizon=%d open=%d hidden=%d future_w=%.1f "
|
||
"absorb/mo=%s months_of_pressure=%s index=%s confidence=%s",
|
||
district,
|
||
horizon_months,
|
||
open_units,
|
||
hidden_units,
|
||
future_units_by_horizon,
|
||
_round_or_none(monthly_absorption, 2),
|
||
_round_or_none(mop, 1),
|
||
_round_or_none(index, 3),
|
||
confidence,
|
||
)
|
||
|
||
return FutureSupplyPressure(
|
||
district=district,
|
||
horizon_months=horizon_months,
|
||
premise_kind=premise_kind,
|
||
confidence=confidence,
|
||
open_units=open_units,
|
||
hidden_units=hidden_units,
|
||
future_units_by_horizon=future_units_by_horizon,
|
||
monthly_absorption_units=monthly_absorption,
|
||
months_of_pressure=mop,
|
||
index=index,
|
||
)
|
||
|
||
|
||
def _coerce_confidence(value: Any) -> Confidence | None:
|
||
"""Привести значение confidence из строки view к whitelisted vocab или None.
|
||
|
||
View-данные проходят CHECK м.125 (high|medium|low), но защищаемся от мусора/None
|
||
дёшево: неизвестное → None (компонент не учитывается в MIN, не роняет искусственно).
|
||
"""
|
||
if value in _CONFIDENCE_RANK:
|
||
return value # type: ignore[return-value] # сужено проверкой членства
|
||
return None
|
||
|
||
|
||
def _query_supply_rows(db: Session, params: Mapping[str, Any]) -> Sequence[Mapping[str, Any]]:
|
||
"""Прочитать supply-строки района из VIEW. Graceful: сбой/пусто → [] (не бросаем).
|
||
|
||
Зеркало market_metrics._query_* / supply_layers._safe_rows — пустой склад
|
||
(worker ещё не наполнил м.125) или сбой БД не должны валить расчёт индекса.
|
||
"""
|
||
try:
|
||
return db.execute(_SUPPLY_LAYERS_SQL, dict(params)).mappings().all()
|
||
except Exception:
|
||
logger.exception(
|
||
"future_supply: supply-layers view query failed (district=%s)",
|
||
params.get("district"),
|
||
)
|
||
return []
|