Calcule chaque mois, pour chaque cellule H3 d'une AOI, les indices NDVI / NDWI / NDBI / SWIR à partir des images Sentinel-2 L2A gratuites de Planetary Computer, et les écrit dans satellite_features_by_zone. Remplace la Statistical API Sentinel Hub, bloquée par son rate-limit (voir ADR-004).
packages/clients/planetary_computer_client/ # LIRE les images
-- search.py # trouver les images du mois (catalogue STAC)
-- scenes.py # enlever les doublons, regrouper par tuile
-- reader.py # lire les pixels d'une tuile, bloc par bloc
packages/geo/raster_h3.py # pixels -> cellules H3
-- cells_to_label_grid # à quelle cellule H3 appartient chaque pixel
-- assign_owner_tiles # une seule tuile par cellule (recouvrements)
-- CellAccumulator # moyenne par cellule, bloc après bloc
packages/sentinel/ # CALCULER (calcul pur, ni DB ni réseau)
-- pixels.py # réflectance, masque nuages, indices, médiane du mois
-- tile_processing.py # une tuile -> moyennes par cellule
-- rows.py # moyennes -> lignes de la table
apps/jobs/sentinel/ # ORCHESTRER (lire -> calculer -> écrire)
-- extract.py # le job mensuel (job_run, upsert, erreurs de zone)
-- backfill.py # le job sur une plage de mois
apps/jobs/definitions.py
-- extract_sentinel, backfill_sentinel # commandes CLI
infra/vps/systemd/
-- biowatch-extract-sentinel.service / .timer # run mensuel sur le VPS
Règle suivie (CLAUDE.md) : packages/sentinel est du calcul pur, comme scoring.
La lecture des images, l'écriture en base et le job_run vivent dans apps/jobs/sentinel.
<aside> 💡 Commande :
uv run biowatch-jobs extract_sentinel --aoi idf --bucket_id 2025-05
Sans --bucket_id, le job traite le mois précédent (c'est ce que lance le timer mensuel).
Ce qui se passe, dans l'ordre :
On liste les ~18 300 cellules H3 de l'Île-de-France.
On cherche les images du mois sur Planetary Computer. Les images à plus de 70 % de nuages sont écartées tout de suite. Les doublons (même image publiée en deux versions) sont retirés.
Les images sont regroupées par tuile (carré de ~110 km). Les tuiles voisines se chevauchent : chaque cellule est attribuée à une seule tuile, pour ne jamais être comptée deux fois.
Pour chaque tuile, on lit les pixels par blocs de ~20 km, sans jamais télécharger l'image entière. Pour chaque pixel :
On fait la moyenne des pixels de chaque cellule, puis on écrit une ligne par cellule dans satellite_features_by_zone, en une seule fois.
Ce qu'on obtient par cellule :
ndvi, ndwi, ndbi, swir -> NULL si aucune image exploitable ce mois-là valid_pixel_ratio -> part des pixels exploitables is_valid_data -> false sous 10 % (valeur gardée mais marquée) obs_count -> nombre moyen de dates claires par pixel cloud_score -> part des dates masquées par les nuages
Historique :
uv run biowatch-jobs backfill_sentinel --aoi idf --from 2016-01 --to 2026-09
À lancer hors VPS, en écrivant dans la base du VPS via un tunnel SSH (voir README).
Mesuré sur un vrai run (IDF, mai 2025) : 8 minutes, 1,6 Go de RAM, 18 307 lignes, 0 erreur.
QUAND UN MOIS ÉCHOUE
Un mois peut finir de trois façons :
success -> toutes les tuiles ont été lues. partial -> au moins une tuile est restée illisible (réseau) : ses cellules sont dans job_run_zone_errors et n'ont pas de ligne ce mois-là. failed -> le job a planté (ex. catalogue injoignable) : rien n'est écrit.
Les cellules SANS IMAGE exploitable (mois trop nuageux) ne sont pas des erreurs : elles ont une ligne avec des indices NULL. Les relancer ne changerait rien.
On relance toujours le MOIS ENTIER.
Si un mois est partial ou failed, on relance le mois complet : toutes les tuiles sont relues, pas seulement celle qui a échoué. Ne relancer que ce qui n'a pas marché serait bien plus complexe à écrire et à fiabiliser, pour gagner quelques minutes (un mois IDF = 8 à 15 minutes).
Qui relance :
Run mensuel (VPS) Le timer tourne chaque nuit du 5 au 10 du mois. Un mois partial ou failed est relancé la nuit suivante ; un mois réussi est sauté en une seconde. Un mois partial sort avec un code d'erreur : systemd le signale.
Backfill À la fin, le backfill liste les mois à relancer, en distinguant : échec (le job a planté) incomplets (une tuile illisible) et donne la commande pour ne relancer qu'eux :
uv run biowatch-jobs backfill_sentinel --aoi idf --h3_res 8 --months 2016-02,2019-07
On peut aussi relancer la commande d'origine (--from / --to) : les mois déjà réussis sont sautés.
Pas de doublon, quelle que soit la relance :
EXPLICATION TECHNIQUE
Le satellite photographie la Terre dans plusieurs "bandes" (couleurs, dont certaines invisibles à l'oeil). On en lit cinq :
B03 vert, B04 rouge, B08 proche infrarouge -> 10 m par pixel B11 infrarouge moyen (SWIR) -> 20 m par pixel SCL, la classification de la scène -> 20 m par pixel
On travaille à 20 m : les bandes à 10 m sont moyennées 4 pixels par 4 pixels.
Les fichiers sont des COG (Cloud Optimized GeoTIFF) : on peut en lire un morceau par requête HTTP, sans télécharger l'image entière (~170 Mo par bande).
Pour chaque pixel et chaque date, l'ESA indique ce qu'elle y voit, avec un code de 0 à 11. On ne garde que trois classes :
4 végétation 5 sol nu / non végétalisé (une bonne partie du bâti) 6 eau
Tout le reste est exclu : nuages (8, 9), cirrus (10), ombres de nuage (3), neige (11), pixels défectueux (1), zones sombres (2), non classifié (7), pas de donnée (0).
La classe 7 ne pèse que 0,3 % des pixels à Paris : l'exclure ne vide pas la ville.
Le satellite ne mesure pas directement la couleur du sol : l'ESA corrige l'effet de l'atmosphère avec une chaîne de traitement. La "baseline" est le numéro de version de cette chaîne, inscrit sur chaque image (02.12, 04.00, 05.11...).
Depuis la version 04.00 (janvier 2022), les valeurs sont décalées de +1000. Une même réflectance de 0,2 vaut 2000 avant 2022 et 3000 après.
On applique donc le bon décalage image par image, jamais une règle unique : un même mois peut mélanger deux versions (c'est le cas en janvier 2022).
Les versions ne sont pas parfaitement identiques : on mesure une petite marche d'environ -0,01 de NDVI début 2022. À garder en tête pour toute analyse de tendance.
Ce sont des "différences normalisées" entre deux bandes : (a - b) / (a + b).
NDVI = (B08 - B04) / (B08 + B04) vigueur de la végétation NDWI = (B03 - B08) / (B03 + B08) présence d'eau libre NDBI = (B11 - B08) / (B11 + B08) surfaces bâties SWIR = valeur brute de B11 (ce n'est pas un indice)
Un mois donne en général 5 à 10 dates par pixel. On calcule les indices pour chaque date, on écarte les dates masquées (nuages...), puis on garde la médiane.
La médiane plutôt que la moyenne : un nuage mal détecté donne une valeur aberrante, qui fausse une moyenne mais pas une médiane.
Chaque pixel appartient à la cellule H3 qui contient son centre. Plutôt que de calculer la cellule pixel par pixel (des millions d'appels), on "dessine" les hexagones sur la grille de pixels : on obtient en une fois le numéro de cellule de chaque pixel.
Une cellule fait ~0,74 km², soit ~1 800 pixels de 20 m. Sa valeur est la moyenne de ses pixels.
L'ESA découpe le monde en tuiles d'environ 110 km, qui se chevauchent sur ~10 km. Une cellule dans la zone commune apparaîtrait deux fois : on lui attribue une seule tuile propriétaire, celle dont le bord est le plus loin de son centre.
Une tuile entière ne tient pas en mémoire : on la lit par blocs de 1024 x 1024 pixels. Une cellule peut être à cheval sur deux blocs, donc on cumule des sommes et des comptes bloc après bloc, et on divise une seule fois à la fin. Le résultat est identique à un calcul sur l'image entière (c'est testé).
Une lecture qui cale expire au bout de 60 secondes et GDAL la retente. Un bloc en échec est relu deux fois (après 10 s, puis 30 s). Si une tuile reste illisible malgré tout, le mois passe en "partial" (voir "Quand un mois échoue").
</aside>
<aside> 💡 La grille H3 de l'AOI doit exister dans zones_hex, à la même résolution (generate_h3_grid).
Aucune clé ni compte : Planetary Computer est gratuit.
Durée : 8 à 18 minutes et ~1,6 à 1,8 Go de RAM par mois pour l'Île-de-France. Backfill complet depuis 2016 : 25 à 35 heures.
source_version = 2.0.0. Changer la source, le masque, une formule ou un seuil impose d'incrémenter EXTRACTION_PIPELINE_VERSION (apps/jobs/sentinel/extract.py).
Une seule source par série : ne jamais mélanger Planetary Computer et un autre catalogue pour une même zone.
Petite marche possible début 2022 (changement de version de traitement ESA, environ -0,01 de NDVI) : à garder en tête pour toute analyse de tendance.
</aside>