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:
266
backend/src/Service/Forecast/ImpactScoreService.php
Normal file
266
backend/src/Service/Forecast/ImpactScoreService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user