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
backend/migrations/Version20260408000000.php
Normal file
31
backend/migrations/Version20260408000000.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260408000000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add source_observation_id to impact_score for nearest-observation scoring';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,11 @@ class ImpactScore
|
|||||||
#[ORM\JoinColumn(nullable: false)]
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
private CoastalPoint $coastalPoint;
|
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]
|
#[ORM\Column]
|
||||||
private \DateTimeImmutable $timestamp;
|
private \DateTimeImmutable $timestamp;
|
||||||
|
|
||||||
@@ -80,4 +85,6 @@ class ImpactScore
|
|||||||
public function setTrend(?string $trend): static { $this->trend = $trend; return $this; }
|
public function setTrend(?string $trend): static { $this->trend = $trend; return $this; }
|
||||||
public function getEtaHours(): ?int { return $this->etaHours; }
|
public function getEtaHours(): ?int { return $this->etaHours; }
|
||||||
public function setEtaHours(?int $etaHours): static { $this->etaHours = $etaHours; return $this; }
|
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);
|
$score = $this->computeScore($spot, $obsId, $forecasts);
|
||||||
$this->persistScore($spot, $score);
|
$this->persistScore($spot, $score, $observation);
|
||||||
$this->invalidateCache($spotId);
|
$this->invalidateCache($spotId);
|
||||||
|
|
||||||
// Alerte push si score passe en "high"
|
// Alerte push si score passe en "high"
|
||||||
@@ -264,28 +264,11 @@ class ImpactScoreService
|
|||||||
: null;
|
: 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 = new ImpactScore();
|
||||||
$s->setCoastalPoint($spot);
|
$s->setCoastalPoint($spot);
|
||||||
$s->setScore($data['score']);
|
$s->setScore($data['score']);
|
||||||
@@ -294,6 +277,7 @@ class ImpactScoreService
|
|||||||
$s->setDensityEstimate($data['density']);
|
$s->setDensityEstimate($data['density']);
|
||||||
$s->setTrend($data['trend']);
|
$s->setTrend($data['trend']);
|
||||||
$s->setEtaHours($data['etaHours']);
|
$s->setEtaHours($data['etaHours']);
|
||||||
|
$s->setSourceObservation($observation);
|
||||||
|
|
||||||
$this->em->persist($s);
|
$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 */
|
/** @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
|
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(
|
$row = $this->connection->fetchAssociative(
|
||||||
'SELECT score, level, distance_to_nearest_sargassum AS distance,
|
'SELECT ist.score, ist.level,
|
||||||
density_estimate AS density, trend, eta_hours, timestamp AS computed_at
|
ist.distance_to_nearest_sargassum AS distance,
|
||||||
FROM impact_score
|
ist.density_estimate AS density, ist.trend, ist.eta_hours,
|
||||||
WHERE coastal_point_id = :spotId
|
ist.timestamp AS computed_at
|
||||||
ORDER BY timestamp DESC LIMIT 1',
|
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]
|
['spotId' => $spotId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user