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): class LayoutDataQuality(BaseModel):
"""Метаданные качества данных (coverage).""" """Метаданные качества данных (coverage).
objects_with_velocity_data: int Issue #2177: coverage считается в ГРУППАХ-КОМПЛЕКСАХ, а не в сырых obj_id.
objects_total_in_radius: int 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 velocity_coverage_pct: float
confidence: Literal["high", "medium", "low"] confidence: Literal["high", "medium", "low"]

View file

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

View file

@ -16,16 +16,24 @@
Step 6: per-row signature + sold_pct. Step 6: per-row signature + sold_pct.
Step 7: фильтр min_velocity + sort + rank. Step 7: фильтр min_velocity + sort + rank.
Step 8: build recommendation_for_tz (unit-mix, price, rationale). 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) данные Fix SF-01: раньше mv_layout_velocity (24 мес) делился на divisor (4/12) данные
не менялись при смене time_window. Теперь inline SQL с реальным фильтром report_month. не менялись при смене 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 from __future__ import annotations
import datetime as dt import datetime as dt
import logging import logging
import math
import re
from dataclasses import dataclass, field
from typing import Any from typing import Any
from sqlalchemy import text from sqlalchemy import text
@ -86,7 +94,8 @@ _PARCEL_CENTROID_SQL = text("""
# filter_competitor_obj_ids: NULL = не фильтровать по списку. # filter_competitor_obj_ids: NULL = не фильтровать по списку.
_COMPETITORS_IN_RADIUS_SQL = text(""" _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 FROM domrf_kn_objects
WHERE latitude IS NOT NULL AND longitude IS NOT NULL WHERE latitude IS NOT NULL AND longitude IS NOT NULL
AND ST_DWithin( AND ST_DWithin(
@ -433,6 +442,260 @@ def _hamilton_round(work: dict[str, float]) -> dict[str, int]:
return floors 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) logger.exception("best_layouts: competitors-in-radius query failed for cad_num=%s", cad_num)
raise raise
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_rows] # Issue #2177: сохраняем полные строки (comm_name/dev_name/гео) для группировки
objects_total_in_radius = len(all_obj_ids) # 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) exclude_set = set(request.exclude_competitor_obj_ids)
if exclude_set: filter_set: set[int] | None = (
all_obj_ids = [oid for oid in all_obj_ids if oid not in exclude_set] 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: def _keep(oid: int) -> bool:
filter_set = set(request.filter_competitor_obj_ids) if oid in exclude_set:
all_obj_ids = [oid for oid in all_obj_ids if oid in filter_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: if not all_obj_ids:
return _empty_response( return _empty_response(
radius_km=request.radius_km, radius_km=request.radius_km,
time_window=request.time_window, 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 ────────────── # ── Step 3: inline velocity из objective_corpus_room_month ──────────────
@ -539,7 +821,8 @@ def get_best_layouts(
return _empty_response( return _empty_response(
radius_km=request.radius_km, radius_km=request.radius_km,
time_window=request.time_window, 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 (батч-запрос) ──────────────────────────────────── # ── Step 5: supply side (батч-запрос) ────────────────────────────────────
@ -691,14 +974,20 @@ def get_best_layouts(
) )
# ── Step 9: data_quality ───────────────────────────────────────────────── # ── Step 9: data_quality ─────────────────────────────────────────────────
# Denominator = post-filter set (effective consideration set после exclude/filter). # Issue #2177: знаменатель = число КОМПЛЕКСОВ (сгруппированных obj_id), а не
objects_total_after_filter = len(all_obj_ids) # сырых obj_id. Группа считается «с данными», если ХОТЯ БЫ ОДИН её obj_id есть
objects_with_data = len(obj_ids_with_data & set(all_obj_ids)) # в 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 = ( coverage_pct = (
round(objects_with_data / objects_total_after_filter * 100.0, 1) round(groups_with_data / groups_total_after_filter * 100.0, 1)
if objects_total_after_filter > 0 if groups_total_after_filter > 0
else 0.0 else 0.0
) )
# Пороги confidence — от процента (не трогаем).
if coverage_pct >= LAYOUT_CONFIDENCE_HIGH_PCT: if coverage_pct >= LAYOUT_CONFIDENCE_HIGH_PCT:
confidence: str = "high" confidence: str = "high"
elif coverage_pct >= LAYOUT_CONFIDENCE_MEDIUM_PCT: elif coverage_pct >= LAYOUT_CONFIDENCE_MEDIUM_PCT:
@ -707,8 +996,9 @@ def get_best_layouts(
confidence = "low" confidence = "low"
data_quality = LayoutDataQuality( data_quality = LayoutDataQuality(
objects_with_velocity_data=objects_with_data, objects_with_velocity_data=groups_with_data,
objects_total_in_radius=objects_total_after_filter, objects_total_in_radius=groups_total_after_filter,
raw_objects_total=raw_objects_total_in_radius,
velocity_coverage_pct=coverage_pct, velocity_coverage_pct=coverage_pct,
confidence=confidence, # type: ignore[arg-type] confidence=confidence, # type: ignore[arg-type]
) )
@ -821,8 +1111,13 @@ def _empty_response(
radius_km: float, radius_km: float,
time_window: str, time_window: str,
objects_total_in_radius: int, objects_total_in_radius: int,
raw_objects_total: int = 0,
) -> BestLayoutsResponse: ) -> BestLayoutsResponse:
"""Ответ когда нет конкурентов или нет MV данных.""" """Ответ когда нет конкурентов или нет MV данных.
objects_total_in_radius число комплексов (сгруппированных obj_id, #2177),
raw_objects_total сырое число obj_id до группировки (прозрачность).
"""
today = dt.date.today() today = dt.date.today()
tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get( tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get(
time_window, time_window time_window, time_window
@ -844,6 +1139,7 @@ def _empty_response(
data_quality=LayoutDataQuality( data_quality=LayoutDataQuality(
objects_with_velocity_data=0, objects_with_velocity_data=0,
objects_total_in_radius=objects_total_in_radius, objects_total_in_radius=objects_total_in_radius,
raw_objects_total=raw_objects_total,
velocity_coverage_pct=0.0, velocity_coverage_pct=0.0,
confidence="low", confidence="low",
), ),

View file

@ -37,11 +37,21 @@ def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
return r return r
def _obj_id_row(obj_id: int) -> MagicMock: def _obj_id_row(obj_id: int) -> dict[str, object]:
"""Строка obj_id из _COMPETITORS_IN_RADIUS_SQL.""" """Строка из _COMPETITORS_IN_RADIUS_SQL.
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k] Issue #2177: SQL возвращает comm_name/dev_name/гео для группировки obj_id в
return r комплексы, а 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( def _vel_row(

View file

@ -30,8 +30,11 @@ from app.schemas.parcel import BestLayoutsRequest
from app.services.site_finder.best_layouts import ( from app.services.site_finder.best_layouts import (
_TIME_WINDOW_PARAMS, _TIME_WINDOW_PARAMS,
MAX_BUCKET_SHARE_PCT, MAX_BUCKET_SHARE_PCT,
NAMELESS_CLUSTER_M,
_cap_and_redistribute, _cap_and_redistribute,
get_best_layouts, get_best_layouts,
group_radius_objects,
normalize_complex_name,
) )
_TODAY = dt.date.today() _TODAY = dt.date.today()
@ -47,10 +50,28 @@ def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
return r return r
def _obj_id_row(obj_id: int) -> MagicMock: def _obj_id_row(
r = MagicMock() obj_id: int,
r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k] comm_name: str | None = None,
return r 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( def _vel_row(
@ -594,3 +615,226 @@ def test_cap_skipped_false_for_normal_case() -> None:
mix = resp.recommendation_for_tz.mix mix = resp.recommendation_for_tz.mix
assert all(row.pct <= MAX_BUCKET_SHARE_PCT for row in mix) assert all(row.pct <= MAX_BUCKET_SHARE_PCT for row in mix)
assert sum(row.pct for row in mix) == 100 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( data_quality=LayoutDataQuality(
objects_with_velocity_data=5, objects_with_velocity_data=5,
objects_total_in_radius=8, objects_total_in_radius=8,
raw_objects_total=14,
velocity_coverage_pct=62.5, velocity_coverage_pct=62.5,
confidence="medium", confidence="medium",
), ),

View file

@ -109,7 +109,8 @@ function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) {
</span> </span>
<span style={{ fontSize: 12, color: "var(--fg-secondary, #5B6066)" }}> <span style={{ fontSize: 12, color: "var(--fg-secondary, #5B6066)" }}>
покрытие {dq.velocity_coverage_pct.toFixed(0)}% ( покрытие {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> </span>
</div> </div>
); );

View file

@ -4214,12 +4214,21 @@ export interface components {
/** /**
* LayoutDataQuality * LayoutDataQuality
* @description Метаданные качества данных (coverage). * @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: { LayoutDataQuality: {
/** Objects With Velocity Data */ /** Objects With Velocity Data */
objects_with_velocity_data: number; objects_with_velocity_data: number;
/** Objects Total In Radius */ /** Objects Total In Radius */
objects_total_in_radius: number; objects_total_in_radius: number;
/** Raw Objects Total */
raw_objects_total: number;
/** Velocity Coverage Pct */ /** Velocity Coverage Pct */
velocity_coverage_pct: number; velocity_coverage_pct: number;
/** /**

View file

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