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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<script type="module" crossorigin src="/assets/index-Brx4kn-A.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CLFkLo67.css">
<script type="module" crossorigin src="/assets/index-BV2EXFwR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jpyCrywh.css">
</head>
<body>
<div id="root"></div>

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