fix: scores horizons — même formule 4 composantes que le score now

Avant : horizons = (1 - dist/50km) × 100 × confidence — échelle incomparable
avec le score now qui utilise distance(40) + densité(30) + vitesse(20) + tendance(10).

Après : même décomposition pour les 4 horizons, en passant currentDistanceKm
pour calculer la vitesse d'approche réelle par rapport à la position courante.
La confiance s'applique sur le score total (incertitude du modèle).

Résultat : si la sargasse s'éloigne à H+6, le score baisse. Si elle approche,
le score monte — mais atténué par la confiance du modèle (0.85 à H+6).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-03 21:51:56 -04:00
parent 0014870c3b
commit 55caca001c

View File

@@ -49,13 +49,15 @@ class SpotScoreController extends AbstractController
$current = $this->scoreService->getCachedScore($id);
}
$currentDistanceKm = $current !== null ? $current['distance'] : null;
return $this->json([
'spotId' => (string) $spot->getId(),
'name' => $spot->getName(),
'type' => $spot->getType(),
'region' => $spot->getRegion(),
'score' => $current,
'horizons' => $this->loadHorizonScores($id),
'horizons' => $this->loadHorizonScores($id, $currentDistanceKm),
]);
}
@@ -81,14 +83,14 @@ class SpotScoreController extends AbstractController
}
/**
* Scores estimés par horizon à partir des forecasts les plus récents.
* Retourne null si aucun forecast disponible.
* Scores estimés par horizon — même formule 4 composantes que computeScore() :
* distance (40pts) + densité observation (30pts) + vitesse d'approche (20pts) + tendance (10pts)
* Le tout multiplié par la confiance du modèle (décroissante avec l'horizon).
*
* @return array<string, array{score: int, level: string, distanceKm: float, confidence: float, validAt: string}>|null
*/
private function loadHorizonScores(string $spotId): ?array
private function loadHorizonScores(string $spotId, ?float $currentDistanceKm): ?array
{
// Dernière observation avec forecasts
$rows = $this->connection->fetchAllAssociative(
'SELECT
f.time_horizon AS horizon,
@@ -97,13 +99,15 @@ class SpotScoreController extends AbstractController
f.geometry::geography
) / 1000 AS numeric), 2) AS distance_km,
f.confidence,
f.valid_at
f.valid_at,
COALESCE(o.coverage_area, 0) AS coverage_area
FROM sargassum_forecast f
JOIN coastal_point cp ON cp.id = :spotId
JOIN sargassum_observation o ON o.id = f.source_observation_id
WHERE f.source_observation_id = (
SELECT o.id FROM sargassum_observation o
INNER JOIN sargassum_forecast ff ON ff.source_observation_id = o.id
ORDER BY o.detected_at DESC LIMIT 1
SELECT o2.id FROM sargassum_observation o2
INNER JOIN sargassum_forecast ff ON ff.source_observation_id = o2.id
ORDER BY o2.detected_at DESC LIMIT 1
)
ORDER BY f.time_horizon ASC',
['spotId' => $spotId]
@@ -114,19 +118,50 @@ class SpotScoreController extends AbstractController
}
$horizons = [];
$prevDistKm = $currentDistanceKm; // distance de référence = "now"
$prevHorizon = 0;
foreach ($rows as $row) {
$distKm = (float) $row['distance_km'];
$distM = $distKm * 1000;
$confidence = (float) $row['confidence'];
// Score pondéré par la confiance : un horizon lointain moins certain
// ne peut pas afficher un risque supérieur à un horizon proche plus fiable
$score = max(0, (int) round((1 - min($distKm / 50, 1)) * 100 * $confidence));
$horizons['h' . $row['horizon']] = [
$coverageKm2 = (float) $row['coverage_area'];
$horizon = (int) $row['horizon'];
// Composante distance (même seuil 50km que computeScore)
$distanceScore = max(0.0, 1.0 - $distM / 50_000) * 40;
// Composante densité : même observation source, coverage identique
$densityScore = min($coverageKm2 / 100, 1.0) * 30;
// Composante vitesse d'approche par rapport à l'horizon précédent
$hoursElapsed = max($horizon - $prevHorizon, 1);
$approachKmh = $prevDistKm !== null
? ($prevDistKm - $distKm) / $hoursElapsed
: 0.0;
$velocityScore = min(max($approachKmh, 0.0) / 5.0, 1.0) * 20;
// Composante tendance
$trendScore = match (true) {
$approachKmh > 0.2 => 10, // approche
$approachKmh < -0.2 => 0, // s'éloigne
default => 5, // stable
};
// Confiance du modèle appliquée à l'ensemble (l'observation "now" est certaine, pas la prévision)
$raw = $distanceScore + $densityScore + $velocityScore + $trendScore;
$score = max(0, min(100, (int) round($raw * $confidence)));
$horizons['h' . $horizon] = [
'score' => $score,
'level' => $this->scoreToLevel($score),
'distanceKm' => $distKm,
'confidence' => $confidence,
'validAt' => $row['valid_at'],
];
$prevDistKm = $distKm;
$prevHorizon = $horizon;
}
return $horizons;