Files
radarsargasses/backend/src/Controller/SpotScoreController.php

275 lines
11 KiB
PHP

<?php
namespace App\Controller;
use App\Entity\CoastalPoint;
use App\Service\Forecast\ImpactScoreService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/spots', name: 'api_spots_')]
class SpotScoreController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private ImpactScoreService $scoreService,
private Connection $connection,
) {}
/**
* GET /api/spots/{id}/score
*
* Retourne le score courant + les scores par horizon (H+6/12/24/48).
* Utilise le cache Redis (TTL 3h).
* Paramètre optionnel : ?at=YYYY-MM-DDTHH:MM:SSZ
*/
#[Route('/{id}/score', name: 'score', methods: ['GET'])]
public function score(string $id, Request $request): JsonResponse
{
$spot = $this->em->getRepository(CoastalPoint::class)->find($id);
if ($spot === null) {
return $this->json(['error' => 'Spot not found'], 404);
}
$atParam = $request->query->get('at');
// Si ?at= spécifié, on ne peut pas utiliser le cache
if ($atParam !== null) {
try {
$at = new \DateTimeImmutable($atParam);
} catch (\Exception) {
return $this->json(['error' => 'Invalid "at" parameter format'], 400);
}
$current = $this->loadScoreAt($id, $at);
} else {
$current = $this->scoreService->getCachedScore($id);
}
$patches = $this->loadPatches($id);
// La distance issue des patches (ST_Dump live) est plus fiable que
// distance_to_nearest_sargassum stockée (peut venir d'une obs lointaine).
// On l'utilise comme référence si des patches sont détectés à proximité.
if ($current !== null && !empty($patches)) {
$minPatchDist = min(array_column($patches, 'distanceKm'));
$current['distance'] = $minPatchDist;
}
$currentDistanceKm = $current !== null ? $current['distance'] : null;
return $this->json([
'spotId' => (string) $spot->getId(),
'name' => $spot->getName(),
'type' => $spot->getType(),
'region' => $spot->getRegion(),
'score' => $current,
'patches' => $patches,
'horizons' => $this->loadHorizonScores($id, $currentDistanceKm),
]);
}
// -------------------------------------------------------------------------
/**
* Score à un instant précis (sans cache).
*
* @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, eta_hours, timestamp AS computed_at
FROM impact_score
WHERE coastal_point_id = :spotId AND timestamp <= :at
ORDER BY timestamp DESC LIMIT 1',
['spotId' => $spotId, 'at' => $at->format('Y-m-d H:i:s')]
);
return $row !== false ? $this->formatScore($row) : null;
}
/**
* Scores estimés par horizon — même formule 4 composantes que computeScore() :
* distance (40pts) + densité observation (30pts) + vitesse d'approche (20pts) + tendance (10pts)
* Le tout multiplié par la confiance du modèle (décroissante avec l'horizon).
*
* @return array<string, array{score: int, level: string, distanceKm: float, confidence: float, validAt: string}>|null
*/
private function loadHorizonScores(string $spotId, ?float $currentDistanceKm): ?array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT
f.time_horizon AS horizon,
ROUND(CAST(ST_Distance(
cp.geometry::geography,
f.geometry::geography
) / 1000 AS numeric), 2) AS distance_km,
f.confidence,
f.valid_at,
COALESCE(o.coverage_area, 0) AS coverage_area
FROM sargassum_forecast f
JOIN coastal_point cp ON cp.id = :spotId
JOIN sargassum_observation o ON o.id = f.source_observation_id
WHERE f.source_observation_id = (
SELECT o2.id FROM sargassum_observation o2
INNER JOIN sargassum_forecast ff ON ff.source_observation_id = o2.id,
coastal_point cp3
WHERE cp3.id = :spotId
AND ST_Distance(o2.geometry::geography, cp3.geometry::geography) < 300000
ORDER BY o2.detected_at DESC LIMIT 1
)
ORDER BY f.time_horizon ASC',
['spotId' => $spotId]
);
if (empty($rows)) {
return null;
}
$horizons = [];
$prevDistKm = $currentDistanceKm; // distance de référence = "now"
$prevHorizon = 0;
foreach ($rows as $row) {
$distKm = (float) $row['distance_km'];
$distM = $distKm * 1000;
$confidence = (float) $row['confidence'];
$coverageKm2 = (float) $row['coverage_area'];
$horizon = (int) $row['horizon'];
// Composante distance (même seuil 50km que computeScore)
$distanceScore = max(0.0, 1.0 - $distM / 50_000) * 40;
// Composante densité : même observation source, coverage identique
$densityScore = min($coverageKm2 / 100, 1.0) * 30;
// Composante vitesse d'approche par rapport à l'horizon précédent
$hoursElapsed = max($horizon - $prevHorizon, 1);
$approachKmh = $prevDistKm !== null
? ($prevDistKm - $distKm) / $hoursElapsed
: 0.0;
$velocityScore = min(max($approachKmh, 0.0) / 5.0, 1.0) * 20;
// Composante tendance
$trendScore = match (true) {
$approachKmh > 0.2 => 10, // approche
$approachKmh < -0.2 => 0, // s'éloigne
default => 5, // stable
};
// Confiance du modèle appliquée à l'ensemble (l'observation "now" est certaine, pas la prévision)
$raw = $distanceScore + $densityScore + $velocityScore + $trendScore;
$score = max(0, min(100, (int) round($raw * $confidence)));
$cf = fn(float $v): int => (int) round($v * $confidence);
$horizons['h' . $horizon] = [
'score' => $score,
'level' => $this->scoreToLevel($score),
'distanceKm' => $distKm,
'confidence' => $confidence,
'validAt' => $row['valid_at'],
'breakdown' => [
'distance' => $cf($distanceScore),
'density' => $cf($densityScore),
'velocity' => $cf($velocityScore),
'trend' => $cf($trendScore),
],
];
$prevDistKm = $distKm;
$prevHorizon = $horizon;
}
return $horizons;
}
/**
* @param array<string, mixed> $row
* @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string, breakdown: array{distance: int, density: int, velocity: int, trend: int}|null}
*/
private function formatScore(array $row): array
{
$distKm = $row['distance'] !== null ? (float) $row['distance'] : null;
$densKm2 = $row['density'] !== null ? (float) $row['density'] : null;
$trend = $row['trend'];
$total = (int) $row['score'];
$breakdown = null;
if ($distKm !== null && $densKm2 !== null && $trend !== null) {
$dScore = (int) round(max(0.0, 1.0 - $distKm * 1000 / 50_000) * 40);
$nScore = (int) round(min($densKm2 / 100, 1.0) * 30);
$tScore = match ($trend) { 'increasing' => 10, 'stable' => 5, default => 0 };
$vScore = max(0, $total - $dScore - $nScore - $tScore);
$breakdown = ['distance' => $dScore, 'density' => $nScore, 'velocity' => $vScore, 'trend' => $tScore];
}
return [
'value' => $total,
'level' => $row['level'],
'distance' => $distKm,
'density' => $densKm2,
'trend' => $trend,
'etaHours' => $row['eta_hours'] !== null ? (int) $row['eta_hours'] : null,
'computedAt' => $row['computed_at'],
'breakdown' => $breakdown,
];
}
/**
* Décompose la dernière observation en patches individuels (ST_Dump)
* et retourne les 5 plus proches du spot dans un rayon de 200 km.
*
* @return array<int, array{areaKm2: float, distanceKm: float}>|null
*/
private function loadPatches(string $spotId): ?array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT
ROUND(CAST(ST_Area(dump.geom::geography) / 1000000 AS numeric), 2) AS area_km2,
ROUND(CAST(ST_Distance(cp.geometry::geography, dump.geom::geography) / 1000 AS numeric), 2) AS distance_km
FROM coastal_point cp
CROSS JOIN LATERAL (
SELECT (ST_Dump(o.geometry)).geom AS geom
FROM sargassum_observation o
WHERE o.id = (
SELECT o2.id FROM sargassum_observation o2
INNER JOIN sargassum_forecast ff ON ff.source_observation_id = o2.id,
coastal_point cp3
WHERE cp3.id = :spotId
AND ST_Distance(o2.geometry::geography, cp3.geometry::geography) < 300000
ORDER BY o2.detected_at DESC LIMIT 1
)
) dump
WHERE cp.id = :spotId
AND ST_Distance(cp.geometry::geography, dump.geom::geography) < 200000
ORDER BY distance_km ASC
LIMIT 5',
['spotId' => $spotId]
);
if (empty($rows)) {
return null;
}
return array_map(
fn(array $r) => ['areaKm2' => (float) $r['area_km2'], 'distanceKm' => (float) $r['distance_km']],
$rows
);
}
private function scoreToLevel(int $score): string
{
return match (true) {
$score <= 30 => 'low',
$score <= 70 => 'medium',
default => 'high',
};
}
}