feat: explainability — décomposition des 4 composantes du score dans le SpotPanel

Expose breakdown {distance, density, velocity, trend} dans l'API /spots/{id}/score
(pour le score courant reconstruit depuis les champs stockés, et pour chaque horizon
avec confidence appliquée). Affiche les barres avec valeur/max dans le SpotPanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-03 23:33:07 -04:00
parent 779c07ced0
commit d4d2314f7b
3 changed files with 117 additions and 5 deletions

View File

@@ -152,12 +152,20 @@ class SpotScoreController extends AbstractController
$raw = $distanceScore + $densityScore + $velocityScore + $trendScore;
$score = max(0, min(100, (int) round($raw * $confidence)));
$cf = fn(float $v): int => (int) round($v * $confidence);
$horizons['h' . $horizon] = [
'score' => $score,
'level' => $this->scoreToLevel($score),
'distanceKm' => $distKm,
'confidence' => $confidence,
'validAt' => $row['valid_at'],
'breakdown' => [
'distance' => $cf($distanceScore),
'density' => $cf($densityScore),
'velocity' => $cf($velocityScore),
'trend' => $cf($trendScore),
],
];
$prevDistKm = $distKm;
@@ -169,18 +177,33 @@ class SpotScoreController extends AbstractController
/**
* @param array<string, mixed> $row
* @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string}
* @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string, breakdown: array{distance: int, density: int, velocity: int, trend: int}|null}
*/
private function formatScore(array $row): array
{
$distKm = $row['distance'] !== null ? (float) $row['distance'] : null;
$densKm2 = $row['density'] !== null ? (float) $row['density'] : null;
$trend = $row['trend'];
$total = (int) $row['score'];
$breakdown = null;
if ($distKm !== null && $densKm2 !== null && $trend !== null) {
$dScore = (int) round(max(0.0, 1.0 - $distKm * 1000 / 50_000) * 40);
$nScore = (int) round(min($densKm2 / 100, 1.0) * 30);
$tScore = match ($trend) { 'increasing' => 10, 'stable' => 5, default => 0 };
$vScore = max(0, $total - $dScore - $nScore - $tScore);
$breakdown = ['distance' => $dScore, 'density' => $nScore, 'velocity' => $vScore, 'trend' => $tScore];
}
return [
'value' => (int) $row['score'],
'value' => $total,
'level' => $row['level'],
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
'density' => $row['density'] !== null ? (float) $row['density'] : null,
'trend' => $row['trend'],
'distance' => $distKm,
'density' => $densKm2,
'trend' => $trend,
'etaHours' => $row['eta_hours'] !== null ? (int) $row['eta_hours'] : null,
'computedAt' => $row['computed_at'],
'breakdown' => $breakdown,
];
}