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:
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user