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|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 $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|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', }; } }