feat: ETA côtier — transformer la map en outil de décision actionnable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-03 18:42:21 -04:00
parent da7d62fa6a
commit 744f76f85e
6 changed files with 137 additions and 24 deletions

View File

@@ -64,13 +64,13 @@ class SpotScoreController extends AbstractController
/**
* Score à un instant précis (sans cache).
*
* @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|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 loadScoreAt(string $spotId, \DateTimeImmutable $at): ?array
{
$row = $this->connection->fetchAssociative(
'SELECT score, level, distance_to_nearest_sargassum AS distance,
density_estimate AS density, trend, timestamp AS computed_at
density_estimate AS density, trend, eta_hours, timestamp AS computed_at
FROM impact_score
WHERE coastal_point_id = :spotId AND timestamp <= :at
ORDER BY timestamp DESC LIMIT 1',
@@ -131,16 +131,17 @@ class SpotScoreController extends AbstractController
/**
* @param array<string, mixed> $row
* @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, computedAt: string}
* @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string}
*/
private function formatScore(array $row): array
{
return [
'value' => (int) $row['score'],
'level' => $row['level'],
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
'density' => $row['density'] !== null ? (float) $row['density'] : null,
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
'density' => $row['density'] !== null ? (float) $row['density'] : null,
'trend' => $row['trend'],
'etaHours' => $row['eta_hours'] !== null ? (int) $row['eta_hours'] : null,
'computedAt' => $row['computed_at'],
];
}

View File

@@ -49,6 +49,13 @@ class ImpactScore
#[ORM\Column(length: 20, nullable: true)]
private ?string $trend = null;
/**
* Horizon (en heures) auquel les sargasses devraient atteindre le spot.
* 0 = impact en cours, null = aucun impact prévu dans les 48h.
*/
#[ORM\Column(nullable: true)]
private ?int $etaHours = null;
public function __construct()
{
$this->timestamp = new \DateTimeImmutable();
@@ -71,4 +78,6 @@ class ImpactScore
public function setDensityEstimate(?float $d): static { $this->densityEstimate = $d; return $this; }
public function getTrend(): ?string { return $this->trend; }
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; }
}

View File

