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

@@ -0,0 +1,266 @@
<?php
namespace App\Service\Forecast;
use App\Entity\CoastalPoint;
use App\Entity\ImpactScore;
use App\Entity\SargassumForecast;
use App\Entity\SargassumObservation;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Calcule et persiste les ImpactScore pour tous les CoastalPoints,
* en prenant en compte l'observation courante et les forecasts.
*
* Formule :
* score = distance_score(40) + density_score(30) + velocity_score(20) + trend_score(10)
*
* distance_score : max(0, 1 - distance/50km) × 40
* density_score : min(coverage_area_km2 / 100, 1) × 30
* velocity_score : max(0, approach_speed_km_h / 5) capped at 1, × 20
* trend_score : 10 si increasing, 5 si stable, 0 si decreasing
*/
class ImpactScoreService
{
private const MAX_DISTANCE_M = 50_000; // 50 km : au-delà score distance = 0
private const MAX_DENSITY_KM2 = 100; // 100 km² = densité maximale
private const MAX_APPROACH_KMH = 5; // 5 km/h = vitesse d'approche max
private const CACHE_TTL = 10_800; // 3h
public function __construct(
private Connection $connection,
private EntityManagerInterface $em,
private CacheInterface $cache,
private LoggerInterface $logger,
) {}
/**
* Calcule et sauvegarde les scores pour tous les CoastalPoints
* à partir d'une observation et de ses forecasts.
*/
public function computeForObservation(SargassumObservation $observation, array $forecasts): void
{
$spots = $this->em->getRepository(CoastalPoint::class)->findAll();
if (empty($spots)) {
$this->logger->info('No coastal points found, skipping score computation');
return;
}
$obsId = (string) $observation->getId();
$forecastH6 = $this->findForecastByHorizon($forecasts, 6);
foreach ($spots as $spot) {
$spotId = (string) $spot->getId();
try {
$score = $this->computeScore($spot, $obsId, $forecastH6);
$this->persistScore($spot, $score);
$this->invalidateCache($spotId);
} catch (\Throwable $e) {
$this->logger->error('Score computation failed', [
'spot' => $spot->getName(),
'error' => $e->getMessage(),
]);
}
}
$this->em->flush();
}
/**
* Retourne le score mis en cache pour un spot (TTL 3h).
*/
public function getCachedScore(string $spotId): ?array
{
$key = $this->cacheKey($spotId);
return $this->cache->get($key, function (ItemInterface $item) use ($spotId) {
$item->expiresAfter(self::CACHE_TTL);
return $this->loadLatestScore($spotId);
});
}
// -------------------------------------------------------------------------
private function computeScore(CoastalPoint $spot, string $obsId, ?SargassumForecast $forecastH6): array
{
$spotId = (string) $spot->getId();
// Distance au sargassum le plus proche (mètres)
$distanceM = $this->getDistanceToNearestSargassum($spotId, $obsId);
if ($distanceM === null) {
return $this->emptyScore();
}
$coverageKm2 = $this->getCoverageArea($obsId);
// Vitesse d'approche : delta distance entre now et H+6 (km/h)
$approachKmh = 0.0;
if ($forecastH6 !== null) {
$distH6M = $this->getDistanceToNearestForecast($spotId, (string) $forecastH6->getId());
if ($distH6M !== null) {
$approachKmh = ($distanceM - $distH6M) / 1000 / 6; // km/h (positif = approche)
}
}
// Tendance
$previousDistance = $this->getPreviousDistance($spotId);
$trend = match (true) {
$previousDistance === null => 'stable',
$distanceM < $previousDistance * 0.9 => 'increasing',
$distanceM > $previousDistance * 1.1 => 'decreasing',
default => 'stable',
};
// Calcul des composantes
$distanceScore = max(0.0, 1.0 - $distanceM / self::MAX_DISTANCE_M) * 40;
$densityScore = min($coverageKm2 / self::MAX_DENSITY_KM2, 1.0) * 30;
$velocityScore = min(max($approachKmh, 0.0) / self::MAX_APPROACH_KMH, 1.0) * 20;
$trendScore = match ($trend) { 'increasing' => 10, 'stable' => 5, default => 0 };
$total = (int) round($distanceScore + $densityScore + $velocityScore + $trendScore);
$total = max(0, min(100, $total));
return [
'score' => $total,
'level' => $this->scoreToLevel($total),
'distance' => round($distanceM / 1000, 2),
'density' => round($coverageKm2, 2),
'trend' => $trend,
];
}
private function getDistanceToNearestSargassum(string $spotId, string $obsId): ?float
{
$row = $this->connection->fetchAssociative(
'SELECT ST_Distance(
cp.geometry::geography,
o.geometry::geography
) AS distance_m
FROM coastal_point cp, sargassum_observation o
WHERE cp.id = :spotId AND o.id = :obsId',
['spotId' => $spotId, 'obsId' => $obsId]
);
return ($row !== false) ? (float) $row['distance_m'] : null;
}
private function getDistanceToNearestForecast(string $spotId, string $forecastId): ?float
{
$row = $this->connection->fetchAssociative(
'SELECT ST_Distance(
cp.geometry::geography,
f.geometry::geography
) AS distance_m
FROM coastal_point cp, sargassum_forecast f
WHERE cp.id = :spotId AND f.id = :forecastId',
['spotId' => $spotId, 'forecastId' => $forecastId]
);
return ($row !== false) ? (float) $row['distance_m'] : null;
}
private function getCoverageArea(string $obsId): float
{
$row = $this->connection->fetchAssociative(
'SELECT coverage_area FROM sargassum_observation WHERE id = :id',
['id' => $obsId]
);
return ($row !== false && $row['coverage_area'] !== null) ? (float) $row['coverage_area'] : 0.0;
}
private function getPreviousDistance(string $spotId): ?float
{
$row = $this->connection->fetchAssociative(
'SELECT distance_to_nearest_sargassum
FROM impact_score
WHERE coastal_point_id = :spotId
ORDER BY timestamp DESC
LIMIT 1',
['spotId' => $spotId]
);
return ($row !== false && $row['distance_to_nearest_sargassum'] !== null)
? (float) $row['distance_to_nearest_sargassum']
: null;
}
private function persistScore(CoastalPoint $spot, array $data): void
{
$s = new ImpactScore();
$s->setCoastalPoint($spot);
$s->setScore($data['score']);
$s->setLevel($data['level']);
$s->setDistanceToNearestSargassum($data['distance']);
$s->setDensityEstimate($data['density']);
$s->setTrend($data['trend']);
$this->em->persist($s);
}
private function loadLatestScore(string $spotId): ?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
ORDER BY timestamp DESC LIMIT 1',
['spotId' => $spotId]
);
if ($row === false) {
return null;
}
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 invalidateCache(string $spotId): void
{
$this->cache->delete($this->cacheKey($spotId));
}
private function cacheKey(string $spotId): string
{
return 'impact_score_' . str_replace('-', '_', $spotId);
}
private function scoreToLevel(int $score): string
{
return match (true) {
$score <= 30 => 'low',
$score <= 70 => 'medium',
default => 'high',
};
}
private function emptyScore(): array
{
return ['score' => 0, 'level' => 'low', 'distance' => null, 'density' => 0.0, 'trend' => 'stable'];
}
private function findForecastByHorizon(array $forecasts, int $horizon): ?SargassumForecast
{
foreach ($forecasts as $f) {
if ($f instanceof SargassumForecast && $f->getTimeHorizon() === $horizon) {
return $f;
}
}
return null;
}
}