275 lines
11 KiB
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',
|
|
};
|
|
}
|
|
}
|