@@ -31,6 +31,7 @@ class ImpactScoreService
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 CACHE_TTL = 10_800; // 3h
private const ETA_BUFFER_M = 5_000; // 5 km : seuil "impact imminent" sur le spot
// Bonus feedback : +8 pts si présence confirmée dans les 6h et dans rayon 20km
private const FEEDBACK_RADIUS_M = 20_000;
@@ -61,14 +62,13 @@ class ImpactScoreService
return;
}
$obsId = (string) $observation->getId();
$forecastH6 = $this->findForecastByHorizon($forecasts, 6);
$obsId = (string) $observation->getId();
foreach ($spots as $spot) {
$spotId = (string) $spot->getId();
try {
$score = $this->computeScore($spot, $obsId, $forecastH6);
$score = $this->computeScore($spot, $obsId, $forecasts);
$this->persistScore($spot, $score);
$this->invalidateCache($spotId);
@@ -90,7 +90,7 @@ class ImpactScoreService
/**
* Retourne le score mis en cache pour un spot (TTL 3h).
*
* @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|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
*/
public function getCachedScore(string $spotId): ?array
{
@@ -104,8 +104,11 @@ class ImpactScoreService
// -------------------------------------------------------------------------
/** @return array{score: int, level: string, distance: float|null, density: float, trend: string} */
private function computeScore(CoastalPoint $spot, string $obsId, ?SargassumForecast $forecastH6): array
/**
* @param array<int, SargassumForecast> $forecasts
* @return array{score: int, level: string, distance: float|null, density: float, trend: string, etaHours: int|null}
*/
private function computeScore(CoastalPoint $spot, string $obsId, array $forecasts): array
{
$spotId = (string) $spot->getId();
@@ -117,6 +120,7 @@ class ImpactScoreService
}
$coverageKm2 = $this->getCoverageArea($obsId);
$forecastH6 = $this->findForecastByHorizon($forecasts, 6);
// Vitesse d'approche : delta distance entre now et H+6 (km/h)
$approachKmh = 0.0;
@@ -130,17 +134,20 @@ class ImpactScoreService
// Tendance
$previousDistance = $this->getPreviousDistance($spotId);
$trend = match (true) {
$previousDistance === null => 'stable',
$previousDistance === null => 'stable',
$distanceM < $previousDistance * 0.9 => 'increasing',
$distanceM > $previousDistance * 1.1 => 'decreasing',
default => 'stable',
default => 'stable',
};
// ETA côtier
$etaHours = $this->computeEta($spotId, $distanceM, $forecasts);
// Calcul des composantes
$distanceScore = max(0.0, 1.0 - $distanceM / self::MAX_DISTANCE_M) * 40;
$densityScore = min($coverageKm2 / self::MAX_DENSITY_KM2, 1.0) * 30;
$velocityScore = min(max($approachKmh, 0.0) / self::MAX_APPROACH_KMH, 1.0) * 20;
$trendScore = match ($trend) { 'increasing' => 10, 'stable' => 5, default => 0 };
$distanceScore = max(0.0, 1.0 - $distanceM / self::MAX_DISTANCE_M) * 40;
$densityScore = min($coverageKm2 / self::MAX_DENSITY_KM2, 1.0) * 30;
$velocityScore = min(max($approachKmh, 0.0) / self::MAX_APPROACH_KMH, 1.0) * 20;
$trendScore = match ($trend) { 'increasing' => 10, 'stable' => 5, default => 0 };
// Bonus feedback terrain récent
$feedbackBonus = $this->getFeedbackBonus($spotId);
@@ -154,9 +161,37 @@ class ImpactScoreService
'distance' => round($distanceM / 1000, 2),
'density' => round($coverageKm2, 2),
'trend' => $trend,
'etaHours' => $etaHours,
];
}
/**
* Calcule l'horizon (en h) auquel les sargasses devraient atteindre le spot.
* Retourne 0 si impact déjà en cours, null si aucun impact prévu dans 48h.
*
* @param array<int, SargassumForecast> $forecasts
*/
private function computeEta(string $spotId, float $currentDistanceM, array $forecasts): ?int
{
if ($currentDistanceM < self::ETA_BUFFER_M) {
return 0; // impact en cours
}
foreach ([6, 12, 24, 48] as $horizon) {
$forecast = $this->findForecastByHorizon($forecasts, $horizon);
if ($forecast === null) {
continue;
}
$distM = $this->getDistanceToNearestForecast($spotId, (string) $forecast->getId());
if ($distM !== null && $distM < self::ETA_BUFFER_M) {
return $horizon;
}
}
return null;
}
private function getDistanceToNearestSargassum(string $spotId, string $obsId): ?float
{
$row = $this->connection->fetchAssociative(
@@ -221,7 +256,7 @@ class ImpactScoreService
: null;
}
/** @param array{score: int, level: string, distance: float|null, density: float, trend: string} $data */
/** @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
{
$s = new ImpactScore();
@@ -231,16 +266,17 @@ class ImpactScoreService
$s->setDistanceToNearestSargassum($data['distance']);
$s->setDensityEstimate($data['density']);
$s->setTrend($data['trend']);
$s->setEtaHours($data['etaHours']);
$this->em->persist($s);
}
/** @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|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
{
$row = $this->connection->fetchAssociative(
'SELECT score, level, distance_to_nearest_sargassum AS distance,
density_estimate AS density, trend, timestamp AS computed_at
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',
@@ -254,9 +290,10 @@ class ImpactScoreService
return [
'value' => (int) $row['score'],
'level' => $row['level'],
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
'density' => $row['density'] !== null ? (float) $row['density'] : null,
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
'density' => $row['density'] !== null ? (float) $row['density'] : null,
'trend' => $row['trend'],
'etaHours' => $row['eta_hours'] !== null ? (int) $row['eta_hours'] : null,
'computedAt' => $row['computed_at'],
];
}
@@ -280,10 +317,10 @@ class ImpactScoreService
};
}
/** @return array{score: int, level: string, distance: null, density: float, trend: string} */
/** @return array{score: int, level: string, distance: null, density: float, trend: string, etaHours: null} */
private function emptyScore(): array
{
return ['score' => 0, 'level' => 'low', 'distance' => null, 'density' => 0.0, 'trend' => 'stable'];
return ['score' => 0, 'level' => 'low', 'distance' => null, 'density' => 0.0, 'trend' => 'stable', 'etaHours' => null];
}
private function getFeedbackBonus(string $spotId): float