Files
radarsargasses/backend/src/Service/Forecast/ImpactScoreService.php
Gwadaking 94bb6f5e8c 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>
2026-04-01 03:23:21 -04:00

267 lines
9.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}