ST_Collect peut retourner une GeometryCollection selon les sous-types. Correction : ST_CollectionExtract(..., 3) extrait uniquement les polygones, ST_Multi garantit le type MultiPolygon, ST_GeomFromText fournit un fallback vide typé correctement. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
277 lines
10 KiB
PHP
277 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Service\Ingestion;
|
|
|
|
use App\Entity\DataIngestionJob;
|
|
use App\Entity\SargassumObservation;
|
|
use App\Service\SentinelHub\SentinelHubClient;
|
|
use Doctrine\DBAL\Connection;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
use Symfony\Component\Process\Exception\ProcessFailedException;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
class IngestionService
|
|
{
|
|
private const CLOUD_THRESHOLD = 60.0;
|
|
private const SARGASSUM_DN = 1;
|
|
private const SIMPLIFY_TOLERANCE = 0.001; // degrés (~100m à l'équateur)
|
|
private const MIN_RING_POINTS = 8; // filtre polygones < ~2 pixels (bruit)
|
|
private const PROCESSING_VERSION = '1.2.0';
|
|
|
|
public function __construct(
|
|
private SentinelHubClient $sentinelHub,
|
|
private EntityManagerInterface $em,
|
|
private LoggerInterface $logger,
|
|
#[Autowire('%kernel.project_dir%')] private string $projectDir,
|
|
) {}
|
|
|
|
/**
|
|
* Tente l'ingestion Sentinel-2, puis Sentinel-3 OLCI en fallback si la
|
|
* couverture nuageuse est trop élevée ou si la scène S2 est indisponible.
|
|
*/
|
|
public function ingestWithFallback(array $bbox, \DateTimeImmutable $date): void
|
|
{
|
|
try {
|
|
$this->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);
|
|
}
|
|
}
|