fix: streaming GeoJSON pour éviter l'OOM + fenêtre 5 jours + simplification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-22 23:00:25 -04:00
parent 493fa35d54
commit 4d33056280

View File

@@ -38,9 +38,9 @@ class GenerateTilesCommand extends Command
// Observations // Observations
$io->section('Observations'); $io->section('Observations');
$geojson = $this->queryObservationsGeoJSON(); $geojsonFile = $this->queryObservationsGeoJSON();
if ($geojson !== null) { if ($geojsonFile !== null) {
$this->runTippecanoe($geojson, $tilesDir . '/observations', 'observations', $io); $this->runTippecanoe($geojsonFile, $tilesDir . '/observations', 'observations', $io);
} else { } else {
$io->warning('Aucune observation récente — tiles non générées.'); $io->warning('Aucune observation récente — tiles non générées.');
} }
@@ -48,9 +48,9 @@ class GenerateTilesCommand extends Command
// Prévisions par horizon // Prévisions par horizon
foreach (self::TILE_HORIZONS as $horizon) { foreach (self::TILE_HORIZONS as $horizon) {
$io->section("Prévisions H+{$horizon}"); $io->section("Prévisions H+{$horizon}");
$geojson = $this->queryForecastsGeoJSON($horizon); $geojsonFile = $this->queryForecastsGeoJSON($horizon);
if ($geojson !== null) { if ($geojsonFile !== null) {
$this->runTippecanoe($geojson, $tilesDir . '/forecasts/h' . $horizon, 'forecasts', $io); $this->runTippecanoe($geojsonFile, $tilesDir . '/forecasts/h' . $horizon, 'forecasts', $io);
} else { } else {
$io->warning("Aucune prévision H+{$horizon} — tiles non générées."); $io->warning("Aucune prévision H+{$horizon} — tiles non générées.");
} }
@@ -62,81 +62,99 @@ class GenerateTilesCommand extends Command
private function queryObservationsGeoJSON(): ?string private function queryObservationsGeoJSON(): ?string
{ {
$rows = $this->connection->fetchAllAssociative( // ST_SimplifyPreserveTopology réduit le nombre de coordonnées avant export
"SELECT ST_AsGeoJSON(geometry) AS geometry, // (tolérance 0.003° ≈ 300m, suffisant pour zoom 5-12).
$result = $this->connection->executeQuery(
"SELECT ST_AsGeoJSON(ST_SimplifyPreserveTopology(geometry, 0.003)) AS geometry,
COALESCE(afai_mean, 0) AS afai, COALESCE(afai_mean, 0) AS afai,
source, source,
confidence, confidence,
ROUND(COALESCE(coverage_area, 0)::numeric, 1) AS coverage_km2 ROUND(COALESCE(coverage_area, 0)::numeric, 1) AS coverage_km2
FROM sargassum_observation FROM sargassum_observation
WHERE detected_at >= NOW() - INTERVAL '30 days' WHERE detected_at >= NOW() - INTERVAL '5 days'
AND coverage_area > 0 AND coverage_area > 0
ORDER BY detected_at DESC" ORDER BY detected_at DESC"
); );
return $this->buildFeatureCollection($rows, [ return $this->streamToGeoJSON($result->iterateAssociative(), static fn($r) => [
'afai' => static fn($r) => (float) $r['afai'], 'afai' => (float) $r['afai'],
'source' => static fn($r) => $r['source'], // 'Sentinel-2' | 'Sentinel-3' 'source' => $r['source'],
'confidence' => static fn($r) => (float) $r['confidence'], 'confidence' => (float) $r['confidence'],
'coverage_km2' => static fn($r) => (float) $r['coverage_km2'], 'coverage_km2' => (float) $r['coverage_km2'],
]); ]);
} }
private function queryForecastsGeoJSON(int $horizon): ?string private function queryForecastsGeoJSON(int $horizon): ?string
{ {
$rows = $this->connection->fetchAllAssociative( $result = $this->connection->executeQuery(
"SELECT ST_AsGeoJSON(geometry) AS geometry, confidence "SELECT ST_AsGeoJSON(ST_SimplifyPreserveTopology(geometry, 0.003)) AS geometry,
confidence
FROM sargassum_forecast FROM sargassum_forecast
WHERE time_horizon = :horizon WHERE time_horizon = :horizon
AND computed_at >= NOW() - INTERVAL '30 days' AND computed_at >= NOW() - INTERVAL '5 days'
ORDER BY computed_at DESC", ORDER BY computed_at DESC",
['horizon' => $horizon] ['horizon' => $horizon]
); );
return $this->buildFeatureCollection($rows, [ return $this->streamToGeoJSON($result->iterateAssociative(), static fn($r) => [
'confidence' => static fn($r) => (float) $r['confidence'], 'confidence' => (float) $r['confidence'],
]); ]);
} }
/** /**
* @param array<array<string, mixed>> $rows * Écrit un FeatureCollection GeoJSON ligne par ligne dans un fichier temporaire.
* @param array<string, callable(array<string, mixed>): mixed> $props * Évite de charger toutes les géométries en mémoire PHP simultanément.
*
* @param \Traversable<array<string,mixed>> $rows
* @param callable(array<string,mixed>): array<string,mixed> $propsBuilder
*/ */
private function buildFeatureCollection(array $rows, array $props): ?string private function streamToGeoJSON(\Traversable $rows, callable $propsBuilder): ?string
{ {
if (empty($rows)) { $tmpFile = sys_get_temp_dir() . '/tiles_' . uniqid() . '.geojson';
$fh = fopen($tmpFile, 'w');
if ($fh === false) {
return null; return null;
} }
$features = []; fwrite($fh, '{"type":"FeatureCollection","features":[');
$count = 0;
foreach ($rows as $row) { foreach ($rows as $row) {
$properties = []; $geom = $row['geometry'];
foreach ($props as $key => $fn) { if ($geom === null) {
$properties[$key] = $fn($row); continue;
} }
$features[] = [ $feature = json_encode([
'type' => 'Feature', 'type' => 'Feature',
'geometry' => json_decode($row['geometry'], true, 512, JSON_THROW_ON_ERROR), 'geometry' => json_decode($geom, true, 512, JSON_THROW_ON_ERROR),
'properties' => $properties, 'properties' => $propsBuilder($row),
]; ], JSON_THROW_ON_ERROR);
if ($count > 0) {
fwrite($fh, ',');
}
fwrite($fh, $feature);
++$count;
} }
return json_encode( fwrite($fh, ']}');
['type' => 'FeatureCollection', 'features' => $features], fclose($fh);
JSON_THROW_ON_ERROR
); if ($count === 0) {
@unlink($tmpFile);
return null;
} }
private function runTippecanoe(string $geojson, string $outputDir, string $layer, SymfonyStyle $io): void return $tmpFile;
}
private function runTippecanoe(string $geojsonFile, string $outputDir, string $layer, SymfonyStyle $io): void
{ {
$parentDir = dirname($outputDir); $parentDir = dirname($outputDir);
if (!is_dir($parentDir)) { if (!is_dir($parentDir)) {
mkdir($parentDir, 0755, true); mkdir($parentDir, 0755, true);
} }
$tmpFile = sys_get_temp_dir() . '/tiles_' . uniqid() . '.geojson';
file_put_contents($tmpFile, $geojson);
try { try {
$cmd = sprintf( $cmd = sprintf(
'tippecanoe -e %s --force --no-tile-compression --layer=%s -Z%d -z%d --drop-densest-as-needed --quiet %s 2>&1', 'tippecanoe -e %s --force --no-tile-compression --layer=%s -Z%d -z%d --drop-densest-as-needed --quiet %s 2>&1',
@@ -144,7 +162,7 @@ class GenerateTilesCommand extends Command
escapeshellarg($layer), escapeshellarg($layer),
self::MIN_ZOOM, self::MIN_ZOOM,
self::MAX_ZOOM, self::MAX_ZOOM,
escapeshellarg($tmpFile) escapeshellarg($geojsonFile)
); );
exec($cmd, $cmdOutput, $exitCode); exec($cmd, $cmdOutput, $exitCode);
@@ -157,7 +175,7 @@ class GenerateTilesCommand extends Command
$io->success("{$outputDir}"); $io->success("{$outputDir}");
} }
} finally { } finally {
@unlink($tmpFile); @unlink($geojsonFile);
} }
} }
} }