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:
26
backend/migrations/Version20260403000000.php
Normal file
26
backend/migrations/Version20260403000000.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260403000000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Ajout eta_hours sur impact_score — horizon prévu d\'impact côtier';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE impact_score ADD COLUMN eta_hours INT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE impact_score DROP COLUMN IF EXISTS eta_hours');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,13 +64,13 @@ class SpotScoreController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Score à un instant précis (sans cache).
|
* 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
|
private function loadScoreAt(string $spotId, \DateTimeImmutable $at): ?array
|
||||||
{
|
{
|
||||||
$row = $this->connection->fetchAssociative(
|
$row = $this->connection->fetchAssociative(
|
||||||
'SELECT score, level, distance_to_nearest_sargassum AS distance,
|
'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
|
FROM impact_score
|
||||||
WHERE coastal_point_id = :spotId AND timestamp <= :at
|
WHERE coastal_point_id = :spotId AND timestamp <= :at
|
||||||
ORDER BY timestamp DESC LIMIT 1',
|
ORDER BY timestamp DESC LIMIT 1',
|
||||||
@@ -131,7 +131,7 @@ class SpotScoreController extends AbstractController
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $row
|
* @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
|
private function formatScore(array $row): array
|
||||||
{
|
{
|
||||||
@@ -141,6 +141,7 @@ class SpotScoreController extends AbstractController
|
|||||||
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
|
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
|
||||||
'density' => $row['density'] !== null ? (float) $row['density'] : null,
|
'density' => $row['density'] !== null ? (float) $row['density'] : null,
|
||||||
'trend' => $row['trend'],
|
'trend' => $row['trend'],
|
||||||
|
'etaHours' => $row['eta_hours'] !== null ? (int) $row['eta_hours'] : null,
|
||||||
'computedAt' => $row['computed_at'],
|
'computedAt' => $row['computed_at'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ class ImpactScore
|
|||||||
#[ORM\Column(length: 20, nullable: true)]
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
private ?string $trend = null;
|
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()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->timestamp = new \DateTimeImmutable();
|
$this->timestamp = new \DateTimeImmutable();
|
||||||
@@ -71,4 +78,6 @@ class ImpactScore
|
|||||||
public function setDensityEstimate(?float $d): static { $this->densityEstimate = $d; return $this; }
|
public function setDensityEstimate(?float $d): static { $this->densityEstimate = $d; return $this; }
|
||||||
public function getTrend(): ?string { return $this->trend; }
|
public function getTrend(): ?string { return $this->trend; }
|
||||||
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 setEtaHours(?int $etaHours): static { $this->etaHours = $etaHours; return $this; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class ImpactScoreService
|
|||||||
private const MAX_DENSITY_KM2 = 100; // 100 km² = densité maximale
|
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 MAX_APPROACH_KMH = 5; // 5 km/h = vitesse d'approche max
|
||||||
private const CACHE_TTL = 10_800; // 3h
|
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
|
// Bonus feedback : +8 pts si présence confirmée dans les 6h et dans rayon 20km
|
||||||
private const FEEDBACK_RADIUS_M = 20_000;
|
private const FEEDBACK_RADIUS_M = 20_000;
|
||||||
@@ -62,13 +63,12 @@ class ImpactScoreService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$obsId = (string) $observation->getId();
|
$obsId = (string) $observation->getId();
|
||||||
$forecastH6 = $this->findForecastByHorizon($forecasts, 6);
|
|
||||||
|
|
||||||
foreach ($spots as $spot) {
|
foreach ($spots as $spot) {
|
||||||
$spotId = (string) $spot->getId();
|
$spotId = (string) $spot->getId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$score = $this->computeScore($spot, $obsId, $forecastH6);
|
$score = $this->computeScore($spot, $obsId, $forecasts);
|
||||||
$this->persistScore($spot, $score);
|
$this->persistScore($spot, $score);
|
||||||
$this->invalidateCache($spotId);
|
$this->invalidateCache($spotId);
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ class ImpactScoreService
|
|||||||
/**
|
/**
|
||||||
* Retourne le score mis en cache pour un spot (TTL 3h).
|
* 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
|
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();
|
$spotId = (string) $spot->getId();
|
||||||
|
|
||||||
@@ -117,6 +120,7 @@ class ImpactScoreService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$coverageKm2 = $this->getCoverageArea($obsId);
|
$coverageKm2 = $this->getCoverageArea($obsId);
|
||||||
|
$forecastH6 = $this->findForecastByHorizon($forecasts, 6);
|
||||||
|
|
||||||
// Vitesse d'approche : delta distance entre now et H+6 (km/h)
|
// Vitesse d'approche : delta distance entre now et H+6 (km/h)
|
||||||
$approachKmh = 0.0;
|
$approachKmh = 0.0;
|
||||||
@@ -136,6 +140,9 @@ class ImpactScoreService
|
|||||||
default => 'stable',
|
default => 'stable',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ETA côtier
|
||||||
|
$etaHours = $this->computeEta($spotId, $distanceM, $forecasts);
|
||||||
|
|
||||||
// Calcul des composantes
|
// Calcul des composantes
|
||||||
$distanceScore = max(0.0, 1.0 - $distanceM / self::MAX_DISTANCE_M) * 40;
|
$distanceScore = max(0.0, 1.0 - $distanceM / self::MAX_DISTANCE_M) * 40;
|
||||||
$densityScore = min($coverageKm2 / self::MAX_DENSITY_KM2, 1.0) * 30;
|
$densityScore = min($coverageKm2 / self::MAX_DENSITY_KM2, 1.0) * 30;
|
||||||
@@ -154,9 +161,37 @@ class ImpactScoreService
|
|||||||
'distance' => round($distanceM / 1000, 2),
|
'distance' => round($distanceM / 1000, 2),
|
||||||
'density' => round($coverageKm2, 2),
|
'density' => round($coverageKm2, 2),
|
||||||
'trend' => $trend,
|
'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
|
private function getDistanceToNearestSargassum(string $spotId, string $obsId): ?float
|
||||||
{
|
{
|
||||||
$row = $this->connection->fetchAssociative(
|
$row = $this->connection->fetchAssociative(
|
||||||
@@ -221,7 +256,7 @@ class ImpactScoreService
|
|||||||
: null;
|
: 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
|
private function persistScore(CoastalPoint $spot, array $data): void
|
||||||
{
|
{
|
||||||
$s = new ImpactScore();
|
$s = new ImpactScore();
|
||||||
@@ -231,16 +266,17 @@ class ImpactScoreService
|
|||||||
$s->setDistanceToNearestSargassum($data['distance']);
|
$s->setDistanceToNearestSargassum($data['distance']);
|
||||||
$s->setDensityEstimate($data['density']);
|
$s->setDensityEstimate($data['density']);
|
||||||
$s->setTrend($data['trend']);
|
$s->setTrend($data['trend']);
|
||||||
|
$s->setEtaHours($data['etaHours']);
|
||||||
|
|
||||||
$this->em->persist($s);
|
$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
|
private function loadLatestScore(string $spotId): ?array
|
||||||
{
|
{
|
||||||
$row = $this->connection->fetchAssociative(
|
$row = $this->connection->fetchAssociative(
|
||||||
'SELECT score, level, distance_to_nearest_sargassum AS distance,
|
'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
|
FROM impact_score
|
||||||
WHERE coastal_point_id = :spotId
|
WHERE coastal_point_id = :spotId
|
||||||
ORDER BY timestamp DESC LIMIT 1',
|
ORDER BY timestamp DESC LIMIT 1',
|
||||||
@@ -257,6 +293,7 @@ class ImpactScoreService
|
|||||||
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
|
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
|
||||||
'density' => $row['density'] !== null ? (float) $row['density'] : null,
|
'density' => $row['density'] !== null ? (float) $row['density'] : null,
|
||||||
'trend' => $row['trend'],
|
'trend' => $row['trend'],
|
||||||
|
'etaHours' => $row['eta_hours'] !== null ? (int) $row['eta_hours'] : null,
|
||||||
'computedAt' => $row['computed_at'],
|
'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
|
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
|
private function getFeedbackBonus(string $spotId): float
|
||||||
|
|||||||
@@ -124,6 +124,27 @@
|
|||||||
.gauge--medium .spot-panel__gauge-fill { background: #eab308; }
|
.gauge--medium .spot-panel__gauge-fill { background: #eab308; }
|
||||||
.gauge--high .spot-panel__gauge-fill { background: #ef4444; }
|
.gauge--high .spot-panel__gauge-fill { background: #ef4444; }
|
||||||
|
|
||||||
|
/* ETA */
|
||||||
|
.spot-panel__eta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 2px solid rgba(0,0,0,0.15);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: 'Fredoka One', cursive;
|
||||||
|
}
|
||||||
|
.eta--now { background: #fee2e2; color: #b91c1c; border-color: #fca5a5; }
|
||||||
|
.eta--soon { background: #fef9c3; color: #92400e; border-color: #fde68a; }
|
||||||
|
.eta--later { background: #fff7ed; color: #9a3412; border-color: #fed7aa; }
|
||||||
|
.eta--safe { background: #dcfce7; color: #15803d; border-color: #86efac; }
|
||||||
|
|
||||||
|
.spot-panel__eta-icon { font-size: 20px; line-height: 1; flex-shrink: 0; }
|
||||||
|
.spot-panel__eta-label { font-family: 'Fredoka', sans-serif; font-weight: 600; }
|
||||||
|
|
||||||
/* Trend */
|
/* Trend */
|
||||||
.spot-panel__trend {
|
.spot-panel__trend {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ const TREND_CONFIG = {
|
|||||||
decreasing: { icon: '📉', label: 'Amélioration en cours', cls: 'trend--good' },
|
decreasing: { icon: '📉', label: 'Amélioration en cours', cls: 'trend--good' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function etaLabel(etaHours) {
|
||||||
|
if (etaHours === 0) return { icon: '🚨', label: 'Impact en cours', cls: 'eta--now' };
|
||||||
|
if (etaHours === 6) return { icon: '⚠️', label: 'Impact prévu dans ~6h', cls: 'eta--soon' };
|
||||||
|
if (etaHours === 12) return { icon: '⚠️', label: 'Impact prévu dans ~12h', cls: 'eta--soon' };
|
||||||
|
if (etaHours === 24) return { icon: '🕐', label: 'Impact prévu dans ~24h', cls: 'eta--later' };
|
||||||
|
if (etaHours === 48) return { icon: '🕐', label: 'Impact prévu dans ~48h', cls: 'eta--later' };
|
||||||
|
return { icon: '✅', label: 'Aucun impact prévu (48h)', cls: 'eta--safe' };
|
||||||
|
}
|
||||||
|
|
||||||
const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' };
|
const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' };
|
||||||
|
|
||||||
export default function SpotPanel({ spot, activeStep, onClose }) {
|
export default function SpotPanel({ spot, activeStep, onClose }) {
|
||||||
@@ -72,6 +81,16 @@ export default function SpotPanel({ spot, activeStep, onClose }) {
|
|||||||
? <p className="spot-panel__no-data">Aucune donnée pour cet horizon.</p>
|
? <p className="spot-panel__no-data">Aucune donnée pour cet horizon.</p>
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
|
{activeStep === 'now' && (() => {
|
||||||
|
const eta = etaLabel(displayScore.etaHours ?? undefined);
|
||||||
|
return (
|
||||||
|
<div className={`spot-panel__eta ${eta.cls}`}>
|
||||||
|
<span className="spot-panel__eta-icon">{eta.icon}</span>
|
||||||
|
<span className="spot-panel__eta-label">{eta.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<div className={`spot-panel__gauge gauge--${lvl}`}>
|
<div className={`spot-panel__gauge gauge--${lvl}`}>
|
||||||
<div className="spot-panel__gauge-top">
|
<div className="spot-panel__gauge-top">
|
||||||
<span className="spot-panel__gauge-label">{LEVEL_LABEL[lvl] ?? lvl}</span>
|
<span className="spot-panel__gauge-label">{LEVEL_LABEL[lvl] ?? lvl}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user