diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 7912880c..f63526cb 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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"] diff --git a/backend/app/services/exporters/layout_tz_pdf.py b/backend/app/services/exporters/layout_tz_pdf.py index 116dd647..fa4531d6 100644 --- a/backend/app/services/exporters/layout_tz_pdf.py +++ b/backend/app/services/exporters/layout_tz_pdf.py @@ -137,7 +137,7 @@ def render_layout_tz_pdf(

Качество данных

Покрытие: {dq.objects_with_velocity_data} из - {dq.objects_total_in_radius} ЖК с данными velocity + {dq.objects_total_in_radius} комплексов с данными velocity ({dq.velocity_coverage_pct:.1f}%)

diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index c6da2e1d..edec7322 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -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(n²) 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", ), diff --git a/backend/tests/api/v1/test_parcel_best_layouts.py b/backend/tests/api/v1/test_parcel_best_layouts.py index 478cd332..88836939 100644 --- a/backend/tests/api/v1/test_parcel_best_layouts.py +++ b/backend/tests/api/v1/test_parcel_best_layouts.py @@ -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( diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py index 2174b074..192b0295 100644 --- a/backend/tests/services/site_finder/test_best_layouts.py +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -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-`) → каждый = своя группа, как раньше по + 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 diff --git a/backend/tests/test_layout_tz_pdf.py b/backend/tests/test_layout_tz_pdf.py index b65833f5..fb44abdc 100644 --- a/backend/tests/test_layout_tz_pdf.py +++ b/backend/tests/test_layout_tz_pdf.py @@ -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", ), diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx index a885333c..a20f80a6 100644 --- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -109,7 +109,8 @@ function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) { покрытие {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}{" "} + комплексов) ); diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index c162fcb5..c67860a5 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -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; /** diff --git a/frontend/src/types/best-layouts.ts b/frontend/src/types/best-layouts.ts index c821dd22..f0ad1bdd 100644 --- a/frontend/src/types/best-layouts.ts +++ b/frontend/src/types/best-layouts.ts @@ -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; }