fix(site-finder): normalize POI weighted score to 0..100 on backend (#1486) #1674

Merged
lekss361 merged 2 commits from fix/poi-score-scale-1486 into main 2026-06-17 17:54:53 +00:00
5 changed files with 182 additions and 57 deletions

View file

@ -44,6 +44,21 @@ CATEGORY_WEIGHTS: dict[str, float] = {
"default": 1.0, "default": 1.0,
} }
# ── Нормировочные константы (для вывода score_contribution / poi_weighted_score в 0..100) ──
#
# Теоретический максимум суммы весов top-7 POI при идеальном расположении:
# straight-line: w_i = cat_weight_i / (0+100) → max_sum = Σ(top7 cat_weights) / 100
# routing-decay: w_i = cat_weight_i * decay(0) = cat_weight_i → max_sum = Σ(top7 cat_weights)
#
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
# straight-line mode: каждый POI берётся с множителем 1/(d+100); при d=0 → /100
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
# routing-decay mode: decay ∈ [0,1], при t=0 decay=1.0 → max = cat_weight
_MAX_ROUTING_SCORE: float = _TOP7_WEIGHT_SUM # = 31.5
class PoiScoreItem(BaseModel): class PoiScoreItem(BaseModel):
"""Один POI в ranked-ответе.""" """Один POI в ranked-ответе."""
@ -52,12 +67,17 @@ class PoiScoreItem(BaseModel):
category: str category: str
distance_m: float distance_m: float
weight: float weight: float
# Вклад данного POI в суммарный балл, в процентах от 0..100.
# Позволяет фронтенду рендерить «долю» без знания формулы нормировки.
score_contribution: float
address: str | None address: str | None
class PoiScoreResponse(BaseModel): class PoiScoreResponse(BaseModel):
cad_num: str cad_num: str
radius_m: int radius_m: int
# Суммарный взвешенный балл инфраструктуры, нормированный в диапазон 0..100.
poi_weighted_score: float
top_poi: list[PoiScoreItem] top_poi: list[PoiScoreItem]
@ -155,6 +175,7 @@ def compute_poi_weighted_top7(
category=category, category=category,
distance_m=round(distance_m, 1), distance_m=round(distance_m, 1),
weight=round(weight, 6), weight=round(weight, 6),
score_contribution=0.0, # заполним после нормировки
address=address, address=address,
) )
) )
@ -163,9 +184,18 @@ def compute_poi_weighted_top7(
items.sort(key=lambda x: x.weight, reverse=True) items.sort(key=lambda x: x.weight, reverse=True)
top_items = items[:top_n] top_items = items[:top_n]
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_STRAIGHT_SCORE) * 100, клэмп [0, 100]
raw_sum = sum(i.weight for i in top_items)
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0), 1)
# score_contribution — доля данного POI в общем балле (0..100)
for item in top_items:
item.score_contribution = round((item.weight / _MAX_STRAIGHT_SCORE) * 100.0, 1)
return PoiScoreResponse( return PoiScoreResponse(
cad_num=cad_num, cad_num=cad_num,
radius_m=radius_m, radius_m=radius_m,
poi_weighted_score=poi_weighted_score,
top_poi=top_items, top_poi=top_items,
) )
@ -303,7 +333,9 @@ def compute_poi_routing_decay(
) )
if not rows: if not rows:
return PoiScoreResponse(cad_num=cad_num, radius_m=radius_m, top_poi=[]) return PoiScoreResponse(
cad_num=cad_num, radius_m=radius_m, poi_weighted_score=0.0, top_poi=[]
)
destinations = [(float(r["lon"]), float(r["lat"])) for r in rows] destinations = [(float(r["lon"]), float(r["lat"])) for r in rows]
@ -311,9 +343,7 @@ def compute_poi_routing_decay(
times_min: list[float | None] times_min: list[float | None]
routing_used = False routing_used = False
try: try:
times_min = ors_client.matrix_durations_min( times_min = ors_client.matrix_durations_min(lon, lat, destinations, profile=profile)
lon, lat, destinations, profile=profile
)
routing_used = True routing_used = True
except ors_client.OrsUnavailableError as exc: except ors_client.OrsUnavailableError as exc:
logger.info( logger.info(
@ -355,13 +385,25 @@ def compute_poi_routing_decay(
category=category, category=category,
distance_m=round(distance_m, 1), distance_m=round(distance_m, 1),
weight=round(weight, 6), weight=round(weight, 6),
score_contribution=0.0, # заполним после нормировки
address=address, address=address,
) )
) )
items.sort(key=lambda x: x.weight, reverse=True) items.sort(key=lambda x: x.weight, reverse=True)
top_items = items[:top_n]
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_ROUTING_SCORE) * 100, клэмп [0, 100]
raw_sum = sum(i.weight for i in top_items)
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_ROUTING_SCORE) * 100.0), 1)
# score_contribution — доля данного POI в общем балле (0..100)
for item in top_items:
item.score_contribution = round((item.weight / _MAX_ROUTING_SCORE) * 100.0, 1)
return PoiScoreResponse( return PoiScoreResponse(
cad_num=cad_num, cad_num=cad_num,
radius_m=radius_m, radius_m=radius_m,
top_poi=items[:top_n], poi_weighted_score=poi_weighted_score,
top_poi=top_items,
) )

