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:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user