fix: score basé sur l'observation la plus proche du spot, pas la dernière globale

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 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-08 02:38:48 -04:00
parent 51ca910e11
commit d60951cae8
3 changed files with 62 additions and 27 deletions

View File

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

View File

@@ -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]
);