From d60951cae8de63cf68e71d759b1aa08a4d046e78 Mon Sep 17 00:00:00 2001 From: Gwadaking Date: Wed, 8 Apr 2026 02:38:48 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20score=20bas=C3=A9=20sur=20l'observation?= =?UTF-8?q?=20la=20plus=20proche=20du=20spot,=20pas=20la=20derni=C3=A8re?= =?UTF-8?q?=20globale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit impact_score stocke source_observation_id — la lecture trie par distance(obs→spot) ASC pour garantir que Barbade n'écrase jamais Guadeloupe. Suppression du garde "closest wins" au write, remplacé par un ORDER BY déterministe au read. Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/Version20260408000000.php | 31 +++++++++++ backend/src/Entity/ImpactScore.php | 7 +++ .../Service/Forecast/ImpactScoreService.php | 51 +++++++++---------- 3 files changed, 62 insertions(+), 27 deletions(-) create mode 100644 backend/migrations/Version20260408000000.php diff --git a/backend/migrations/Version20260408000000.php b/backend/migrations/Version20260408000000.php new file mode 100644 index 0000000..eede796 --- /dev/null +++ b/backend/migrations/Version20260408000000.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE impact_score ADD COLUMN source_observation_id UUID NULL'); + $this->addSql('ALTER TABLE impact_score ADD CONSTRAINT fk_impact_score_observation + FOREIGN KEY (source_observation_id) REFERENCES sargassum_observation(id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX idx_impact_score_source_obs ON impact_score (source_observation_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX idx_impact_score_source_obs'); + $this->addSql('ALTER TABLE impact_score DROP CONSTRAINT fk_impact_score_observation'); + $this->addSql('ALTER TABLE impact_score DROP COLUMN source_observation_id'); + } +} diff --git a/backend/src/Entity/ImpactScore.php b/backend/src/Entity/ImpactScore.php index cfca589..ebdf634 100644 --- a/backend/src/Entity/ImpactScore.php +++ b/backend/src/Entity/ImpactScore.php @@ -31,6 +31,11 @@ class ImpactScore #[ORM\JoinColumn(nullable: false)] private CoastalPoint $coastalPoint; + /** Observation source ayant produit ce score — permet de trier par distance observation→spot. */ + #[ORM\ManyToOne] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + private ?SargassumObservation $sourceObservation = null; + #[ORM\Column] private \DateTimeImmutable $timestamp; @@ -80,4 +85,6 @@ class ImpactScore public function setTrend(?string $trend): static { $this->trend = $trend; return $this; } public function getEtaHours(): ?int { return $this->etaHours; } public function setEtaHours(?int $etaHours): static { $this->etaHours = $etaHours; return $this; } + public function getSourceObservation(): ?SargassumObservation { return $this->sourceObservation; } + public function setSourceObservation(?SargassumObservation $obs): static { $this->sourceObservation = $obs; return $this; } } diff --git a/backend/src/Service/Forecast/ImpactScoreService.php b/backend/src/Service/Forecast/ImpactScoreService.php index d6c6ee4..d26645b 100644 --- a/backend/src/Service/Forecast/ImpactScoreService.php +++ b/backend/src/Service/Forecast/ImpactScoreService.php @@ -77,7 +77,7 @@ class ImpactScoreService } $score = $this->computeScore($spot, $obsId, $forecasts); - $this->persistScore($spot, $score); + $this->persistScore($spot, $score, $observation); $this->invalidateCache($spotId); // Alerte push si score passe en "high" @@ -264,28 +264,11 @@ class ImpactScoreService : null; } - /** @param array{score: int, level: string, distance: float|null, density: float, trend: string, etaHours: int|null} $data */ - private function persistScore(CoastalPoint $spot, array $data): void + /** + * @param array{score: int, level: string, distance: float|null, density: float, trend: string, etaHours: int|null} $data + */ + private function persistScore(CoastalPoint $spot, array $data, SargassumObservation $observation): void { - // "L'observation la plus proche gagne" : ne pas écraser un score avec une distance - // plus grande que le score actuel, sauf si celui-ci a plus de 6h (stale). - if ($data['distance'] !== null) { - $current = $this->connection->fetchAssociative( - 'SELECT distance_to_nearest_sargassum AS dist, timestamp - FROM impact_score WHERE coastal_point_id = :id ORDER BY timestamp DESC LIMIT 1', - ['id' => (string) $spot->getId()] - ); - if ($current !== false && $current['dist'] !== null) { - $ageSeconds = (new \DateTimeImmutable())->getTimestamp() - - (new \DateTimeImmutable($current['timestamp']))->getTimestamp(); - $isStale = $ageSeconds > 21_600; // 6h - $isCloser = $data['distance'] < (float) $current['dist']; - if (!$isStale && !$isCloser) { - return; // garder le score de l'observation plus proche - } - } - } - $s = new ImpactScore(); $s->setCoastalPoint($spot); $s->setScore($data['score']); @@ -294,6 +277,7 @@ class ImpactScoreService $s->setDensityEstimate($data['density']); $s->setTrend($data['trend']); $s->setEtaHours($data['etaHours']); + $s->setSourceObservation($observation); $this->em->persist($s); } @@ -301,12 +285,25 @@ class ImpactScoreService /** @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string}|null */ private function loadLatestScore(string $spotId): ?array { + // Parmi tous les scores récents (7j), prendre celui dont l'observation source + // est la plus proche du spot — déterministe, sans garde arbitraire. + // Les anciens scores sans source_observation_id sont conservés en fallback. $row = $this->connection->fetchAssociative( - 'SELECT score, level, distance_to_nearest_sargassum AS distance, - density_estimate AS density, trend, eta_hours, timestamp AS computed_at - FROM impact_score - WHERE coastal_point_id = :spotId - ORDER BY timestamp DESC LIMIT 1', + 'SELECT ist.score, ist.level, + ist.distance_to_nearest_sargassum AS distance, + ist.density_estimate AS density, ist.trend, ist.eta_hours, + ist.timestamp AS computed_at + FROM impact_score ist + LEFT JOIN sargassum_observation o ON o.id = ist.source_observation_id + JOIN coastal_point cp ON cp.id = :spotId + WHERE ist.coastal_point_id = :spotId + AND (o.detected_at IS NULL OR o.detected_at > NOW() - INTERVAL \'7 days\') + ORDER BY + CASE WHEN ist.source_observation_id IS NOT NULL + THEN ST_Distance(o.geometry::geography, cp.geometry::geography) + ELSE 999999999.0 END ASC, + ist.timestamp DESC + LIMIT 1', ['spotId' => $spotId] );