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