em->getRepository(CoastalPoint::class)->findAll(); if (empty($spots)) { $this->logger->info('No coastal points found, skipping score computation'); return; } $obsId = (string) $observation->getId(); $forecastH6 = $this->findForecastByHorizon($forecasts, 6); foreach ($spots as $spot) { $spotId = (string) $spot->getId(); try { $score = $this->computeScore($spot, $obsId, $forecastH6); $this->persistScore($spot, $score); $this->invalidateCache($spotId); } catch (\Throwable $e) { $this->logger->error('Score computation failed', [ 'spot' => $spot->getName(), 'error' => $e->getMessage(), ]); } } $this->em->flush(); } /** * Retourne le score mis en cache pour un spot (TTL 3h). */ public function getCachedScore(string $spotId): ?array { $key = $this->cacheKey($spotId); return $this->cache->get($key, function (ItemInterface $item) use ($spotId) { $item->expiresAfter(self::CACHE_TTL); return $this->loadLatestScore($spotId); }); } // ------------------------------------------------------------------------- private function computeScore(CoastalPoint $spot, string $obsId, ?SargassumForecast $forecastH6): array { $spotId = (string) $spot->getId(); // Distance au sargassum le plus proche (mètres) $distanceM = $this->getDistanceToNearestSargassum($spotId, $obsId); if ($distanceM === null) { return $this->emptyScore(); } $coverageKm2 = $this->getCoverageArea($obsId); // Vitesse d'approche : delta distance entre now et H+6 (km/h) $approachKmh = 0.0; if ($forecastH6 !== null) { $distH6M = $this->getDistanceToNearestForecast($spotId, (string) $forecastH6->getId()); if ($distH6M !== null) { $approachKmh = ($distanceM - $distH6M) / 1000 / 6; // km/h (positif = approche) } } // Tendance $previousDistance = $this->getPreviousDistance($spotId); $trend = match (true) { $previousDistance === null => 'stable', $distanceM < $previousDistance * 0.9 => 'increasing', $distanceM > $previousDistance * 1.1 => 'decreasing', default => 'stable', }; // 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 }; $total = (int) round($distanceScore + $densityScore + $velocityScore + $trendScore); $total = max(0, min(100, $total)); return [ 'score' => $total, 'level' => $this->scoreToLevel($total), 'distance' => round($distanceM / 1000, 2), 'density' => round($coverageKm2, 2), 'trend' => $trend, ]; } private function getDistanceToNearestSargassum(string $spotId, string $obsId): ?float { $row = $this->connection->fetchAssociative( 'SELECT ST_Distance( cp.geometry::geography, o.geometry::geography ) AS distance_m FROM coastal_point cp, sargassum_observation o WHERE cp.id = :spotId AND o.id = :obsId', ['spotId' => $spotId, 'obsId' => $obsId] ); return ($row !== false) ? (float) $row['distance_m'] : null; } private function getDistanceToNearestForecast(string $spotId, string $forecastId): ?float { $row = $this->connection->fetchAssociative( 'SELECT ST_Distance( cp.geometry::geography, f.geometry::geography ) AS distance_m FROM coastal_point cp, sargassum_forecast f WHERE cp.id = :spotId AND f.id = :forecastId', ['spotId' => $spotId, 'forecastId' => $forecastId] ); return ($row !== false) ? (float) $row['distance_m'] : null; } private function getCoverageArea(string $obsId): float { $row = $this->connection->fetchAssociative( 'SELECT coverage_area FROM sargassum_observation WHERE id = :id', ['id' => $obsId] ); return ($row !== false && $row['coverage_area'] !== null) ? (float) $row['coverage_area'] : 0.0; } private function getPreviousDistance(string $spotId): ?float { $row = $this->connection->fetchAssociative( 'SELECT distance_to_nearest_sargassum FROM impact_score WHERE coastal_point_id = :spotId ORDER BY timestamp DESC LIMIT 1', ['spotId' => $spotId] ); return ($row !== false && $row['distance_to_nearest_sargassum'] !== null) ? (float) $row['distance_to_nearest_sargassum'] : null; } private function persistScore(CoastalPoint $spot, array $data): void { $s = new ImpactScore(); $s->setCoastalPoint($spot); $s->setScore($data['score']); $s->setLevel($data['level']); $s->setDistanceToNearestSargassum($data['distance']); $s->setDensityEstimate($data['density']); $s->setTrend($data['trend']); $this->em->persist($s); } 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 FROM impact_score WHERE coastal_point_id = :spotId ORDER BY timestamp DESC LIMIT 1', ['spotId' => $spotId] ); if ($row === false) { return null; } return [ 'value' => (int) $row['score'], 'level' => $row['level'], 'distance' => $row['distance'] !== null ? (float) $row['distance'] : null, 'density' => $row['density'] !== null ? (float) $row['density'] : null, 'trend' => $row['trend'], 'computedAt' => $row['computed_at'], ]; } private function invalidateCache(string $spotId): void { $this->cache->delete($this->cacheKey($spotId)); } private function cacheKey(string $spotId): string { return 'impact_score_' . str_replace('-', '_', $spotId); } private function scoreToLevel(int $score): string { return match (true) { $score <= 30 => 'low', $score <= 70 => 'medium', default => 'high', }; } private function emptyScore(): array { return ['score' => 0, 'level' => 'low', 'distance' => null, 'density' => 0.0, 'trend' => 'stable']; } private function findForecastByHorizon(array $forecasts, int $horizon): ?SargassumForecast { foreach ($forecasts as $f) { if ($f instanceof SargassumForecast && $f->getTimeHorizon() === $horizon) { return $f; } } return null; } }