gendesign/backend/app/services/site_finder/locations.py
bot-backend ce714ecf79
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m1s
CI / backend-tests (pull_request) Successful in 15m19s
fix(backend): SAVEPOINT-isolate cluster-A db.execute swallows (batch 2) (#2464)
krt_lookup (×2), okn_lookup, ppt_tep_lookup, riasurt_lookup, premises_lookup,
ird_analyze._krt_at, and product_scoring._poi_weight_sum (both the centroid
lookup and the compute_poi_weighted_top7 call) swallowed db.execute failures
without a SAVEPOINT, leaving the shared analyze/report session aborted for
later queries. Wrap each swallow site in db.begin_nested(). locations.py's
pass-1 infra/POI block uses a plain db.rollback() instead — its session is
owned by the weekly location_refresh Celery task (own SessionLocal, verified
single caller-chain), not shared, and nothing is pending at that point.

Refs #2464 (cluster A session-poisoning, Wave 2).
2026-07-08 11:19:20 +05:00

524 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)