feat: diffusion turbulente + recalcul forcé — polygones qui évoluent dans le temps

- DriftSimulationService : ajout diffusion turbulente 600m/h (marche aléatoire
  indépendante par point) → les patches se dispersent et déforment au fil des horizons
  au lieu d'une simple translation rigide avec les alizés constants
- SAMPLE_POINTS 30→40, CONCAVITY 0.8→0.7, MODEL_VERSION 1.0→1.1
- ComputeForecastsCommand : option --force pour supprimer et recalculer les
  forecasts existants (utile après changement de modèle)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-03 20:03:51 -04:00
parent 492ed02b6f
commit fb25142bad
2 changed files with 49 additions and 13 deletions

View File

@@ -29,32 +29,60 @@ class ComputeForecastsCommand extends Command
protected function configure(): void protected function configure(): void
{ {
$this->addOption('limit', 'l', InputOption::VALUE_OPTIONAL, $this
'Nombre max d\'observations à traiter', 5); ->addOption('limit', 'l', InputOption::VALUE_OPTIONAL,
'Nombre max d\'observations à traiter', 5)
->addOption('force', null, InputOption::VALUE_NONE,
'Supprime les forecasts existants et les recalcule (utile après changement de modèle)');
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$limit = (int) $input->getOption('limit'); $limit = (int) $input->getOption('limit');
$force = (bool) $input->getOption('force');
$io->title('Calcul forecasts + ImpactScores'); $io->title('Calcul forecasts + ImpactScores');
// Observations récentes sans forecast // Charge les N observations cibles (avec ou sans forecasts existants)
$observations = $this->em->getRepository(SargassumObservation::class) $qb = $this->em->getRepository(SargassumObservation::class)
->createQueryBuilder('o') ->createQueryBuilder('o')
->leftJoin('o.forecasts', 'f')
->where('f.id IS NULL')
->orderBy('o.detectedAt', 'DESC') ->orderBy('o.detectedAt', 'DESC')
->setMaxResults($limit) ->setMaxResults($limit);
->getQuery()
->getResult(); if (!$force) {
$qb->leftJoin('o.forecasts', 'f')->where('f.id IS NULL');
}
$observations = $qb->getQuery()->getResult();
if (empty($observations)) { if (empty($observations)) {
$io->info('Aucune observation sans forecast trouvée.'); $io->info('Aucune observation sans forecast trouvée.');
return Command::SUCCESS; return Command::SUCCESS;
} }
if ($force) {
$ids = array_map(fn($o) => (string) $o->getId(), $observations);
$io->note(sprintf('--force : recalcul pour %d observation(s)...', count($ids)));
$this->em->getConnection()->executeStatement(
'DELETE FROM sargassum_forecast WHERE source_observation_id IN (:ids)',
['ids' => $ids],
['ids' => \Doctrine\DBAL\ArrayParameterType::STRING]
);
// ImpactScore n'a pas de FK vers l'observation — on purge tout et on recalcule
$this->em->getConnection()->executeStatement('DELETE FROM impact_score');
$this->em->clear();
// Recharge les observations détachées
$observations = $this->em->getRepository(SargassumObservation::class)
->createQueryBuilder('o')
->where('o.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult();
}
$io->progressStart(count($observations)); $io->progressStart(count($observations));
$errors = 0; $errors = 0;

View File

@@ -22,10 +22,11 @@ use Psr\Log\LoggerInterface;
class DriftSimulationService class DriftSimulationService
{ {
private const HORIZONS = [6, 12, 24, 48]; private const HORIZONS = [6, 12, 24, 48];
private const SAMPLE_POINTS = 30; private const SAMPLE_POINTS = 40;
private const WIND_FACTOR = 0.03; // 3% du vent = dérive Stokes private const WIND_FACTOR = 0.03; // 3% du vent = dérive Stokes
private const MODEL_VERSION = '1.0.0'; private const MODEL_VERSION = '1.1.0';
private const CONCAVITY = 0.8; // paramètre ST_ConcaveHull (0=convex, 1=très concave) private const CONCAVITY = 0.7; // paramètre ST_ConcaveHull (0=convex, 1=très concave)
private const DIFFUSION_M = 600; // m/heure — diffusion turbulente horizontale (marche aléatoire)
public function __construct( public function __construct(
private OpenMeteoClient $weather, private OpenMeteoClient $weather,
@@ -155,9 +156,16 @@ class DriftSimulationService
$dy = $speed * cos($dirRad) * 3600; // Nord-Sud (m) $dy = $speed * cos($dirRad) * 3600; // Nord-Sud (m)
foreach ($points as &$pt) { foreach ($points as &$pt) {
// Conversion m → degrés // Advection : conversion m → degrés
$pt['lat'] += $dy / 111320; $pt['lat'] += $dy / 111320;
$pt['lng'] += $dx / (111320 * cos(deg2rad($pt['lat']))); $pt['lng'] += $dx / (111320 * cos(deg2rad($pt['lat'])));
// Diffusion turbulente : marche aléatoire indépendante par point
// Simule la dispersion naturelle du patch sous l'effet des courants de méso-échelle
$perturbLat = (mt_rand(-1000, 1000) / 1000.0) * self::DIFFUSION_M / 111320;
$perturbLng = (mt_rand(-1000, 1000) / 1000.0) * self::DIFFUSION_M / (111320 * cos(deg2rad($pt['lat'])));
$pt['lat'] += $perturbLat;
$pt['lng'] += $perturbLng;
} }
unset($pt); unset($pt);
} }