fix(best-layouts): покрытие §4.2 в комплексах, не в сырых obj_id (#2177, шаг 1/3) #2198
9 changed files with 607 additions and 34 deletions
|
|
@ -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"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(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)
|
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",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue