fix(best-layouts): покрытие §4.2 в комплексах, не в сырых obj_id (#2177, шаг 1/3) #2198

Merged
bot-backend merged 2 commits from fix/best-layouts-coverage-2177 into main 2026-07-02 19:34:12 +00:00
9 changed files with 607 additions and 34 deletions

View file

@ -535,10 +535,19 @@ class LayoutTzRecommendation(BaseModel):
class LayoutDataQuality(BaseModel):
"""Метаданные качества данных (coverage)."""
"""Метаданные качества данных (coverage).
objects_with_velocity_data: int
objects_total_in_radius: int
Issue #2177: coverage считается в ГРУППАХ-КОМПЛЕКСАХ, а не в сырых obj_id.
domrf_kn_objects дробит один ЖК на несколько obj_id (разные очереди /
дубли snapshot-строк / гео-кучки безымянных корпусов), из-за чего сырой
знаменатель завышал число «конкурентов» (прод-кейс: 12 obj_id = 5 реальных
комплексов «2 из 12» вместо честных «2 из 5»). Поля objects_* теперь
считают комплексы; raw_objects_total старый счёт obj_id для прозрачности.
"""
objects_with_velocity_data: int # число комплексов с velocity-данными
objects_total_in_radius: int # число комплексов (сгруппированных obj_id)
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
velocity_coverage_pct: float
confidence: Literal["high", "medium", "low"]

View file

@ -137,7 +137,7 @@ def render_layout_tz_pdf(
<h2>Качество данных</h2>
<p>
Покрытие: {dq.objects_with_velocity_data} из
{dq.objects_total_in_radius} ЖК с данными velocity
{dq.objects_total_in_radius} комплексов с данными velocity
({dq.velocity_coverage_pct:.1f}%)
</p>
<p>

View file

@ -16,16 +16,24 @@
Step 6: per-row signature + sold_pct.
Step 7: фильтр min_velocity + sort + rank.
Step 8: build recommendation_for_tz (unit-mix, price, rationale).
Step 9: data_quality (coverage + confidence).
Step 9: data_quality (coverage + confidence) в ГРУППАХ-КОМПЛЕКСАХ (#2177).
Fix SF-01: раньше mv_layout_velocity (24 мес) делился на divisor (4/12) данные
не менялись при смене time_window. Теперь inline SQL с реальным фильтром report_month.
Fix #2177: coverage-знаменатель = число КОМПЛЕКСОВ, а не сырых obj_id.
domrf_kn_objects дробит один ЖК на несколько obj_id (очереди / дубли snapshot-строк /
безымянные корпуса), из-за чего «2 из 12 ЖК» завышало знаменатель. group_radius_objects
схлопывает obj_id в комплексы честное «2 из 5 комплексов».
"""
from __future__ import annotations
import datetime as dt
import logging
import math
import re
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import text
@ -86,7 +94,8 @@ _PARCEL_CENTROID_SQL = text("""
# filter_competitor_obj_ids: NULL = не фильтровать по списку.
_COMPETITORS_IN_RADIUS_SQL = text("""
SELECT DISTINCT ON (obj_id) obj_id
SELECT DISTINCT ON (obj_id)
obj_id, comm_name, dev_name, latitude, longitude
FROM domrf_kn_objects
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
AND ST_DWithin(
@ -433,6 +442,260 @@ def _hamilton_round(work: dict[str, float]) -> dict[str, int]:
return floors
# ── Группировка obj_id в комплексы (Issue #2177) ──────────────────────────────
# domrf_kn_objects дробит один реальный ЖК на несколько obj_id:
# • разные очереди («2 очередь жилого комплекса «7 ключей»»);
# • дубли snapshot-строк одного комплекса (одинаковые comm_name+dev_name, точки
# в ~200 м — «Симфония» 3346/43063/43064);
# • повторы ядра имени внутри строки («Квартет Квартет Квартет» у Эталона);
# • безымянные корпуса (comm_name пустой, гео-кучка в ~10 м — 3 Эфеса 55320-22).
# Сырой счёт obj_id завышает знаменатель coverage (прод: 12 obj_id = 5 комплексов).
# group_radius_objects схлопывает их в комплексы для честного «N из M комплексов».
# Радиус single-linkage гео-кластеризации безымянных obj_id (comm_name пустой) с
# одинаковым dev_name. Кучка в пределах NAMELESS_CLUSTER_M — один комплекс; дальше —
# отдельный (честно неизвестный) проект. 3 Эфеса в ~600 м от «7 ключей» → отдельно.
NAMELESS_CLUSTER_M = 300.0
# Generic-префиксы, которые убираем из начала имени перед сравнением ядра.
# «квартал» НЕ входит: в «Квартал Депо» слово — часть имени (без кавычек), не префикс.
_GENERIC_PREFIXES: tuple[str, ...] = (
"жилой комплекс",
"жк",
"мкр",
"микрорайон",
"жилой район",
"жилой квартал",
"клубный дом",
"клубный квартал",
"дом",
)
# «N очередь [жилого комплекса] …» / «N-я очередь …» / «корпус N …» и подобные
# порядковые обёртки — вырезаем, оставляя ядро имени.
_STAGE_PREFIX_RE = re.compile(
r"^\s*\d+\s*(?:-?\s*(?:я|й|ая|ой|го))?\s*"
r"(?:очередь|очереди|этап|этапа|корпус|корпуса|секция|секции|"
r"дом|блок)\b",
re.IGNORECASE,
)
# «жилого комплекса» / «жилой комплекс» в родительном/именительном — вырезаем
# как соединительную обёртку внутри «… очередь ЖИЛОГО КОМПЛЕКСА «ядро»».
_JK_WORD_RE = re.compile(
r"\bжил(?:ой|ого|ом|ому|ым)\s+комплекс(?:а|е|у|ом|ы)?\b",
re.IGNORECASE,
)
# Ёлочки-кавычки любого начертания.
_QUOTE_INNER_RE = re.compile(r"[«»\"“”„'`]([^«»\"“”„'`]+)[«»\"“”„'`]")
def _extract_quoted_core(name: str) -> str | None:
"""Вернуть содержимое ёлочек, если оно есть: «7 ключей» → '7 ключей'."""
match = _QUOTE_INNER_RE.search(name)
if match:
core = match.group(1).strip()
if core:
return core
return None
def _dedup_repeated_words(name: str) -> str:
"""Схлопнуть подряд идущие повторы того же токена: 'квартет квартет''квартет'.
Эталоновский «Квартет» приходит как «Квартет Квартет Квартет» (повтор ядра ×3
в одной строке). Убираем соседние дубли, регистр уже нормализован вызывающим.
"""
tokens = name.split()
out: list[str] = []
for tok in tokens:
if not out or out[-1] != tok:
out.append(tok)
return " ".join(out)
def normalize_complex_name(raw: str | None) -> str:
"""Нормализовать comm_name до имени-ядра комплекса.
Шаги:
1. lower + trim + схлопнуть пробелы;
2. если есть ёлочки берём их содержимое как ядро («7 ключей» из
«2 очередь жилого комплекса «7 ключей»»);
3. иначе режем порядковый префикс («N очередь »), слова «жилого
комплекса», generic-префиксы; «квартал» сохраняем (часть имени);
4. схлопываем соседние повторы токенов («Квартет Квартет» «квартет»).
Пустой / None '' (безымянный, пойдёт в гео-кластеризацию).
"""
if not raw:
return ""
name = re.sub(r"\s+", " ", raw.strip().lower())
if not name:
return ""
quoted = _extract_quoted_core(name)
if quoted is not None:
return _dedup_repeated_words(re.sub(r"\s+", " ", quoted).strip())
name = _STAGE_PREFIX_RE.sub("", name).strip()
name = _JK_WORD_RE.sub(" ", name).strip()
# generic-префиксы в начале (после снятия «очереди»); «квартал» намеренно не тут.
changed = True
while changed:
changed = False
for prefix in _GENERIC_PREFIXES:
if name == prefix:
# имя ЦЕЛИКОМ generic (например просто «жк») — оставить как есть,
# иначе получим пустое ядро и склеим разные объекты.
continue
if name.startswith(prefix + " "):
name = name[len(prefix) :].strip()
changed = True
break
name = re.sub(r"\s+", " ", name).strip()
return _dedup_repeated_words(name)
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Расстояние между двумя точками (метры) по формуле haversine."""
r = 6_371_000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlam / 2) ** 2
return 2 * r * math.asin(min(1.0, math.sqrt(a)))
@dataclass
class ComplexGroup:
"""Сгруппированный «комплекс» = один реальный ЖК из N сырых obj_id.
key стабильный идентификатор группы (для дедупа / логов), НЕ user-facing.
obj_ids все obj_id, схлопнутые в этот комплекс.
"""
key: str
obj_ids: list[int] = field(default_factory=list)
dev_name_norm: str = ""
core_name: str = ""
def group_radius_objects(rows: list[dict[str, Any]]) -> list[ComplexGroup]:
"""Схлопнуть сырые obj_id радиуса в комплексы (Issue #2177).
Args:
rows: строки из _COMPETITORS_IN_RADIUS_SQL dict с ключами
obj_id, comm_name, dev_name, latitude, longitude.
Логика:
Именованные (comm_name непустой): ключ = (dev_name_норм, имя-ядро).
Один и тот же комплекс (одинаковое ядро+застройщик) одна группа,
независимо от очереди / дублей snapshot-строк / разброса точек.
Безымянные (comm_name пустой): single-linkage гео-кластеризация по
haversine в пределах NAMELESS_CLUSTER_M с ОДИНАКОВЫМ dev_name_норм.
Каждый кластер = отдельная группа (честно неизвестный проект); НЕ
приклеиваем к именованным группам (3 Эфеса в 600 м от «7 ключей»
остаются отдельным комплексом).
Returns:
Список ComplexGroup, порядок детерминирован (по key).
"""
named: dict[tuple[str, str], ComplexGroup] = {}
nameless: list[dict[str, Any]] = []
for row in rows:
obj_id = int(row["obj_id"])
dev_norm = re.sub(r"\s+", " ", (row.get("dev_name") or "").strip().lower())
core = normalize_complex_name(row.get("comm_name"))
if core:
gkey = (dev_norm, core)
grp = named.get(gkey)
if grp is None:
grp = ComplexGroup(
key=f"named:{dev_norm}|{core}",
dev_name_norm=dev_norm,
core_name=core,
)
named[gkey] = grp
grp.obj_ids.append(obj_id)
else:
nameless.append(
{
"obj_id": obj_id,
"dev_norm": dev_norm,
"lat": row.get("latitude"),
"lon": row.get("longitude"),
}
)
groups: list[ComplexGroup] = list(named.values())
groups.extend(_cluster_nameless(nameless))
for grp in groups:
grp.obj_ids.sort()
groups.sort(key=lambda g: g.key)
return groups
def _cluster_nameless(items: list[dict[str, Any]]) -> list[ComplexGroup]:
"""Single-linkage гео-кластеризация безымянных obj_id в пределах NAMELESS_CLUSTER_M.
Кластеризуем ТОЛЬКО точки с одинаковым dev_norm (разные застройщики заведомо
разные проекты). Точек мало (единицы), поэтому наивный O() union-по-соседям
достаточен. obj_id без координат каждый в свою группу (не с чем сравнивать).
"""
groups: list[ComplexGroup] = []
# Партиционируем по застройщику, чтобы не сливать разных девелоперов.
by_dev: dict[str, list[dict[str, Any]]] = {}
for it in items:
by_dev.setdefault(it["dev_norm"], []).append(it)
for dev_norm, pts in by_dev.items():
# union-find по соседству ≤ NAMELESS_CLUSTER_M
parent = list(range(len(pts)))
def _find(i: int, parent: list[int] = parent) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
for i in range(len(pts)):
lat_i, lon_i = pts[i]["lat"], pts[i]["lon"]
if lat_i is None or lon_i is None:
continue
for j in range(i + 1, len(pts)):
lat_j, lon_j = pts[j]["lat"], pts[j]["lon"]
if lat_j is None or lon_j is None:
continue
if (
_haversine_m(float(lat_i), float(lon_i), float(lat_j), float(lon_j))
<= NAMELESS_CLUSTER_M
):
parent[_find(i)] = _find(j)
clusters: dict[int, list[int]] = {}
for idx, pt in enumerate(pts):
root = _find(idx)
clusters.setdefault(root, []).append(int(pt["obj_id"]))
for obj_ids in clusters.values():
anchor = min(obj_ids)
groups.append(
ComplexGroup(
key=f"nameless:{dev_norm}|{anchor}",
obj_ids=obj_ids,
dev_name_norm=dev_norm,
core_name="",
)
)
return groups
# ── Главная функция ───────────────────────────────────────────────────────────
@ -493,23 +756,42 @@ def get_best_layouts(
logger.exception("best_layouts: competitors-in-radius query failed for cad_num=%s", cad_num)
raise
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_rows]
objects_total_in_radius = len(all_obj_ids)
# Issue #2177: сохраняем полные строки (comm_name/dev_name/гео) для группировки
# obj_id в комплексы. Сырой obj_id-счёт (raw_objects_total) — для прозрачности.
id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows]
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts]
raw_objects_total_in_radius = len(all_obj_ids)
# Число комплексов ДО exclude/filter — знаменатель для empty-ответа (совместимо
# со старой семантикой: пустой ответ отражал полный радиус, а не post-filter 0).
groups_total_pre_filter = len(group_radius_objects(id_row_dicts))
# Применить exclude / filter из request
# Применить exclude / filter из request (и к obj_ids, и к строкам для группировки)
exclude_set = set(request.exclude_competitor_obj_ids)
if exclude_set:
all_obj_ids = [oid for oid in all_obj_ids if oid not in exclude_set]
filter_set: set[int] | None = (
set(request.filter_competitor_obj_ids)
if request.filter_competitor_obj_ids is not None
else None
)
if request.filter_competitor_obj_ids is not None:
filter_set = set(request.filter_competitor_obj_ids)
all_obj_ids = [oid for oid in all_obj_ids if oid in filter_set]
def _keep(oid: int) -> bool:
if oid in exclude_set:
return False
if filter_set is not None and oid not in filter_set:
return False
return True
id_row_dicts = [r for r in id_row_dicts if _keep(int(r["obj_id"]))]
all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)]
# Группируем отфильтрованные obj_id в комплексы — знаменатель coverage.
complex_groups = group_radius_objects(id_row_dicts)
if not all_obj_ids:
return _empty_response(
radius_km=request.radius_km,
time_window=request.time_window,
objects_total_in_radius=objects_total_in_radius,
objects_total_in_radius=groups_total_pre_filter,
raw_objects_total=raw_objects_total_in_radius,
)
# ── Step 3: inline velocity из objective_corpus_room_month ──────────────
@ -539,7 +821,8 @@ def get_best_layouts(
return _empty_response(
radius_km=request.radius_km,
time_window=request.time_window,
objects_total_in_radius=objects_total_in_radius,
objects_total_in_radius=len(complex_groups),
raw_objects_total=raw_objects_total_in_radius,
)
# ── Step 5: supply side (батч-запрос) ────────────────────────────────────
@ -691,14 +974,20 @@ def get_best_layouts(
)
# ── Step 9: data_quality ─────────────────────────────────────────────────
# Denominator = post-filter set (effective consideration set после exclude/filter).
objects_total_after_filter = len(all_obj_ids)
objects_with_data = len(obj_ids_with_data & set(all_obj_ids))
# Issue #2177: знаменатель = число КОМПЛЕКСОВ (сгруппированных obj_id), а не
# сырых obj_id. Группа считается «с данными», если ХОТЯ БЫ ОДИН её obj_id есть
# в obj_ids_with_data (velocity-покрытые). Прод-кейс (радиус 1 км): 12 obj_id
# = 5 комплексов, из них 2 с velocity → «40% (2 из 5)» вместо «17% (2 из 12)».
groups_total_after_filter = len(complex_groups)
groups_with_data = sum(
1 for g in complex_groups if any(oid in obj_ids_with_data for oid in g.obj_ids)
)
coverage_pct = (
round(objects_with_data / objects_total_after_filter * 100.0, 1)
if objects_total_after_filter > 0
round(groups_with_data / groups_total_after_filter * 100.0, 1)
if groups_total_after_filter > 0
else 0.0
)
# Пороги confidence — от процента (не трогаем).
if coverage_pct >= LAYOUT_CONFIDENCE_HIGH_PCT:
confidence: str = "high"
elif coverage_pct >= LAYOUT_CONFIDENCE_MEDIUM_PCT:
@ -707,8 +996,9 @@ def get_best_layouts(
confidence = "low"
data_quality = LayoutDataQuality(
objects_with_velocity_data=objects_with_data,
objects_total_in_radius=objects_total_after_filter,
objects_with_velocity_data=groups_with_data,
objects_total_in_radius=groups_total_after_filter,
raw_objects_total=raw_objects_total_in_radius,
velocity_coverage_pct=coverage_pct,
confidence=confidence, # type: ignore[arg-type]
)
@ -821,8 +1111,13 @@ def _empty_response(
radius_km: float,
time_window: str,
objects_total_in_radius: int,
raw_objects_total: int = 0,
) -> BestLayoutsResponse:
"""Ответ когда нет конкурентов или нет MV данных."""
"""Ответ когда нет конкурентов или нет MV данных.
objects_total_in_radius число комплексов (сгруппированных obj_id, #2177),
raw_objects_total сырое число obj_id до группировки (прозрачность).
"""
today = dt.date.today()
tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get(
time_window, time_window
@ -844,6 +1139,7 @@ def _empty_response(
data_quality=LayoutDataQuality(
objects_with_velocity_data=0,
objects_total_in_radius=objects_total_in_radius,
raw_objects_total=raw_objects_total,
velocity_coverage_pct=0.0,
confidence="low",
),

View file

@ -37,11 +37,21 @@ def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
return r
def _obj_id_row(obj_id: int) -> MagicMock:
"""Строка obj_id из _COMPETITORS_IN_RADIUS_SQL."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k]
return r
def _obj_id_row(obj_id: int) -> dict[str, object]:
"""Строка из _COMPETITORS_IN_RADIUS_SQL.
Issue #2177: SQL возвращает comm_name/dev_name/гео для группировки obj_id в
комплексы, а get_best_layouts делает dict(r). Возвращаем реальный dict (как
SQLAlchemy RowMapping). Уникальное comm_name на obj_id каждый = своя группа,
поэтому существующие coverage-инварианты этих API-тестов не меняются.
"""
return {
"obj_id": obj_id,
"comm_name": f"obj-{obj_id}",
"dev_name": f"dev-{obj_id}",
"latitude": None,
"longitude": None,
}
def _vel_row(

View file

@ -30,8 +30,11 @@ from app.schemas.parcel import BestLayoutsRequest
from app.services.site_finder.best_layouts import (
_TIME_WINDOW_PARAMS,
MAX_BUCKET_SHARE_PCT,
NAMELESS_CLUSTER_M,
_cap_and_redistribute,
get_best_layouts,
group_radius_objects,
normalize_complex_name,
)
_TODAY = dt.date.today()
@ -47,10 +50,28 @@ def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
return r
def _obj_id_row(obj_id: int) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k]
return r
def _obj_id_row(
obj_id: int,
comm_name: str | None = None,
dev_name: str | None = None,
latitude: float | None = None,
longitude: float | None = None,
) -> dict[str, object]:
"""Строка из _COMPETITORS_IN_RADIUS_SQL.
Issue #2177: SQL теперь возвращает comm_name/dev_name/гео для группировки в
комплексы; get_best_layouts вызывает dict(r), поэтому возвращаем реальный dict
(SQLAlchemy RowMapping тоже dict-конвертируем). По умолчанию каждый obj_id
получает уникальное имя (`obj-<id>`) каждый = своя группа, как раньше по
obj_id (существующие coverage-инварианты остаются в силе).
"""
return {
"obj_id": obj_id,
"comm_name": comm_name if comm_name is not None else f"obj-{obj_id}",
"dev_name": dev_name if dev_name is not None else f"dev-{obj_id}",
"latitude": latitude,
"longitude": longitude,
}
def _vel_row(
@ -594,3 +615,226 @@ def test_cap_skipped_false_for_normal_case() -> None:
mix = resp.recommendation_for_tz.mix
assert all(row.pct <= MAX_BUCKET_SHARE_PCT for row in mix)
assert sum(row.pct for row in mix) == 100
# ── Issue #2177: группировка obj_id в комплексы ──────────────────────────────
# Реальный прод-кейс: радиус 1 км от 66:41:0204016:10 = 12 obj_id, 5 комплексов.
# Координаты сконструированы под дистанционные ограничения из issue:
# • Симфония — 3 точки в ~200 м (name-группа, дистанция не важна);
# • Эфес безымянные — 3 точки в ~6 м друг от друга (< 300 м → один кластер),
# ~600 м от именованного «7 ключей» (> 300 м → отдельная группа).
_SYM1 = (56.850449, 60.600821)
_SYM2 = (56.850449, 60.604107)
_SYM3 = (56.852246, 60.600821)
_KLYUCHI = (56.85, 60.593429)
_EFES1 = (56.85, 60.583572)
_EFES2 = (56.85, 60.583671)
_EFES3 = (56.850045, 60.583621)
def _prod_radius_rows() -> list[dict[str, object]]:
"""12 obj_id прод-кейса (радиус 1 км от 66:41:0204016:10)."""
return [
# Симфония (Желдорипотека) — 3 obj_id, одинаковое comm_name+dev_name
_obj_id_row(3346, "ЖК «Симфония»", "Желдорипотека", *_SYM1),
_obj_id_row(43063, "ЖК «Симфония»", "Желдорипотека", *_SYM2),
_obj_id_row(43064, "ЖК «Симфония»", "Желдорипотека", *_SYM3),
# Квартет (Эталон) — 2 obj_id, повтор «Квартет» ×3 в строке
_obj_id_row(33955, "Квартет Квартет Квартет", "Эталон", 56.851, 60.602),
_obj_id_row(21801, "Квартет Квартет Квартет", "Эталон", 56.8512, 60.6022),
# Квартал Депо (Брусника) — 2 obj_id, «квартал» без кавычек = часть имени
_obj_id_row(63122, "Квартал Депо", "Брусника", 56.8505, 60.5985),
_obj_id_row(63282, "Квартал Депо", "Брусника", 56.8506, 60.5986),
# Траектория (Брусника) — 1 obj_id
_obj_id_row(68027, "ЖК «Траектория»", "Брусника", 56.8495, 60.6015),
# 7 ключей (Эфес) — именованная 2 очередь
_obj_id_row(15731, "2 очередь жилого комплекса «7 ключей»", "Эфес", *_KLYUCHI),
# 3 безымянных Эфеса — гео-кучка ~6 м, ~600 м от «7 ключей»
_obj_id_row(55320, "", "Эфес", *_EFES1),
_obj_id_row(55321, "", "Эфес", *_EFES2),
_obj_id_row(55322, "", "Эфес", *_EFES3),
]
def test_group_radius_objects_prod_case_groups() -> None:
"""Прод-кейс: 12 obj_id → 5 именованных комплексов + 1 безымянный кластер (#2177).
Issue-заголовок «5 реальных комплексов» перечисляет ИМЕНОВАННЫЕ проекты
(Симфония, Квартет, Квартал Депо, Траектория, 7 ключей). 3 безымянных Эфеса
в ~600 м от «7 ключей» issue ЯВНО требует держать ОТДЕЛЬНОЙ группой («честно
неизвестный проект», НЕ приклеивать дальше 300 м). Итог 6 групп; ключевой
инвариант: «7 ключей» и безымянные Эфесы НЕ слиты.
"""
groups = group_radius_objects(_prod_radius_rows())
# состав групп: множество frozenset(obj_ids) должно совпасть
got = {frozenset(g.obj_ids) for g in groups}
expected = {
frozenset({3346, 43063, 43064}), # Симфония (snapshot-дубли схлопнуты)
frozenset({33955, 21801}), # Квартет (повтор имени схлопнут)
frozenset({63122, 63282}), # Квартал Депо («квартал» сохранён в ядре)
frozenset({68027}), # Траектория
frozenset({15731}), # 7 ключей (именованная 2 очередь)
frozenset({55320, 55321, 55322}), # безымянные Эфесы — свой кластер
}
assert len(groups) == 6, f"ожидалось 6 групп, получено {len(groups)}: {got}"
# load-bearing инвариант: 7 ключей и безымянные Эфесы разделены >300 м
assert frozenset({15731}) in got, "7 ключей должна быть отдельной группой"
assert (
frozenset({55320, 55321, 55322}) in got
), "3 безымянных Эфеса — один кластер, отдельный от 7 ключей"
assert got == expected
def test_group_radius_objects_symphony_collapses_snapshot_dupes() -> None:
"""Симфония: 3 obj_id с одинаковым comm_name+dev_name → одна группа."""
rows = [
_obj_id_row(3346, "ЖК «Симфония»", "Желдорипотека", *_SYM1),
_obj_id_row(43063, "ЖК «Симфония»", "Желдорипотека", *_SYM2),
_obj_id_row(43064, "ЖК «Симфония»", "Желдорипотека", *_SYM3),
]
groups = group_radius_objects(rows)
assert len(groups) == 1
assert sorted(groups[0].obj_ids) == [3346, 43063, 43064]
def test_group_nameless_efes_two_clusters_do_not_merge() -> None:
"""Два разнесённых (>300 м) безымянных кластера одного застройщика НЕ сливаются."""
# кластер A ~6 м; кластер B в ~600 м от A; оба Эфес, comm_name пустой
rows = [
_obj_id_row(55320, "", "Эфес", *_EFES1),
_obj_id_row(55321, "", "Эфес", *_EFES2),
_obj_id_row(55322, "", "Эфес", *_EFES3),
# второй кластер — рядом с «7 ключей» (600 м от первого)
_obj_id_row(90001, "", "Эфес", 56.85, 60.593429),
_obj_id_row(90002, "", "Эфес", 56.850009, 60.5934),
]
groups = group_radius_objects(rows)
assert len(groups) == 2, f"ожидалось 2 кластера, получено {len(groups)}"
clusters = {frozenset(g.obj_ids) for g in groups}
assert frozenset({55320, 55321, 55322}) in clusters
assert frozenset({90001, 90002}) in clusters
def test_group_nameless_different_dev_never_merge() -> None:
"""Безымянные точки разных застройщиков в 1 м — всё равно разные группы."""
rows = [
_obj_id_row(1, "", "Эфес", 56.85, 60.60),
_obj_id_row(2, "", "Брусника", 56.850001, 60.60),
]
groups = group_radius_objects(rows)
assert len(groups) == 2
def test_group_nameless_without_coords_each_own_group() -> None:
"""Безымянный obj_id без координат — своя группа (не с чем кластеризовать)."""
rows = [
_obj_id_row(1, "", "Эфес", None, None),
_obj_id_row(2, "", "Эфес", None, None),
]
groups = group_radius_objects(rows)
assert len(groups) == 2
# ── Нормализация имён ─────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"raw, expected",
[
# ёлочки: ядро из «N очередь жилого комплекса «X»»
("2 очередь жилого комплекса «7 ключей»", "7 ключей"),
("1-я очередь ЖК «Символ»", "символ"),
# повторы ядра
("Квартет Квартет Квартет", "квартет"),
("Квартет", "квартет"),
# generic-префиксы
("ЖК «Траектория»", "траектория"),
("ЖК Траектория", "траектория"),
("Жилой комплекс Символ", "символ"),
("микрорайон Академический", "академический"),
# «квартал» без кавычек — часть имени, НЕ префикс
("Квартал Депо", "квартал депо"),
("квартал депо", "квартал депо"),
# пустое / None
("", ""),
(None, ""),
(" ", ""),
# регистр + пробелы
(" Симфония ", "симфония"),
("ЖК «Символ»", "символ"),
],
)
def test_normalize_complex_name(raw: str | None, expected: str) -> None:
assert normalize_complex_name(raw) == expected
def test_normalize_kvartal_depo_stays_intact() -> None:
"""«Квартал Депо» не должен потерять слово «квартал» (regression guard)."""
assert normalize_complex_name("Квартал Депо") == "квартал депо"
# два obj_id «Квартал Депо» → одна группа (ядро совпадает)
rows = [
_obj_id_row(63122, "Квартал Депо", "Брусника", 56.8505, 60.5985),
_obj_id_row(63282, "Квартал Депо", "Брусника", 56.8506, 60.5986),
]
groups = group_radius_objects(rows)
assert len(groups) == 1
assert sorted(groups[0].obj_ids) == [63122, 63282]
# ── Coverage по группам ───────────────────────────────────────────────────────
def test_coverage_counts_groups_not_raw_obj_ids() -> None:
"""Прод-кейс coverage: velocity у 2 групп → «2 из 6 комплексов», raw=12.
Из 6 групп velocity-данные (obj_ids_with_data) есть у Симфонии (obj 3346) и
Квартета (obj 33955). Группа считается покрытой, если ХОТЯ БЫ ОДИН её obj_id
в velocity-выборке. Знаменатель = группы (6), НЕ сырые obj_id (12).
"""
id_rows = _prod_radius_rows()
# velocity только у Симфонии (через obj 3346) и Квартета (через obj 33955)
vel_rows = [
_vel_row("2", deals_window=48.0, obj_ids=[3346]),
_vel_row("1", deals_window=24.0, obj_ids=[33955]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
dq = resp.data_quality
assert dq.raw_objects_total == 12, "сырой счёт obj_id должен быть 12"
assert dq.objects_total_in_radius == 6, "знаменатель = комплексы (6), не obj_id"
assert dq.objects_with_velocity_data == 2, "покрыты Симфония + Квартет"
# 2/6 = 33.3% (не 2/12 = 16.7%)
assert dq.velocity_coverage_pct == pytest.approx(33.3, rel=1e-2)
def test_coverage_group_covered_by_any_member_obj_id() -> None:
"""Группа «с данными», если velocity есть хотя бы у одного её obj_id.
Симфония = {3346, 43063, 43064}; velocity только у 43064 (не у 3346)
группа всё равно покрыта.
"""
id_rows = [
_obj_id_row(3346, "ЖК «Симфония»", "Желдорипотека", *_SYM1),
_obj_id_row(43063, "ЖК «Симфония»", "Желдорипотека", *_SYM2),
_obj_id_row(43064, "ЖК «Симфония»", "Желдорипотека", *_SYM3),
]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[43064])]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
dq = resp.data_quality
assert dq.objects_total_in_radius == 1
assert dq.objects_with_velocity_data == 1
assert dq.raw_objects_total == 3
assert dq.velocity_coverage_pct == pytest.approx(100.0, rel=1e-3)
def test_nameless_cluster_constant_reasonable() -> None:
"""NAMELESS_CLUSTER_M экспортируется и в разумных пределах (guard)."""
assert 100.0 <= NAMELESS_CLUSTER_M <= 500.0

View file

@ -95,6 +95,7 @@ def _sample_response() -> BestLayoutsResponse:
data_quality=LayoutDataQuality(
objects_with_velocity_data=5,
objects_total_in_radius=8,
raw_objects_total=14,
velocity_coverage_pct=62.5,
confidence="medium",
),

View file

@ -109,7 +109,8 @@ function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) {
</span>
<span style={{ fontSize: 12, color: "var(--fg-secondary, #5B6066)" }}>
покрытие {dq.velocity_coverage_pct.toFixed(0)}% (
{dq.objects_with_velocity_data} из {dq.objects_total_in_radius} ЖК)
{dq.objects_with_velocity_data} из {dq.objects_total_in_radius}{" "}
комплексов)
</span>
</div>
);

View file

@ -4214,12 +4214,21 @@ export interface components {
/**
* LayoutDataQuality
* @description Метаданные качества данных (coverage).
*
* Issue #2177: coverage считается в ГРУППАХ-КОМПЛЕКСАХ, а не в сырых obj_id.
* domrf_kn_objects дробит один ЖК на несколько obj_id (разные очереди /
* дубли snapshot-строк / гео-кучки безымянных корпусов), из-за чего сырой
* знаменатель завышал число «конкурентов» (прод-кейс: 12 obj_id = 5 реальных
* комплексов «2 из 12» вместо честных «2 из 5»). Поля objects_* теперь
* считают комплексы; raw_objects_total старый счёт obj_id для прозрачности.
*/
LayoutDataQuality: {
/** Objects With Velocity Data */
objects_with_velocity_data: number;
/** Objects Total In Radius */
objects_total_in_radius: number;
/** Raw Objects Total */
raw_objects_total: number;
/** Velocity Coverage Pct */
velocity_coverage_pct: number;
/**

View file

@ -53,8 +53,11 @@ export interface LayoutTzRecommendation {
}
export interface LayoutDataQuality {
// Issue #2177: objects_* считают комплексы (сгруппированные obj_id), а не сырые
// obj_id. raw_objects_total — сырое число obj_id до группировки (прозрачность).
objects_with_velocity_data: number;
objects_total_in_radius: number;
raw_objects_total: number;
velocity_coverage_pct: number;
confidence: Confidence;
}