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:
89
backend/src/Command/ComputeForecastsCommand.php
Normal file
89
backend/src/Command/ComputeForecastsCommand.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\SargassumObservation;
|
||||
use App\Service\Forecast\DriftSimulationService;
|
||||
use App\Service\Forecast\ImpactScoreService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:compute-forecasts',
|
||||
description: 'Calcule les forecasts de dérive et les ImpactScores pour les dernières observations',
|
||||
)]
|
||||
class ComputeForecastsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private DriftSimulationService $drift,
|
||||
private ImpactScoreService $scores,
|
||||
private EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('limit', 'l', InputOption::VALUE_OPTIONAL,
|
||||
'Nombre max d\'observations à traiter', 5);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$limit = (int) $input->getOption('limit');
|
||||
|
||||
$io->title('Calcul forecasts + ImpactScores');
|
||||
|
||||
// Observations récentes sans forecast
|
||||
$observations = $this->em->getRepository(SargassumObservation::class)
|
||||
->createQueryBuilder('o')
|
||||
->leftJoin('o.forecasts', 'f')
|
||||
->where('f.id IS NULL')
|
||||
->orderBy('o.detectedAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
if (empty($observations)) {
|
||||
$io->info('Aucune observation sans forecast trouvée.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->progressStart(count($observations));
|
||||
$errors = 0;
|
||||
|
||||
foreach ($observations as $observation) {
|
||||
try {
|
||||
$forecasts = $this->drift->computeForecasts($observation);
|
||||
$io->text(sprintf(
|
||||
'Observation %s → %d forecast(s)',
|
||||
$observation->getId(),
|
||||
count($forecasts)
|
||||
));
|
||||
|
||||
$this->scores->computeForObservation($observation, $forecasts);
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Erreur sur ' . $observation->getId() . ' : ' . $e->getMessage());
|
||||
$errors++;
|
||||
}
|
||||
|
||||
$io->progressAdvance();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
|
||||
$io->success(sprintf(
|
||||
'%d observation(s) traitée(s), %d erreur(s).',
|
||||
count($observations) - $errors,
|
||||
$errors
|
||||
));
|
||||
|
||||
return $errors === 0 ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
227
backend/src/Service/Forecast/DriftSimulationService.php
Normal file
227
backend/src/Service/Forecast/DriftSimulationService.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Forecast;
|
||||
|
||||
use App\Entity\SargassumForecast;
|
||||
use App\Entity\SargassumObservation;
|
||||
use App\Service\Weather\OpenMeteoClient;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Simule la dérive des sargasses à partir d'une observation.
|
||||
*
|
||||
* Algorithme :
|
||||
* 1. Échantillonnage PostGIS (ST_GeneratePoints) → N points internes
|
||||
* 2. Pour chaque point, déplacement horaire via vecteur vent Open-Meteo
|
||||
* (modèle Stokes : 3% vitesse vent = contribution sargassum)
|
||||
* 3. Reconstruction du polygone par ST_ConcaveHull des points déplacés
|
||||
* 4. Sauvegarde d'un SargassumForecast par horizon (H+6/12/24/48)
|
||||
*/
|
||||
class DriftSimulationService
|
||||
{
|
||||
private const HORIZONS = [6, 12, 24, 48];
|
||||
private const SAMPLE_POINTS = 30;
|
||||
private const WIND_FACTOR = 0.03; // 3% du vent = dérive Stokes
|
||||
private const MODEL_VERSION = '1.0.0';
|
||||
private const CONCAVITY = 0.8; // paramètre ST_ConcaveHull (0=convex, 1=très concave)
|
||||
|
||||
public function __construct(
|
||||
private OpenMeteoClient $weather,
|
||||
private Connection $connection,
|
||||
private EntityManagerInterface $em,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Calcule et persiste les forecasts pour une observation.
|
||||
*
|
||||
* @return SargassumForecast[]
|
||||
*/
|
||||
public function computeForecasts(SargassumObservation $observation): array
|
||||
{
|
||||
$id = (string) $observation->getId();
|
||||
|
||||
// Centroïde de l'observation → point de référence pour Open-Meteo
|
||||
$centroid = $this->getCentroid($id);
|
||||
if ($centroid === null) {
|
||||
$this->logger->warning('Centroid unavailable, skipping forecast', ['id' => $id]);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Vents horaires sur 3 jours (72h)
|
||||
$windData = $this->weather->getHourlyWind($centroid['lat'], $centroid['lng']);
|
||||
|
||||
// Points échantillonnés à l'intérieur du polygone
|
||||
$samplePoints = $this->samplePoints($id);
|
||||
if (empty($samplePoints)) {
|
||||
$this->logger->warning('No sample points, skipping forecast', ['id' => $id]);
|
||||
return [];
|
||||
}
|
||||
|
||||
$forecasts = [];
|
||||
$currentPoints = $samplePoints; // état courant des points
|
||||
$prevHorizon = 0;
|
||||
|
||||
foreach (self::HORIZONS as $horizon) {
|
||||
// Déplace les points heure par heure de $prevHorizon à $horizon
|
||||
$currentPoints = $this->displacePoints($currentPoints, $windData, $prevHorizon, $horizon);
|
||||
|
||||
// Reconstruit le polygone depuis les points déplacés
|
||||
$geometry = $this->reconstructPolygon($currentPoints);
|
||||
if ($geometry === null) {
|
||||
$this->logger->info("No polygon for H+{$horizon}", ['id' => $id]);
|
||||
$prevHorizon = $horizon;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Vecteur de dérive moyen sur cette période
|
||||
$driftVector = $this->avgDriftVector($windData, $prevHorizon, $horizon);
|
||||
|
||||
$forecast = new SargassumForecast();
|
||||
$forecast->setSourceObservation($observation);
|
||||
$forecast->setGeometry($geometry);
|
||||
$forecast->setValidAt($observation->getDetectedAt()->modify("+{$horizon} hours"));
|
||||
$forecast->setModelVersion(self::MODEL_VERSION);
|
||||
$forecast->setTimeHorizon($horizon);
|
||||
$forecast->setConfidence($this->computeConfidence($horizon));
|
||||
$forecast->setDriftVectorAvg(
|
||||
sprintf('SRID=4326;POINT(%f %f)', $driftVector['lng'], $driftVector['lat'])
|
||||
);
|
||||
|
||||
$this->em->persist($forecast);
|
||||
$forecasts[] = $forecast;
|
||||
$prevHorizon = $horizon;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $forecasts;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function getCentroid(string $observationId): ?array
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT ST_X(ST_Centroid(geometry)) AS lng,
|
||||
ST_Y(ST_Centroid(geometry)) AS lat
|
||||
FROM sargassum_observation WHERE id = :id',
|
||||
['id' => $observationId]
|
||||
);
|
||||
|
||||
return ($row !== false) ? ['lat' => (float) $row['lat'], 'lng' => (float) $row['lng']] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère N points aléatoires à l'intérieur du polygone via PostGIS.
|
||||
*
|
||||
* @return array<int, array{lat: float, lng: float}>
|
||||
*/
|
||||
private function samplePoints(string $observationId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT ST_X(geom) AS lng, ST_Y(geom) AS lat
|
||||
FROM (
|
||||
SELECT (ST_Dump(ST_GeneratePoints(geometry, :n))).geom
|
||||
FROM sargassum_observation WHERE id = :id
|
||||
) AS pts',
|
||||
['n' => self::SAMPLE_POINTS, 'id' => $observationId]
|
||||
);
|
||||
|
||||
return array_map(fn($r) => ['lat' => (float) $r['lat'], 'lng' => (float) $r['lng']], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Déplace un ensemble de points sur la période [$fromHour..$toHour]
|
||||
* en appliquant le vecteur vent heure par heure.
|
||||
*
|
||||
* @param array<int, array{lat: float, lng: float}> $points
|
||||
* @param array<int, array{speed: float, direction: float}> $windData
|
||||
* @return array<int, array{lat: float, lng: float}>
|
||||
*/
|
||||
private function displacePoints(array $points, array $windData, int $fromHour, int $toHour): array
|
||||
{
|
||||
for ($h = $fromHour; $h < $toHour; $h++) {
|
||||
$wind = $windData[$h] ?? ['speed' => 0, 'direction' => 0];
|
||||
|
||||
$speed = $wind['speed'] * self::WIND_FACTOR; // m/s
|
||||
$dirRad = deg2rad($wind['direction']);
|
||||
|
||||
// Déplacement en mètres sur 1 heure
|
||||
$dx = $speed * sin($dirRad) * 3600; // Est-Ouest (m)
|
||||
$dy = $speed * cos($dirRad) * 3600; // Nord-Sud (m)
|
||||
|
||||
foreach ($points as &$pt) {
|
||||
// Conversion m → degrés
|
||||
$pt['lat'] += $dy / 111320;
|
||||
$pt['lng'] += $dx / (111320 * cos(deg2rad($pt['lat'])));
|
||||
}
|
||||
unset($pt);
|
||||
}
|
||||
|
||||
return $points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruit un MULTIPOLYGON depuis un nuage de points via PostGIS.
|
||||
*/
|
||||
private function reconstructPolygon(array $points): ?string
|
||||
{
|
||||
if (count($points) < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$wktPoints = implode(',', array_map(
|
||||
fn($p) => sprintf('%f %f', $p['lng'], $p['lat']),
|
||||
$points
|
||||
));
|
||||
|
||||
$row = $this->connection->fetchAssociative(
|
||||
"SELECT ST_AsText(
|
||||
ST_Multi(
|
||||
ST_ConcaveHull(
|
||||
ST_GeomFromText('MULTIPOINT({$wktPoints})', 4326),
|
||||
:concavity
|
||||
)
|
||||
)
|
||||
) AS wkt",
|
||||
['concavity' => self::CONCAVITY]
|
||||
);
|
||||
|
||||
return ($row !== false && $row['wkt']) ? 'SRID=4326;' . $row['wkt'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le vecteur de dérive moyen (composantes lat/lng) sur une période.
|
||||
*/
|
||||
private function avgDriftVector(array $windData, int $fromHour, int $toHour): array
|
||||
{
|
||||
$count = $toHour - $fromHour;
|
||||
$sumLat = 0.0;
|
||||
$sumLng = 0.0;
|
||||
|
||||
for ($h = $fromHour; $h < $toHour; $h++) {
|
||||
$wind = $windData[$h] ?? ['speed' => 0, 'direction' => 0];
|
||||
$speed = $wind['speed'] * self::WIND_FACTOR;
|
||||
$dirRad = deg2rad($wind['direction']);
|
||||
$sumLat += $speed * cos($dirRad);
|
||||
$sumLng += $speed * sin($dirRad);
|
||||
}
|
||||
|
||||
return ['lat' => $sumLat / max($count, 1), 'lng' => $sumLng / max($count, 1)];
|
||||
}
|
||||
|
||||
/** Confiance décroissante avec l'horizon temporel. */
|
||||
private function computeConfidence(int $horizon): float
|
||||
{
|
||||
return match ($horizon) {
|
||||
6 => 0.85,
|
||||
12 => 0.75,
|
||||
24 => 0.60,
|
||||
48 => 0.45,
|
||||
default => 0.50,
|
||||
};
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
51
backend/src/Service/Weather/OpenMeteoClient.php
Normal file
51
backend/src/Service/Weather/OpenMeteoClient.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Weather;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Récupère les données de vent horaire depuis Open-Meteo (gratuit, sans clé).
|
||||
* Utilisé pour la simulation de dérive des sargasses.
|
||||
*/
|
||||
class OpenMeteoClient
|
||||
{
|
||||
private const BASE_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
|
||||
public function __construct(private HttpClientInterface $httpClient) {}
|
||||
|
||||
/**
|
||||
* Retourne les vecteurs de vent heure par heure pour un point géographique.
|
||||
* Période couverte : 3 jours (suffisant pour H+48).
|
||||
*
|
||||
* @return array<int, array{time: string, speed: float, direction: float}>
|
||||
* Tableau indexé par heure UTC (0..71)
|
||||
*/
|
||||
public function getHourlyWind(float $lat, float $lng): array
|
||||
{
|
||||
$response = $this->httpClient->request('GET', self::BASE_URL, [
|
||||
'query' => [
|
||||
'latitude' => $lat,
|
||||
'longitude' => $lng,
|
||||
'hourly' => 'wind_speed_10m,wind_direction_10m',
|
||||
'wind_speed_unit' => 'ms',
|
||||
'forecast_days' => 3,
|
||||
'timezone' => 'UTC',
|
||||
],
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
$hourly = $data['hourly'];
|
||||
|
||||
$result = [];
|
||||
foreach ($hourly['time'] as $i => $time) {
|
||||
$result[] = [
|
||||
'time' => $time,
|
||||
'speed' => (float) ($hourly['wind_speed_10m'][$i] ?? 0),
|
||||
'direction' => (float) ($hourly['wind_direction_10m'][$i] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user