View file

@ -7,6 +7,8 @@ import pytest
from app.services.site_finder import ors_client, poi_score from app.services.site_finder import ors_client, poi_score
from app.services.site_finder.poi_score import ( from app.services.site_finder.poi_score import (
_MAX_ROUTING_SCORE,
_MAX_STRAIGHT_SCORE,
CATEGORY_WEIGHTS, CATEGORY_WEIGHTS,
PoiScoreResponse, PoiScoreResponse,
_category_radius_min, _category_radius_min,
@ -153,6 +155,76 @@ def test_empty_db_returns_empty_top_poi():
assert result.top_poi == [] assert result.top_poi == []
assert result.cad_num == "66:41:0204016:10" assert result.cad_num == "66:41:0204016:10"
assert result.radius_m == 2000 assert result.radius_m == 2000
assert result.poi_weighted_score == 0.0
# ── #1486: normalization 0..100 ────────────────────────────────────────────────
def test_max_straight_score_constant():
"""_MAX_STRAIGHT_SCORE = Σ(top-7 category_weights) / 100 ≈ 0.315."""
top7_sum = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
assert _MAX_STRAIGHT_SCORE == pytest.approx(top7_sum / 100.0)
def test_max_routing_score_constant():
"""_MAX_ROUTING_SCORE = Σ(top-7 category_weights) = 31.5."""
top7_sum = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
assert _MAX_ROUTING_SCORE == pytest.approx(top7_sum)
def test_poi_weighted_score_in_range():
"""poi_weighted_score должен быть в диапазоне 0..100 для реалистичных данных."""
rows = [
_make_row("Метро", "metro_stop", 400.0),
_make_row("Школа", "school", 500.0),
_make_row("Детсад", "kindergarten", 300.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
assert 0.0 <= result.poi_weighted_score <= 100.0
def test_score_contribution_in_range():
"""Каждый score_contribution должен быть в 0..100."""
rows = [
_make_row("Метро", "metro_stop", 200.0),
_make_row("Школа", "school", 800.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
for item in result.top_poi:
assert (
0.0 <= item.score_contribution <= 100.0
), f"{item.category} score_contribution={item.score_contribution} вне 0..100"
def test_metro_at_zero_distance_scores_high():
"""Метро в 0м должно дать poi_weighted_score близко к max (≥19.0/100)."""
# metro weight = 6.0 / (0 + 100) = 0.06; normalized = 0.06 / _MAX_STRAIGHT_SCORE * 100
rows = [_make_row("Метро у дома", "metro_stop", 0.0)]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
assert (
result.poi_weighted_score >= 19.0
), f"Метро у дома (d=0) должно давать ≥19/100, получили {result.poi_weighted_score}"
def test_score_contribution_sum_equals_total():
"""Сумма score_contribution по top_poi должна совпадать с poi_weighted_score.
Допуск 0.5 оба значения округлены независимо до 1 знака, накопленная
ошибка при N POI N * 0.05. Для top-7 это 0.35 допуск 0.5 достаточен.
"""
rows = [
_make_row("Метро", "metro_stop", 300.0),
_make_row("Школа", "school", 600.0),
_make_row("Парк", "park", 200.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
total_from_contributions = sum(i.score_contribution for i in result.top_poi)
assert total_from_contributions == pytest.approx(result.poi_weighted_score, abs=0.5)
def test_address_built_from_tags(): def test_address_built_from_tags():
@ -301,6 +373,7 @@ def test_routing_decay_empty_db(monkeypatch):
monkeypatch.setattr(ors_client, "is_configured", lambda: True) monkeypatch.setattr(ors_client, "is_configured", lambda: True)
result = compute_poi_routing_decay(db, "cad", 56.838, 60.605) result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
assert result.top_poi == [] assert result.top_poi == []
assert result.poi_weighted_score == 0.0
def test_routing_decay_score_spread_wider_than_straight_line(monkeypatch): def test_routing_decay_score_spread_wider_than_straight_line(monkeypatch):
@ -313,9 +386,7 @@ def test_routing_decay_score_spread_wider_than_straight_line(monkeypatch):
def total(rows, times): def total(rows, times):
db = _MockDb(rows) db = _MockDb(rows)
monkeypatch.setattr(ors_client, "is_configured", lambda: True) monkeypatch.setattr(ors_client, "is_configured", lambda: True)
monkeypatch.setattr( monkeypatch.setattr(ors_client, "matrix_durations_min", lambda *_a, **_k: list(times))
ors_client, "matrix_durations_min", lambda *_a, **_k: list(times)
)
res = compute_poi_routing_decay(db, "cad", 56.8, 60.6) res = compute_poi_routing_decay(db, "cad", 56.8, 60.6)
return sum(i.weight for i in res.top_poi) return sum(i.weight for i in res.top_poi)

View file

@ -51,12 +51,14 @@ const CATEGORY_LABELS: Record<string, string> = {
bank: "Банк", bank: "Банк",
}; };
// score_contribution is now 0..100 (normalized on backend, #1486).
// Thresholds: ≥20 → "отличная инфраструктура", ≥12 → "хорошая", ≥8 → "средняя", <8 → "слабая".
function weightBadgeVariant( function weightBadgeVariant(
weight: number, score_contribution: number,
): "success" | "info" | "neutral" | "warning" { ): "success" | "info" | "neutral" | "warning" {
if (weight >= 0.2) return "success"; if (score_contribution >= 20) return "success";
if (weight >= 0.12) return "info"; if (score_contribution >= 12) return "info";
if (weight >= 0.08) return "neutral"; if (score_contribution >= 8) return "neutral";
return "warning"; return "warning";
} }
@ -70,10 +72,8 @@ interface Props {
// ── Component ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
export function PoiList2Gis({ items, totalScore }: Props) { export function PoiList2Gis({ items, totalScore }: Props) {
// POI-weighted score is presented as «X / 100», so clamp to 0..100 defensively: // poi_weighted_score is normalized to 0..100 on the backend (#1486).
// the adapter sums round(weight*100) per POI без нормировки и при достаточном // Clamp defensively in case of floating-point edge cases.
// числе POI может выдать >100 → бессмысленное «137 / 100» (#1470). Корневую
// нормировку нужно чинить в site-finder-api.ts (useParcelPoiScoreQuery).
const displayScore = Math.min(100, Math.max(0, totalScore)); const displayScore = Math.min(100, Math.max(0, totalScore));
// Top-7, sorted by score_contribution desc // Top-7, sorted by score_contribution desc
@ -215,9 +215,12 @@ export function PoiList2Gis({ items, totalScore }: Props) {
: `${(item.distance_m / 1000).toFixed(1)} км`} : `${(item.distance_m / 1000).toFixed(1)} км`}
</span> </span>
{/* Weight badge */} {/* Score contribution badge (normalized 0..100, #1486) */}
<Badge variant={weightBadgeVariant(item.weight)} size="sm"> <Badge
×{item.weight.toFixed(2)} variant={weightBadgeVariant(item.score_contribution)}
size="sm"
>
{item.score_contribution.toFixed(1)}
</Badge> </Badge>
</li> </li>
); );

View file

@ -1,55 +1,63 @@
{ {
"cad_num": "66:41:0701045:42", "cad_num": "66:41:0701045:42",
"poi_weighted_score": 76, "radius_m": 2000,
"poi_weighted_score": 19.9,
"items": [ "items": [
{
"category": "metro_stop",
"name": "Площадь 1905 года",
"distance_m": 340,
"weight": 0.25,
"score_contribution": 22
},
{ {
"category": "park", "category": "park",
"name": "Сквер Попова", "name": "Сквер Попова",
"distance_m": 150, "distance_m": 150,
"weight": 0.1, "weight": 0.014,
"score_contribution": 9 "score_contribution": 4.4,
"address": null
}, },
{ {
"category": "school", "category": "metro_stop",
"name": "Школа № 32", "name": "Площадь 1905 года",
"distance_m": 480, "distance_m": 340,
"weight": 0.15, "weight": 0.013636,
"score_contribution": 11 "score_contribution": 4.3,
"address": null
}, },
{ {
"category": "kindergarten", "category": "kindergarten",
"name": "Детский сад № 111", "name": "Детский сад № 111",
"distance_m": 260, "distance_m": 260,
"weight": 0.12, "weight": 0.0125,
"score_contribution": 10 "score_contribution": 4.0,
"address": null
}, },
{ {
"category": "shop_mall", "category": "school",
"name": "МЕГА Екатеринбург", "name": "Школа № 32",
"distance_m": 1200, "distance_m": 480,
"weight": 0.1, "weight": 0.008621,
"score_contribution": 7 "score_contribution": 2.7,
"address": null
}, },
{ {
"category": "hospital", "category": "hospital",
"name": "Городская больница № 7", "name": "Городская больница № 7",
"distance_m": 650, "distance_m": 650,
"weight": 0.1, "weight": 0.005333,
"score_contribution": 8 "score_contribution": 1.7,
"address": null
}, },
{ {
"category": "school", "category": "school",
"name": "Гимназия № 9", "name": "Гимназия № 9",
"distance_m": 820, "distance_m": 820,
"weight": 0.08, "weight": 0.005435,
"score_contribution": 5 "score_contribution": 1.7,
"address": null
},
{
"category": "shop_mall",
"name": "МЕГА Екатеринбург",
"distance_m": 1200,
"weight": 0.003077,
"score_contribution": 1.0,
"address": null
} }
] ]
} }

View file

@ -556,18 +556,20 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
// ── Hook: useParcelPoiScoreQuery (B6) ──────────────────────────────────────── // ── Hook: useParcelPoiScoreQuery (B6) ────────────────────────────────────────
/** /**
* Raw shape from backend /api/v1/parcels/{cad}/poi-score. * Raw shape from backend /api/v1/parcels/{cad}/poi-score (#1486).
* Backend returns top_poi array with address field (not score_contribution). * Backend now returns poi_weighted_score (0..100) and per-POI score_contribution (0..100)
* We adapt to PoiScoreResponse so downstream components are stable. * normalized on the server side no client-side reconstruction needed.
*/ */
interface PoiScoreRaw { interface PoiScoreRaw {
cad_num: string; cad_num: string;
radius_m: number; radius_m: number;
poi_weighted_score: number;
top_poi: Array<{ top_poi: Array<{
name: string; name: string;
category: string; category: string;
distance_m: number; distance_m: number;
weight: number; weight: number;
score_contribution: number;
address?: string | null; address?: string | null;
}>; }>;
} }
@ -579,8 +581,8 @@ export function useParcelPoiScoreQuery(cad: string) {
if (MOCK_POI_SCORE) { if (MOCK_POI_SCORE) {
return fixturePoiScore as PoiScoreResponse; return fixturePoiScore as PoiScoreResponse;
} }
// Backend returns {cad_num, radius_m, top_poi: [{name,category,distance_m,weight,address}]} // Backend returns normalized poi_weighted_score (0..100) and score_contribution per POI.
// Adapt to PoiScoreResponse {cad_num, poi_weighted_score, items} for stable consumer API. // Pass through directly — no client-side weight × 100 reconstruction (#1486).
const raw = await apiFetch<PoiScoreRaw>( const raw = await apiFetch<PoiScoreRaw>(
`/api/v1/parcels/${encodeURIComponent(cad)}/poi-score`, `/api/v1/parcels/${encodeURIComponent(cad)}/poi-score`,
); );
@ -589,14 +591,13 @@ export function useParcelPoiScoreQuery(cad: string) {
name: p.name, name: p.name,
distance_m: p.distance_m, distance_m: p.distance_m,
weight: p.weight, weight: p.weight,
// score_contribution not in backend response — derive from weight (0..1) × 100 score_contribution: p.score_contribution,
score_contribution: Math.round(p.weight * 100),
})); }));
const poi_weighted_score = items.reduce( return {
(s, p) => s + p.score_contribution, cad_num: raw.cad_num,
0, poi_weighted_score: raw.poi_weighted_score,
); items,
return { cad_num: raw.cad_num, poi_weighted_score, items }; };
}, },
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
retry: 1, retry: 1,