Phase 2 : dérive, ImpactScore, slider temporel

Backend :
- OpenMeteoClient : vent horaire 72h (gratuit, sans clé)
- DriftSimulationService : échantillonnage ST_GeneratePoints,
  déplacement itératif Stokes 3%, reconstruction ST_ConcaveHull,
  4 horizons H+6/12/24/48, confiance décroissante
- ImpactScoreService : score 4 composantes (distance/densité/
  vitesse/tendance), cache Redis TTL 3h, invalidation à chaque calcul
- ComputeForecastsCommand : traite les observations sans forecast
- SpotScoreController : score courant + horizons depuis forecasts

Frontend :
- TimelineSlider : navigation Now/+6h/+12h/+24h/+48h
- SargassesMap : couche observations (orange) vs forecasts (violet)
  rechargée à chaque changement d'étape
- SpotPanel : affichage score par horizon actif + indicateur confiance
- Build → backend/public/spa/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-01 03:23:21 -04:00
parent 0c1deb93cb
commit 94bb6f5e8c
18 changed files with 983 additions and 186 deletions

View File

@@ -3,7 +3,8 @@
namespace App\Controller;
use App\Entity\CoastalPoint;
use App\Entity\ImpactScore;
use App\Service\Forecast\ImpactScoreService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -13,55 +14,135 @@ use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/spots', name: 'api_spots_')]
class SpotScoreController extends AbstractController
{
public function __construct(private EntityManagerInterface $em) {}
public function __construct(
private EntityManagerInterface $em,
private ImpactScoreService $scoreService,
private Connection $connection,
) {}
/**
* GET /api/spots/{id}/score
*
* Retourne le score courant pour un CoastalPoint.
* Paramètre optionnel : ?at=YYYY-MM-DDTHH:MM:SSZ (score à un instant donné)
* Retourne le score courant + les scores par horizon (H+6/12/24/48).
* Utilise le cache Redis (TTL 3h).
* Paramètre optionnel : ?at=YYYY-MM-DDTHH:MM:SSZ
*/
#[Route('/{id}/score', name: 'score', methods: ['GET'])]
public function score(string $id, Request $request): JsonResponse
{
$spot = $this->em->getRepository(CoastalPoint::class)->find($id);
if ($spot === null) {
return $this->json(['error' => 'Spot not found'], 404);
}
$qb = $this->em->getRepository(ImpactScore::class)
->createQueryBuilder('s')
->where('s.coastalPoint = :spot')
->setParameter('spot', $spot)
->orderBy('s.timestamp', 'DESC')
->setMaxResults(1);
$atParam = $request->query->get('at');
// Si ?at= spécifié, on ne peut pas utiliser le cache
if ($atParam !== null) {
try {
$at = new \DateTimeImmutable($atParam);
$qb->andWhere('s.timestamp <= :at')->setParameter('at', $at);
} catch (\Exception) {
return $this->json(['error' => 'Invalid "at" parameter format'], 400);
}
$current = $this->loadScoreAt($id, $at);
} else {
$current = $this->scoreService->getCachedScore($id);
}
$latest = $qb->getQuery()->getOneOrNullResult();
return $this->json([
'spotId' => (string) $spot->getId(),
'name' => $spot->getName(),
'type' => $spot->getType(),
'region' => $spot->getRegion(),
'score' => $latest ? [
'value' => $latest->getScore(),
'level' => $latest->getLevel(),
'distance' => $latest->getDistanceToNearestSargassum(),
'density' => $latest->getDensityEstimate(),
'trend' => $latest->getTrend(),
'computedAt' => $latest->getTimestamp()?->format(\DateTimeInterface::ATOM),
] : null,
'spotId' => (string) $spot->getId(),
'name' => $spot->getName(),
'type' => $spot->getType(),
'region' => $spot->getRegion(),
'score' => $current,
'horizons' => $this->loadHorizonScores($id),
]);
}
// -------------------------------------------------------------------------
/**
* Score à un instant précis (sans cache).
*/
private function loadScoreAt(string $spotId, \DateTimeImmutable $at): ?array
{
$row = $this->connection->fetchAssociative(
'SELECT score, level, distance_to_nearest_sargassum AS distance,
density_estimate AS density, trend, timestamp AS computed_at
FROM impact_score
WHERE coastal_point_id = :spotId AND timestamp <= :at
ORDER BY timestamp DESC LIMIT 1',
['spotId' => $spotId, 'at' => $at->format('Y-m-d H:i:s')]
);
return $row !== false ? $this->formatScore($row) : null;
}
/**
* Scores estimés par horizon à partir des forecasts les plus récents.
* Retourne null si aucun forecast disponible.
*/
private function loadHorizonScores(string $spotId): ?array
{
// Dernière observation avec forecasts
$rows = $this->connection->fetchAllAssociative(
'SELECT
f.time_horizon AS horizon,
ROUND(CAST(ST_Distance(
cp.geometry::geography,
f.geometry::geography
) / 1000 AS numeric), 2) AS distance_km,
f.confidence,
f.valid_at
FROM sargassum_forecast f
JOIN coastal_point cp ON cp.id = :spotId
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
)
ORDER BY f.time_horizon ASC',
['spotId' => $spotId]
);
if (empty($rows)) {
return null;
}
$horizons = [];
foreach ($rows as $row) {
$distKm = (float) $row['distance_km'];
$score = max(0, (int) round((1 - min($distKm / 50, 1)) * 100));
$horizons['h' . $row['horizon']] = [
'score' => $score,
'level' => $this->scoreToLevel($score),
'distanceKm' => $distKm,
'confidence' => (float) $row['confidence'],
'validAt' => $row['valid_at'],
];
}
return $horizons;
}
private function formatScore(array $row): array
{
return [
'value' => (int) $row['score'],
'level' => $row['level'],
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
'density' => $row['density'] !== null ? (float) $row['density'] : null,
'trend' => $row['trend'],
'computedAt' => $row['computed_at'],
];
}
private function scoreToLevel(int $score): string
{
return match (true) {
$score <= 30 => 'low',
$score <= 70 => 'medium',
default => 'high',
};
}
}