ПТИЦА: avg_area_m2 в топ-планировках показывает 0 м² вместо «нет данных» (нужна правка контракта) #2867

Closed
opened 2026-08-13 11:52:58 +00:00 by bot-backend · 1 comment
Collaborator

Что

_INLINE_VELOCITY_SQL в backend/app/services/site_finder/best_layouts.py считает среднюю
площадь как

COALESCE(SUM(a.area_weighted_sum) / NULLIF(SUM(a.deals_window), 0), 0)::numeric(10,2) AS avg_area_m2

Сделок за окно нет → делитель NULL → COALESCE подставляет 0. То есть «средняя площадь
проданной квартиры — 0 м²» вместо «сделок не было, средней нет».

Замер (прод, 13.08, окно 6 месяцев)

Важно: deals_per_bucket группирует по room_bucket после джойна с проектами в радиусе,
поэтому попадёт ноль или нет — зависит от того, сколько замапленных проектов в радиусе участка.
Поэтому цифры по слоям, а не одним числом:

проектов с данными за окно                616
пар (проект × комнатность)               2083
из них пустых (deals_window = 0/NULL)     635

проектов, где пуста хотя бы одна комнатность   323  (52%)
проектов, где пусты ВСЕ комнатности             80  (13%)
в среднем пустых комнатностей на проект       1.03

если объединять проекты по два                 255 пустых из 1267  (20%)
если объединить весь город                       0 пустых из 5

Читается так: чем беднее окрестность участка замапленными проектами, тем чаще выдумывается
ноль; для участка, у которого в радиусе один проект из тех 80, обнулятся все комнатности.

Строк-источников с deals_total_count > 0 и NULL-площадью — 0, то есть гипотеза «все
слагаемые NULL» не подтвердилась; механизм именно в пустом окне продаж.

Насколько видно пользователю

Сегодня в основном не видно, и это надо сказать прямо:

  • фильтр velocity_per_month >= min_velocity_per_month с дефолтом 0.5
    (schemas/parcel.py:584) выбрасывает нулевые группы до выдачи;
  • взвешенный роллап по room_bucket умножает на sum_deals = 0 → вклад нулевой.

Но min_velocity_per_month объявлен ge=0.0 и приходит от клиента. При 0 группы с нулём
сделок попадают в top_layouts с avg_area_m2 = 0.0 и, как следствие,
area_bin = "<25" (ветка area_bin(avg_area) if avg_area > 0 else "<25",
best_layouts.py:1279) — то есть выдумывается ещё и типоразмер.

Почему отдельным issue, а не в PR с ценой

Цену чиню без правки контракта: TopLayoutRow.avg_price_per_m2_rub уже float | None,
и Python-потребитель уже умеет None — COALESCE просто делал эту ветку недостижимой.

С площадью иначе: TopLayoutRow.avg_area_m2: float — не Optional. Честный NULL требует

  • правки pydantic-схемы,
  • перегенерации типов фронта (openapi-codegen-check в CI),
  • решения, что показывать в area_bin, когда площадь неизвестна (сейчас туда молча
    подставляется "<25").

Это уже не однострочник и не должно ехать прицепом.

Что сделать

  1. avg_area_m2: float | None в TopLayoutRow, убрать COALESCE(...,0) в SQL.
  2. area_bin: при неизвестной площади не выдавать "<25" — либо None, либо явный
    маркер «нет данных»; посмотреть, как это ложится на layout_signature.
  3. Проверить фронт: как рисуется строка без площади (карточка топ-планировок).
  4. Заодно решить, надо ли вообще пускать группы с deals_window = 0 в выдачу при
    min_velocity_per_month = 0 — возможно, правильнее отсекать их явно, а не полагаться
    на дефолт фильтра.

Оценка: S-M. Refs #2464 (кластер B, silent caps).

