This commit is contained in:
parent
a7ad1764c1
commit
b028377584
9 changed files with 607 additions and 34 deletions
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4251,12 +4251,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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue