Compare commits

..

No commits in common. "7d5962e7b4999a5be6f9089a6f9cc1f68419e067" and "ba83c36bf4d74c160999a23fb4fee269e8c18b7b" have entirely different histories.

5 changed files with 62 additions and 187 deletions

View file

@ -44,21 +44,6 @@ 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-ответе."""
@ -67,17 +52,12 @@ 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]
@ -175,7 +155,6 @@ 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,
) )
) )
@ -184,18 +163,9 @@ 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,
) )
@ -333,9 +303,7 @@ def compute_poi_routing_decay(
) )
if not rows: if not rows:
return PoiScoreResponse( return PoiScoreResponse(cad_num=cad_num, radius_m=radius_m, top_poi=[])
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]
@ -343,7 +311,9 @@ 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(lon, lat, destinations, profile=profile) times_min = ors_client.matrix_durations_min(
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(
@ -385,25 +355,13 @@ 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,
poi_weighted_score=poi_weighted_score, top_poi=items[:top_n],
top_poi=top_items,
) )

View file

@ -7,8 +7,6 @@ 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,
@ -155,76 +153,6 @@ 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():
@ -373,7 +301,6 @@ 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):
@ -386,7 +313,9 @@ 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(ors_client, "matrix_durations_min", lambda *_a, **_k: list(times)) monkeypatch.setattr(
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,14 +51,12 @@ 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(
score_contribution: number, weight: number,
): "success" | "info" | "neutral" | "warning" { ): "success" | "info" | "neutral" | "warning" {
if (score_contribution >= 20) return "success"; if (weight >= 0.2) return "success";
if (score_contribution >= 12) return "info"; if (weight >= 0.12) return "info";
if (score_contribution >= 8) return "neutral"; if (weight >= 0.08) return "neutral";
return "warning"; return "warning";
} }
@ -72,8 +70,10 @@ interface Props {
// ── Component ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
export function PoiList2Gis({ items, totalScore }: Props) { export function PoiList2Gis({ items, totalScore }: Props) {
// poi_weighted_score is normalized to 0..100 on the backend (#1486). // POI-weighted score is presented as «X / 100», so clamp to 0..100 defensively:
// Clamp defensively in case of floating-point edge cases. // the adapter sums round(weight*100) per POI без нормировки и при достаточном
// числе 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,12 +215,9 @@ export function PoiList2Gis({ items, totalScore }: Props) {
: `${(item.distance_m / 1000).toFixed(1)} км`} : `${(item.distance_m / 1000).toFixed(1)} км`}
</span> </span>
{/* Score contribution badge (normalized 0..100, #1486) */} {/* Weight badge */}
<Badge <Badge variant={weightBadgeVariant(item.weight)} size="sm">
variant={weightBadgeVariant(item.score_contribution)} ×{item.weight.toFixed(2)}
size="sm"
>
{item.score_contribution.toFixed(1)}
</Badge> </Badge>
</li> </li>
); );

View file

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

View file

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