## Что `_INLINE_VELOCITY_SQL` в `backend/app/services/site_finder/best_layouts.py` считает среднюю площадь как ```sql COALESCE(SUM(a.area_weighted_sum) / NULLIF(SUM(a.deals_window), 0), 0)::numeric(10,2) AS avg_area_m2 ``` Сделок за окно нет → делитель NULL → `COALESCE` подставляет **0**. То есть «средняя площадь проданной квартиры — 0 м²» вместо «сделок не было, средней нет». ## Замер (прод, 13.08, окно 6 месяцев) Важно: `deals_per_bucket` группирует по `room_bucket` **после** джойна с проектами в радиусе, поэтому попадёт ноль или нет — зависит от того, сколько замапленных проектов в радиусе участка. Поэтому цифры по слоям, а не одним числом: ``` проектов с данными за окно 616 пар (проект × комнатность) 2083 из них пустых (deals_window = 0/NULL) 635 проектов, где пуста хотя бы одна комнатность 323 (52%) проектов, где пусты ВСЕ комнатности 80 (13%) в среднем пустых комнатностей на проект 1.03 если объединять проекты по два 255 пустых из 1267 (20%) если объединить весь город 0 пустых из 5 ``` Читается так: чем беднее окрестность участка замапленными проектами, тем чаще выдумывается ноль; для участка, у которого в радиусе один проект из тех 80, обнулятся **все** комнатности. Строк-источников с `deals_total_count > 0` и NULL-площадью — **0**, то есть гипотеза «все слагаемые NULL» не подтвердилась; механизм именно в пустом окне продаж. ## Насколько видно пользователю Сегодня в основном не видно, и это надо сказать прямо: - фильтр `velocity_per_month >= min_velocity_per_month` с дефолтом **0.5** (`schemas/parcel.py:584`) выбрасывает нулевые группы до выдачи; - взвешенный роллап по `room_bucket` умножает на `sum_deals` = 0 → вклад нулевой. Но `min_velocity_per_month` объявлен `ge=0.0` и приходит от клиента. При `0` группы с нулём сделок попадают в `top_layouts` с `avg_area_m2 = 0.0` и, как следствие, `area_bin = "<25"` (ветка `area_bin(avg_area) if avg_area > 0 else "<25"`, `best_layouts.py:1279`) — то есть выдумывается ещё и типоразмер. ## Почему отдельным issue, а не в PR с ценой Цену чиню без правки контракта: `TopLayoutRow.avg_price_per_m2_rub` **уже** `float | None`, и Python-потребитель уже умеет None — COALESCE просто делал эту ветку недостижимой. С площадью иначе: `TopLayoutRow.avg_area_m2: float` — не Optional. Честный NULL требует - правки pydantic-схемы, - перегенерации типов фронта (`openapi-codegen-check` в CI), - решения, что показывать в `area_bin`, когда площадь неизвестна (сейчас туда молча подставляется `"<25"`). Это уже не однострочник и не должно ехать прицепом. ## Что сделать 1. `avg_area_m2: float | None` в `TopLayoutRow`, убрать `COALESCE(...,0)` в SQL. 2. `area_bin`: при неизвестной площади не выдавать `"<25"` — либо `None`, либо явный маркер «нет данных»; посмотреть, как это ложится на `layout_signature`. 3. Проверить фронт: как рисуется строка без площади (карточка топ-планировок). 4. Заодно решить, надо ли вообще пускать группы с `deals_window = 0` в выдачу при `min_velocity_per_month = 0` — возможно, правильнее отсекать их явно, а не полагаться на дефолт фильтра. Оценка: S-M. Refs #2464 (кластер B, silent caps).
lekss361 added the
bug
priority/p2
scope/backend
scope/frontend
site-finder
labels 2026-08-16 10:25:28 +00:00
Author
Collaborator

Второе место с тем же корнем: площадь неизвестна → корзина «<25 м²»

Нашёл при разборе пункта эпика #2464 (best_layouts.py:402). Решение о контракте нужно одно на оба места, поэтому кладу сюда, а не завожу отдельно.

