ingest($bbox, $date, 'Sentinel-2', 'sentinel-2-l2a'); } catch (HighCloudCoverageException $e) { $this->logger->info('S2 cloud too high, falling back to Sentinel-3', ['reason' => $e->getMessage()]); $this->ingest($bbox, $date, 'Sentinel-3', 'sentinel-3-olci'); } } public function ingest( array $bbox, \DateTimeImmutable $date, string $source = 'Sentinel-2', string $collection = 'sentinel-2-l2a', ): void { $job = new DataIngestionJob(); $job->setSource($source); $job->setTileId(sprintf('[%s]', implode(',', $bbox))); $this->em->persist($job); $this->em->flush(); $tmpDir = null; try { // Étape 1 : vérification couverture nuageuse via Catalog API $cloudCoverage = $this->sentinelHub->getCloudCoverage($bbox, $date, $collection); $this->logger->info('Cloud coverage check', ['bbox' => $bbox, 'coverage' => $cloudCoverage, 'collection' => $collection]); if ($cloudCoverage > self::CLOUD_THRESHOLD) { $job->setStatus('failed'); $job->setErrorMessage("Couverture nuageuse {$cloudCoverage}% > seuil " . self::CLOUD_THRESHOLD . '%'); $this->em->flush(); throw new HighCloudCoverageException( "Cloud coverage {$cloudCoverage}% exceeds threshold for collection {$collection}" ); } // Étape 2 : téléchargement du raster AFAI binaire $tmpDir = $this->projectDir . '/var/ingestion/' . uniqid('ingest_', true); mkdir($tmpDir, 0755, true); $tifPath = $tmpDir . '/afai.tif'; $geojsonPath = $tmpDir . '/polygons.geojson'; $metadata = $this->sentinelHub->downloadAfaiBinary($bbox, $date, $tifPath, $collection); $this->logger->info('GeoTIFF downloaded', ['path' => $tifPath, 'tile' => $metadata['tileId']]); // Étape 3 : vectorisation via GDAL $this->polygonize($tifPath, $geojsonPath); // Étape 4 : construction du MULTIPOLYGON depuis le GeoJSON $geometry = $this->buildMultiPolygon($geojsonPath); if ($geometry === null) { $job->setStatus('success'); $job->setProcessedAt(new \DateTimeImmutable()); $job->setErrorMessage('Aucune sargasse détectée dans cette scène'); $this->em->flush(); $this->logger->info('No sargassum detected', ['bbox' => $bbox]); return; } // Étape 5 : persistance $observation = new SargassumObservation(); $observation->setGeometry(json_encode($geometry)); $observation->setDetectedAt($date); $observation->setSource($source); $observation->setTileId($metadata['tileId']); $observation->setCloudCoverage($cloudCoverage); $observation->setConfidence(0.8); $observation->setAfaiMean($metadata['afaiMean']); $observation->setAfaiStd($metadata['afaiStd']); $observation->setProcessingVersion(self::PROCESSING_VERSION); $this->em->persist($observation); $this->em->flush(); // Étape 6 : simplification + bbox + aire via PostGIS $this->updateSpatialMeta((string) $observation->getId()); $job->setStatus('success'); $job->setProcessedAt(new \DateTimeImmutable()); $this->em->flush(); $this->logger->info('Ingestion OK', ['observation_id' => (string) $observation->getId()]); } catch (\Throwable $e) { $job->setStatus('failed'); $job->incrementRetry(); $job->setErrorMessage($e->getMessage()); $this->em->flush(); $this->logger->error('Ingestion failed', ['error' => $e->getMessage()]); throw $e; } finally { if ($tmpDir !== null && is_dir($tmpDir)) { $this->cleanup($tmpDir); } } } private function polygonize(string $tifPath, string $geojsonPath): void { $maskPath = dirname($tifPath) . '/mask.tif'; // Créer un masque binaire : pixels DN=1 → valeur 1, tout le reste → nodata (0) // Cela permet à gdal_polygonize de n'extraire QUE les pixels sargasses, // évitant les faux positifs issus de la vectorisation de tout le tile. $maskProcess = new Process([ 'gdal_calc.py', '-A', $tifPath, '--outfile', $maskPath, '--calc', 'A==1', '--type', 'Byte', '--NoDataValue', '0', '--quiet', ]); $maskProcess->setTimeout(60); $maskProcess->run(); $args = [ 'gdal_polygonize.py', $tifPath, '-f', 'GeoJSON', '-q', $geojsonPath, ]; if ($maskProcess->isSuccessful() && file_exists($maskPath)) { // Insérer -mask avant le fichier de sortie array_splice($args, 1, 0, ['-mask', $maskPath]); } $process = new Process($args); $process->setTimeout(120); $process->run(); if (file_exists($maskPath)) { unlink($maskPath); } if (!$process->isSuccessful()) { throw new ProcessFailedException($process); } } private function buildMultiPolygon(string $geojsonPath): ?array { if (!file_exists($geojsonPath)) { return null; } $data = json_decode(file_get_contents($geojsonPath), true); if (empty($data['features'])) { return null; } $polygons = []; foreach ($data['features'] as $feature) { if (($feature['properties']['DN'] ?? 0) !== self::SARGASSUM_DN) { continue; } $geom = $feature['geometry']; if ($geom['type'] === 'Polygon') { // Ignorer les polygones trop petits (bruit : quelques pixels isolés) if (count($geom['coordinates'][0] ?? []) >= self::MIN_RING_POINTS) { $polygons[] = $geom['coordinates']; } } elseif ($geom['type'] === 'MultiPolygon') { foreach ($geom['coordinates'] as $poly) { if (count($poly[0] ?? []) >= self::MIN_RING_POINTS) { $polygons[] = $poly; } } } } if (empty($polygons)) { return null; } return ['type' => 'MultiPolygon', 'coordinates' => $polygons]; } private const MIN_POLYGON_AREA_M2 = 1_000_000; // 1 km² — filtre résidus AFAI private function updateSpatialMeta(string $id): void { // 1) Filtrer les micro-polygones < MIN_POLYGON_AREA_M2 avant simplification. // ST_CollectionExtract(..., 3) extrait uniquement les Polygon pour éviter // que ST_Collect retourne une GeometryCollection incompatible avec la colonne. // ST_Multi force le type MultiPolygon même pour un seul polygone résiduel. $this->em->getConnection()->executeStatement( "UPDATE sargassum_observation SET geometry = ( SELECT COALESCE( ST_Multi(ST_CollectionExtract(ST_Collect(geom), 3)), ST_GeomFromText('MULTIPOLYGON EMPTY', 4326) ) FROM ( SELECT (ST_Dump(geometry)).geom AS geom FROM sargassum_observation WHERE id = :id ) parts WHERE ST_Area(parts.geom::geography) >= :minArea ) WHERE id = :id", ['id' => $id, 'minArea' => self::MIN_POLYGON_AREA_M2] ); // 2) Simplifier + calculer bbox et surface finale $this->em->getConnection()->executeStatement( 'UPDATE sargassum_observation SET geometry = ST_SimplifyPreserveTopology(geometry, :tolerance), bbox = ST_Envelope(geometry), coverage_area = ROUND(CAST(ST_Area(geometry::geography) / 1000000 AS numeric), 2) WHERE id = :id AND NOT ST_IsEmpty(geometry)', ['id' => $id, 'tolerance' => self::SIMPLIFY_TOLERANCE] ); // 3) Supprimer l'observation si plus rien ne reste après filtrage $this->em->getConnection()->executeStatement( 'DELETE FROM sargassum_observation WHERE id = :id AND (ST_IsEmpty(geometry) OR geometry IS NULL)', ['id' => $id] ); } private function cleanup(string $dir): void { foreach (glob($dir . '/*') ?: [] as $file) { if (is_file($file)) { unlink($file); } } rmdir($dir); } }