fix: 3 bugs introduits lors du refactoring DriftSimulation/--force

1. DriftSimulationService : suppression ST_Buffer(::geography) — produisait des
   géométries invalides/dégénérées sur grand MULTIPOLYGON → ST_Distance retournait
   652 029km. Remplacé par ST_Translate + ST_SimplifyPreserveTopology uniquement.

2. ImpactScoreService : filtre MAX_RELEVANT_M (300km) — une observation distante
   ne remplace plus le score d'un spot proche. Les scores existants sont préservés
   si l'observation est hors zone pertinente.

3. ComputeForecastsCommand --force : suppression du DELETE FROM impact_score.
   On ne purge plus les scores existants ; computeForObservation ajoute les
   nouveaux scores, les anciens restent valides pour les spots éloignés.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-03 20:25:49 -04:00
parent 1429eaa71c
commit a14782b067
3 changed files with 22 additions and 18 deletions

View File

@@ -70,8 +70,9 @@ class ComputeForecastsCommand extends Command
['ids' => $ids], ['ids' => $ids],
['ids' => \Doctrine\DBAL\ArrayParameterType::STRING] ['ids' => \Doctrine\DBAL\ArrayParameterType::STRING]
); );
// ImpactScore n'a pas de FK vers l'observation — on purge tout et on recalcule // Note : on ne supprime PAS les impact_scores existants.
$this->em->getConnection()->executeStatement('DELETE FROM impact_score'); // computeForObservation créera de nouveaux scores ; les anciens restent
// pour les spots où cette observation est trop loin (MAX_RELEVANT_M).
$this->em->clear(); $this->em->clear();
// Recharge les observations détachées // Recharge les observations détachées

View File

@@ -27,7 +27,6 @@ class DriftSimulationService
{ {
private const HORIZONS = [6, 12, 24, 48]; private const HORIZONS = [6, 12, 24, 48];
private const WIND_FACTOR = 0.03; // 3% vitesse vent = dérive de Stokes sargassum private const WIND_FACTOR = 0.03; // 3% vitesse vent = dérive de Stokes sargassum
private const DIFFUSION_M = 1500; // m — coefficient de diffusion (rayon du buffer à 1h)
private const MODEL_VERSION = '2.0.0'; private const MODEL_VERSION = '2.0.0';
public function __construct( public function __construct(
@@ -73,10 +72,7 @@ class DriftSimulationService
$dxDeg = $totalDxM / (111320.0 * cos(deg2rad($centroid['lat']))); $dxDeg = $totalDxM / (111320.0 * cos(deg2rad($centroid['lat'])));
$dyDeg = $totalDyM / 111320.0; $dyDeg = $totalDyM / 111320.0;
// Buffer de diffusion : croît comme √horizon (diffusion Brownienne) $geometry = $this->buildForecastGeometry($id, $dxDeg, $dyDeg);
$bufferM = self::DIFFUSION_M * sqrt($horizon);
$geometry = $this->buildForecastGeometry($id, $dxDeg, $dyDeg, $bufferM);
if ($geometry === null) { if ($geometry === null) {
$this->logger->info("No polygon for H+{$horizon}", ['id' => $id]); $this->logger->info("No polygon for H+{$horizon}", ['id' => $id]);
$prevHorizon = $horizon; $prevHorizon = $horizon;
@@ -122,29 +118,28 @@ class DriftSimulationService
} }
/** /**
* Traduit la géométrie source de $dxDeg/$dyDeg degrés, puis dilate de $bufferM mètres. * Traduit la géométrie source de $dxDeg/$dyDeg degrés via ST_Translate.
* *
* ST_Translate conserve la structure MULTIPOLYGON : chaque patch individuel * Pas de ST_Buffer ici : buffer sur geography produit des géométries volumineuses
* se déplace de façon identique → ST_Distance retourne la vraie distance * et invalides sur de grands MULTIPOLYGON, corrompant les calculs ST_Distance.
* au patch le plus proche, pas au centroïde global. * ST_Translate seul conserve la structure et la taille de chaque patch individuel.
*/ */
private function buildForecastGeometry( private function buildForecastGeometry(
string $observationId, string $observationId,
float $dxDeg, float $dxDeg,
float $dyDeg, float $dyDeg,
float $bufferM,
): ?string { ): ?string {
$row = $this->connection->fetchAssociative( $row = $this->connection->fetchAssociative(
'SELECT ST_AsText( 'SELECT ST_AsText(
ST_Multi( ST_Multi(
ST_Buffer( ST_SimplifyPreserveTopology(
ST_Translate(geometry, :dx, :dy)::geography, ST_Translate(geometry, :dx, :dy),
:buffer 0.001
)::geometry )
) )
) AS wkt ) AS wkt
FROM sargassum_observation WHERE id = :id', FROM sargassum_observation WHERE id = :id',
['id' => $observationId, 'dx' => $dxDeg, 'dy' => $dyDeg, 'buffer' => $bufferM] ['id' => $observationId, 'dx' => $dxDeg, 'dy' => $dyDeg]
); );
return ($row !== false && isset($row['wkt']) && $row['wkt'] !== null) return ($row !== false && isset($row['wkt']) && $row['wkt'] !== null)

View File

@@ -28,6 +28,7 @@ use Symfony\Contracts\Cache\ItemInterface;
class ImpactScoreService class ImpactScoreService
{ {
private const MAX_DISTANCE_M = 50_000; // 50 km : au-delà score distance = 0 private const MAX_DISTANCE_M = 50_000; // 50 km : au-delà score distance = 0
private const MAX_RELEVANT_M = 300_000; // 300 km : au-delà, on ne touche pas au score existant
private const MAX_DENSITY_KM2 = 100; // 100 km² = densité maximale 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 MAX_APPROACH_KMH = 5; // 5 km/h = vitesse d'approche max
private const CACHE_TTL = 10_800; // 3h private const CACHE_TTL = 10_800; // 3h
@@ -68,6 +69,13 @@ class ImpactScoreService
$spotId = (string) $spot->getId(); $spotId = (string) $spot->getId();
try { try {
// Ne pas écraser le score d'un spot si cette observation est trop loin.
// Cela évite qu'une ingestion distante remplace un bon score existant.
$distanceCheck = $this->getDistanceToNearestSargassum($spotId, $obsId);
if ($distanceCheck === null || $distanceCheck > self::MAX_RELEVANT_M) {
continue;
}
$score = $this->computeScore($spot, $obsId, $forecasts); $score = $this->computeScore($spot, $obsId, $forecasts);
$this->persistScore($spot, $score); $this->persistScore($spot, $score);
$this->invalidateCache($spotId); $this->invalidateCache($spotId);