_SUPPLY_ONLY_LOTS_SQL раскладывает лоты в продаже по корзинам площади:

CASE
    WHEN area_pd IS NULL     THEN '<25'       отдельной строкой, явно
    WHEN area_pd < 25        THEN '<25'
    WHEN area_pd < 40        THEN '25-40'
    

То есть «площадь неизвестна» кладётся в ту же корзину, что и настоящие студии меньше 25 м². Читатель отчёта видит предложение мелких квартир там, где на деле нет данных о площади.

Замер прода 20.08.2026

objective_lots, premise_kind='квартира':

все снимки последний снимок
лотов 2 859 268 330 394
без area_pd 77 317 (2.7 %) 8 917
настоящих < 25 м² 202 102 23 544

На последнем снимке корзина «<25» состоит из 32 461 лота, из которых 8 917 — 27.5 % — это «площадь неизвестна», а не маленькие квартиры.

Почему это ваша задача, а не моя правка

Схема закрыта литералом:

area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"]

Любой честный вариант меняет контракт:

  1. отдельная корзина "unknown" — правка Literal, регенерация api-types.ts, обработка на фронте;
  2. исключать лоты без площади из блока — тогда сумма по корзинам перестанет сходиться с общим числом лотов, и это надо где-то показать, иначе получится тихая потеря 2.7 %;
  3. оставить как есть — но тогда в коде должно быть написано, что корзина «<25» означает «мелкие ИЛИ неизвестные», и в отчёте тоже.

В самом коде рядом уже стоит указатель на это issue: -- и решения, что писать в area_bin. Отдельным заходом: #2867. Так что маршрут выбран верно, не хватало только величины.

Сам менять контракт не стал: это видимое пользователю число и закрытый литерал в схеме.

## Второе место с тем же корнем: площадь неизвестна → корзина «<25 м²» Нашёл при разборе пункта эпика #2464 (`best_layouts.py:402`). Решение о контракте нужно одно на оба места, поэтому кладу сюда, а не завожу отдельно. `_SUPPLY_ONLY_LOTS_SQL` раскладывает лоты в продаже по корзинам площади: ```sql CASE WHEN area_pd IS NULL THEN '<25' ← отдельной строкой, явно WHEN area_pd < 25 THEN '<25' WHEN area_pd < 40 THEN '25-40' … ``` То есть «площадь неизвестна» кладётся в ту же корзину, что и настоящие студии меньше 25 м². Читатель отчёта видит предложение мелких квартир там, где на деле нет данных о площади. ## Замер прода 20.08.2026 `objective_lots`, `premise_kind='квартира'`: | | все снимки | последний снимок | |---|---|---| | лотов | 2 859 268 | 330 394 | | без `area_pd` | **77 317** (2.7 %) | **8 917** | | настоящих < 25 м² | 202 102 | 23 544 | На последнем снимке корзина «<25» состоит из 32 461 лота, из которых **8 917 — 27.5 % — это «площадь неизвестна»**, а не маленькие квартиры. ## Почему это ваша задача, а не моя правка Схема закрыта литералом: ```python area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"] ``` Любой честный вариант меняет контракт: 1. **отдельная корзина** `"unknown"` — правка `Literal`, регенерация `api-types.ts`, обработка на фронте; 2. **исключать** лоты без площади из блока — тогда сумма по корзинам перестанет сходиться с общим числом лотов, и это надо где-то показать, иначе получится тихая потеря 2.7 %; 3. **оставить как есть** — но тогда в коде должно быть написано, что корзина «<25» означает «мелкие ИЛИ неизвестные», и в отчёте тоже. В самом коде рядом уже стоит указатель на это issue: `-- и решения, что писать в area_bin. Отдельным заходом: #2867`. Так что маршрут выбран верно, не хватало только величины. Сам менять контракт не стал: это видимое пользователю число и закрытый литерал в схеме.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2867
No description provided.