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