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,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;
}
}

View File

@@ -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',
};
}
}

View 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,
};
}
}

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;
}
}

View 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;
}
}