API Satellitari e Meteorologiche per AgriTech: Dati Predittivi con Sentinel-2 e Python
Nel 2024, l'Agenzia Spaziale Europea ha acquisito oltre 1.5 petabyte di dati ogni giorno dai satelliti Sentinel del programma Copernicus. Di questi, la quota dedicata al monitoraggio agricolo in Europa rappresenta il segmento più ampio e strategicamente rilevante. Eppure la grande maggioranza delle aziende agricole italiane, incluse quelle più tecnologicamente avanzate, non sfrutta ancora questa infrastruttura pubblica, gratuita e scientificamente validata.
La ragione non e la mancanza di dati: e la complessità di accedervi, elaborarli e trasformarli in decisioni agronomiche concrete. Un satellite Sentinel-2 passa sulla tua vigna ogni 5 giorni con risoluzione a 10 metri per pixel. Ogni passaggio produce 13 bande spettrali che codificano salute della vegetazione, stress idrico, contenuto di clorofilla, presenza di parassiti. Combinati con dati meteorologici, sensori IoT al suolo e modelli predittivi, questi dati possono trasformare radicalmente la qualità delle decisioni agronomiche, riducendo i costi degli input del 15-25% e aumentando le rese fino al 10-20%.
Il mercato globale dell'Earth Observation vale 10.07 miliardi di dollari nel 2025 secondo Straits Research, con crescita prevista a 17.20 miliardi entro il 2033 (CAGR 6.92%). Il segmento agricoltura rappresenta il 21% delle applicazioni e sta crescendo al ritmo più elevato, trainato dalla domanda di precision farming, monitoraggio delle rese e ottimizzazione dell'irrigazione. In questo articolo costruiamo, passo dopo passo, una pipeline Python completa per accedere a Sentinel-2 via CDSE (Copernicus Data Space Ecosystem), calcolare NDVI e altri indici vegetazionali, integrare dati meteorologici da Open-Meteo, e alimentare un modello predittivo per lo stress idrico.
Cosa Imparerai in Questo Articolo
- Architettura del programma Copernicus e come accedere gratuitamente a Sentinel-2 via CDSE
- Indici vegetazionali: NDVI, EVI, SAVI, LAI - formule matematiche, interpretazione e soglie per colture italiane
- Implementazione Python completa: autenticazione CDSE, download bande, calcolo NDVI con rasterio e numpy
- API meteorologiche comparate: Open-Meteo, OpenWeatherMap, Tomorrow.io e integrazione con dati satellite
- Pipeline geospaziale con GeoPandas, rasterio e PostGIS per dati vettoriali e raster
- Modello predittivo stress idrico: combinare Sentinel-2 + meteo + IoT con scikit-learn
- Case study pratico: vigna in Puglia, monitoraggio NDVI stagionale, ottimizzazione irrigazione
- Normativa italiana: AGEA, SIAN, open data CAP e PAC digitale
Serie FoodTech - Tutti gli Articoli
| # | Articolo | Livello | Stato |
|---|---|---|---|
| 1 | Pipeline IoT per Agricoltura di Precisione con Python e MQTT | Advanced | Disponibile |
| 2 | ML Edge per Monitoraggio Colture: Computer Vision sui Campi | Advanced | Disponibile |
| 3 | API Satellitari e Meteorologiche per AgriTech: Dati Predittivi (sei qui) | Advanced | Corrente |
| 4 | Tracciabilita Blockchain nel Food: dal Campo al Supermercato | Intermediate | In arrivo |
| 5 | Computer Vision per Quality Control nell'Industria Alimentare | Advanced | In arrivo |
| 6 | FSMA e Compliance Digitale: Automazione dei Processi Normativi | Intermediate | In arrivo |
| 7 | Vertical Farming: Controllo Ambientale con IoT e ML | Advanced | In arrivo |
| 8 | Demand Forecasting per il Food Retail con Prophet e LightGBM | Intermediate | In arrivo |
| 9 | Dashboard Farm Intelligence: Real-Time Analytics con Grafana | Intermediate | In arrivo |
| 10 | Supply Chain Food Optimization: ML per Riduzione degli Sprechi | Intermediate | In arrivo |
Il Programma Copernicus: Infrastruttura Europea per l'Osservazione della Terra
Il programma Copernicus dell'Unione Europea e la più grande infrastruttura di osservazione della Terra mai costruita. Gestito da ESA (Agenzia Spaziale Europea) e dalla Commissione Europea con il supporto operativo di EUMETSAT, Copernicus opera una costellazione di satelliti denominati "Sentinel", ciascuno progettato per specifiche missioni di monitoraggio. Per l'agricoltura, il satellite di riferimento assoluto e Sentinel-2.
La costellazione Sentinel-2 e composta da due satelliti gemelli (Sentinel-2A e Sentinel-2B) in orbita eliosincrona a 786 km di quota. La configurazione a due satelliti garantisce un tempo di rivisitazione di 5 giorni sull'equatore e di 2-3 giorni alle latitudini europee (Italia inclusa). Ogni satellite porta a bordo il sensore MSI (MultiSpectral Instrument), un pushbroom scanner che acquisisce dati con una larghezza di strisciata di 290 km e 13 bande spettrali distribuite su tre risoluzioni spaziali: 10 metri, 20 metri e 60 metri.
La caratteristica più strategica di Sentinel-2 e la sua completa gratuita: dal 2014 tutti i dati Copernicus sono disponibili in open access per qualsiasi uso, commerciale e non commerciale. Il nuovo portale di accesso dal 2023 e il Copernicus Data Space Ecosystem (CDSE), che ha sostituito il precedente Copernicus Open Access Hub (SciHub) dismesso nel 2023. CDSE offre accesso immediato all'archivio completo di Sentinel-2, con latenza da acquisizione a disponibilità di circa 3 ore per le immagini più recenti.
Satellite Sentinel-2: Specifiche Tecniche del Sensore MSI
| Banda | Lunghezza d'onda (nm) | Risoluzione | Applicazione Principale |
|---|---|---|---|
| B1 - Coastal aerosol | 443 | 60 m | qualità aria, correzione atmosferica |
| B2 - Blue | 490 | 10 m | Discriminazione vegetazione/suolo, mappatura |
| B3 - Green | 560 | 10 m | Vigor vegetazione, colore fogliare |
| B4 - Red | 665 | 10 m | Clorofilla, NDVI (banda rossa) |
| B5 - Vegetation Red Edge | 705 | 20 m | Stress vegetazione, Red Edge NDVI |
| B6 - Vegetation Red Edge | 740 | 20 m | Contenuto clorofilla, classificazione specie |
| B7 - Vegetation Red Edge | 783 | 20 m | Biomassa, LAI |
| B8 - NIR | 842 | 10 m | NDVI (banda NIR), biomassa, LAI |
| B8a - Vegetation Red Edge | 865 | 20 m | Canopy structure, NDVI narrow |
| B9 - Water vapour | 940 | 60 m | Correzione vapore acqueo atmosferico |
| B10 - SWIR - Cirrus | 1375 | 60 m | Rilevamento nubi cirro |
| B11 - SWIR | 1610 | 20 m | Stress idrico, contenuto acqua fogliare |
| B12 - SWIR | 2190 | 20 m | Stress idrico avanzato, senescenza |
I livelli di elaborazione dei dati Sentinel-2 rilevanti per l'agricoltura sono principalmente due: Level-1C (L1C) - radiance al top dell'atmosfera con correzione geometrica, e Level-2A (L2A) - riflettanza al fondo dell'atmosfera (Bottom of Atmosphere, BOA) con correzione atmosferica applicata tramite l'algoritmo Sen2Cor. Per applicazioni agricole dirette, il Level-2A e quello raccomandato in quanto già corretto per l'influenza dell'atmosfera, rendendo i valori di riflettanza comparabili tra date diverse e localita geografiche diverse.
Piattaforme Satellite per AgriTech: Confronto Completo 2025
Sentinel-2 non e l'unica opzione disponibile. Il mercato dei dati satellitari per l'agricoltura include provider commerciali come Planet Labs, operatori storici come Landsat (NASA/USGS) e piattaforme cloud integrate come Google Earth Engine. La scelta dipende da un trade-off complesso tra risoluzione spaziale, frequenza di acquisizione, copertura nuvolosa gestita e budget disponibile.
Confronto Piattaforme Satellite per Agricoltura Precision - 2025
| Piattaforma | Risoluzione Spaziale | Frequenza Rivisitazione | Bande Spettrali | Costo | Latenza Dati | API |
|---|---|---|---|---|---|---|
| Sentinel-2 (ESA/Copernicus) | 10 m (vis/NIR), 20 m (SWIR) | 5 giorni (2-3 in EU) | 13 bande MSI | Gratuito (open data) | ~3 ore da acquisizione | CDSE OData, STAC, SentinelHub, OpenEO |
| Planet PlanetScope | 3.7 m | Giornaliero (quasi-daily) | 8 bande (PS2.SD) | Subscription commerciale; gratuito per ricerca | 24 ore | Planet API v1, Subscriptions API |
| Landsat 8/9 (NASA/USGS) | 30 m (multispettrale), 15 m (pan) | 16 giorni | 11 bande OLI/TIRS | Gratuito (open data) | 24-48 ore | USGS EarthExplorer, Google Earth Engine |
| MODIS (NASA Terra/Aqua) | 250 m / 500 m / 1 km | 1-2 giorni | 36 bande | Gratuito (open data) | 24-48 ore | NASA EarthData STAC, Google Earth Engine |
| Google Earth Engine | Dipende dal dataset (10m-1km) | Dipende dal dataset | Multi-dataset integrati | Gratuito non-commerciale; commerciale a pagamento | Elaborazione cloud immediata | Python ee, JavaScript API |
| Maxar WorldView-3 | 0.3 m (pan), 1.24 m (multi) | 1-4 giorni | 29 bande (CAVIS) | ~25-40 USD/km2 | 4-8 ore | Maxar Streaming API |
| Airbus Pleiades Neo | 0.3 m | 1-2 giorni | 6 bande multispettrali | ~15-30 EUR/km2 | 24-48 ore | OneAtlas API |
Per la grande maggioranza delle applicazioni agricole in Italia, Sentinel-2 e la scelta ottimale: la risoluzione a 10 metri e sufficiente per appezzamenti superiori a 0.5 ettari (soglia sotto cui la statistica di pixel diventa inaffidabile), la frequenza di rivisitazione di 2-3 giorni in Italia e adeguata per il monitoraggio stagionale, e la gratuita completa elimina qualsiasi barriera economica all'adozione. Planet Labs diventa rilevante solo per applicazioni che richiedono monitoraggio quasi-giornaliero (ad esempio, detecting early onset di stress idrico in colture ad alto valore come ortofrutticoli intensivi) o risoluzione sub-10m. MODIS e Landsat restano utili per analisi su grandi aree e serie storiche pluridecennali.
Indici Vegetazionali: NDVI, EVI, SAVI e LAI per Colture Italiane
Gli indici vegetazionali (Vegetation Indices, VI) sono metriche derivate matematicamente dalla combinazione di bande spettrali che quantificano proprietà specifiche della vegetazione. Sfruttano il fatto che la clorofilla assorbe fortemente nella banda rossa (665 nm) e riflette fortemente nel near-infrared (842 nm): un rapporto tra queste bande codifica la densita e vitalita della vegetazione con eleganza matematica.
NDVI - Normalized Difference Vegetation Index
L'NDVI e l'indice vegetazionale più utilizzato al mondo, introdotto da Rouse et al. nel 1973. La formula matematica e:
Formula NDVI
NDVI = (NIR - Red) / (NIR + Red)
Su Sentinel-2:
NDVI = (B8 - B4) / (B8 + B4)
Range: da -1.0 a +1.0
I valori NDVI si interpretano secondo una scala standardizzata, ma le soglie ottimali variano per coltura e fase fenologica. La tabella seguente riporta le soglie di riferimento per le principali colture italiane, derivate da letteratura scientifica e validazione empirica su areali tipici (Pianura Padana, Puglia, Sicilia, Toscana):
Interpretazione NDVI per Colture Italiane
| NDVI Range | Interpretazione Generale | Grano (Triticum) | Vite (Vitis) | Pomodoro (Solanum) | Mais (Zea mays) | Olivo (Olea) |
|---|---|---|---|---|---|---|
| < 0.1 | Suolo nudo, roccia, neve | Terreno non seminato | Inverno, pre-germogliamento | Post-raccolta | Pre-semina | Terreno nudo inter-filare |
| 0.1 - 0.2 | Vegetazione molto rada o secca | Emergenza iniziale | Dormienza / stress severo | Trapianto recente | Emergenza (VE) | Stress idrico/termic severo |
| 0.2 - 0.4 | Vegetazione rada, stress moderato | Accestimento iniziale | Pre-fioritura, stress leggero | Crescita vegetativa iniziale | Stadi V1-V3 | Vegetazione scarsa, stress |
| 0.4 - 0.6 | Vegetazione moderata, buona salute | Piena levata | Pre-fioritura / allegagione | Sviluppo vegetativo attivo | Stadi V4-V8 | Chioma normale, ben irrigato |
| 0.6 - 0.8 | Vegetazione densa, ottimo vigor | Spigatura (picco stagionale) | Piena fogliazione, invaiatura | Copertura massima (fioritura) | Stadi V10-VT (fioritura) | Chioma densa, ottima condizione |
| > 0.8 | Vegetazione molto densa, foresta | Raro (boschi bordo campo) | Siepi e alberi di confine | Serre con copertura totale | Rare condizioni ottimali | Oliveti con alta densita |
EVI - Enhanced Vegetation Index
L'EVI, sviluppato da Huete et al. per il sensore MODIS, corregge i limiti dell'NDVI nelle aree ad alta densita di vegetazione (saturazione dell'NDVI oltre 0.7) e nelle zone con suoli esposti (errori di NDVI causati dalla riflettanza del suolo). La formula e:
EVI = G * (NIR - Red) / (NIR + C1*Red - C2*Blue + L)
Su Sentinel-2:
EVI = 2.5 * (B8 - B4) / (B8 + 6*B4 - 7.5*B2 + 1)
Costanti standard:
G = 2.5 (guadagno)
C1 = 6.0 (coefficiente resistenza aerosol)
C2 = 7.5 (coefficiente resistenza aerosol)
L = 1.0 (fattore aggiustamento canopy)
Range: -1.0 a +1.0 (tipicamente 0 a 0.9 per vegetazione)
SAVI - Soil Adjusted Vegetation Index
Il SAVI, proposto da Huete nel 1988, e particolarmente utile in ambienti semi-aridi come la Puglia o la Sicilia dove il suolo esposto influenza significativamente la risposta spettrale. Il fattore di correzione L riduce l'influenza del suolo:
SAVI = (NIR - Red) * (1 + L) / (NIR + Red + L)
Su Sentinel-2:
SAVI = (B8 - B4) * (1 + 0.5) / (B8 + B4 + 0.5)
L = 0.5 (valore standard per vegetazione media)
L = 1.0 per vegetazione molto rada
L = 0.25 per vegetazione densa
Vantaggioso rispetto a NDVI quando:
- Copertura vegetale < 40%
- Suoli chiari e brillanti (calcari, sabbie)
- Areali semi-aridi del Sud Italia
LAI - Leaf Area Index
Il LAI (Indice di Area Fogliare) quantifica la superficie fogliare totale per unita di superficie suolo. E un parametro agronomico fondamentale correlato alla produttività potenziale e alla traspirazione. Sentinel-2 permette di stimare il LAI tramite algoritmi empirici o fisici (SNAP BioPhysical Processor):
# Stima LAI empirica da NDVI (relazione di Boegh et al., corretta per Sentinel-2)
# Valida per cereali e colture erbacee europee
LAI_stima = -0.3 + 10.2 * NDVI # valida per NDVI in [0.2, 0.8]
# Per la vite (relazione specifica da letteratura italiana):
LAI_vite = 0.57 * EXP(3.28 * NDVI) # Dalla et al., 2019
# Soglie LAI indicative per colture italiane:
# Grano: LAI ottimale 3-5 m2/m2 alla spigatura
# Mais: LAI ottimale 3-5 m2/m2 alla fioritura
# Vite: LAI ottimale 1.5-2.5 m2/m2 durante invaiatura
# Pomodoro: LAI ottimale 3-4 m2/m2 durante copertura massima
Limitazioni degli Indici Vegetazionali da Satellite
- Nuvolosita: Sentinel-2 e ottico - le nuvole rendono inutilizzabile l'immagine. In Italia, la copertura nuvolosa media e 48% in inverno e 15% in estate. Il QA60 band permette il masking delle nuvole automatico.
- Saturazione NDVI: Oltre NDVI ~0.7-0.8 la sensibilita si riduce drasticamente. Per colture ad alta densita usare EVI.
- Mix di pixel: A 10 m di risoluzione, un pixel può contenere sia vegetazione sia suolo. Per appezzamenti piccoli (< 0.5 ha) il NDVI medio e meno rappresentativo.
- Stagionalita fenologica: Confrontare NDVI tra date diverse richiede di tenere conto della fase fenologica, non solo del valore assoluto.
- Variabilità inter-annuale: NDVI varia anche in funzione delle condizioni meteorologiche stagionali (temperatura, precipitazioni) indipendentemente dalle pratiche agronomiche.
Implementazione Python: Accesso CDSE e Download Sentinel-2
Il Copernicus Data Space Ecosystem (CDSE) e diventato il portale unico di accesso ai dati Sentinel dal 2023. Offre quattro API principali: OData API (ricerca e download prodotti), STAC API (SpatioTemporal Asset Catalog), OpenEO API (processing standardizzato) e Sentinel Hub API (processing as a service). Per iniziare, crea un account gratuito su dataspace.copernicus.eu e genera le credenziali OAuth2.
Setup Ambiente Python e Dipendenze
# Installa le librerie necessarie
pip install sentinelhub rasterio numpy pandas geopandas shapely \
requests python-dotenv matplotlib scipy scikit-learn \
openmeteo-requests retry-requests xarray
# requirements.txt con versioni validate per Python 3.11+
# sentinelhub==3.11.4 - interfaccia CDSE e SentinelHub
# rasterio==1.4.0 - lettura/scrittura raster geospaziali
# numpy==2.1.0 - calcolo matriciale per bande
# geopandas==1.0.1 - dati vettoriali e spatial operations
# shapely==2.0.6 - geometrie per AOI (Area of Interest)
# openmeteo-requests==0.3.3 - client Open-Meteo API
# scikit-learn==1.5.2 - modello predittivo stress idrico
Autenticazione CDSE con OAuth2
import os
import requests
from datetime import datetime, timedelta
from dotenv import load_dotenv
load_dotenv()
# Configurazione credenziali CDSE (da variabili d'ambiente)
CDSE_USERNAME = os.getenv("CDSE_USERNAME")
CDSE_PASSWORD = os.getenv("CDSE_PASSWORD")
CDSE_CLIENT_ID = "cdse-public"
CDSE_TOKEN_URL = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token"
def get_cdse_access_token(username: str, password: str) -> str:
"""
Ottieni access token OAuth2 dal CDSE Identity Server.
Il token ha durata di 600 secondi (10 minuti).
"""
response = requests.post(
CDSE_TOKEN_URL,
data={
"grant_type": "password",
"client_id": CDSE_CLIENT_ID,
"username": username,
"password": password,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30,
)
response.raise_for_status()
token_data = response.json()
return token_data["access_token"]
class CDSESession:
"""Sessione autenticata con gestione automatica del refresh token."""
def __init__(self, username: str, password: str):
self._username = username
self._password = password
self._token: str | None = None
self._token_expiry: datetime | None = None
def _refresh_token(self) -> None:
self._token = get_cdse_access_token(self._username, self._password)
# Margine di 60 secondi prima della scadenza reale (600s)
self._token_expiry = datetime.utcnow() + timedelta(seconds=540)
def get_headers(self) -> dict:
if self._token is None or datetime.utcnow() >= self._token_expiry:
self._refresh_token()
return {"Authorization": f"Bearer {self._token}"}
# Inizializza sessione
session = CDSESession(CDSE_USERNAME, CDSE_PASSWORD)
Ricerca Prodotti Sentinel-2 tramite OData API
from shapely.geometry import box
import json
ODATA_BASE_URL = "https://catalogue.dataspace.copernicus.eu/odata/v1"
def search_sentinel2_products(
bbox: tuple[float, float, float, float], # (lon_min, lat_min, lon_max, lat_max)
start_date: str, # formato: "2024-05-01T00:00:00.000Z"
end_date: str, # formato: "2024-05-31T23:59:59.000Z"
cloud_cover_max: float = 20.0,
product_type: str = "S2MSI2A", # L2A = Bottom of Atmosphere, preferire per agricoltura
max_results: int = 20,
) -> list[dict]:
"""
Cerca prodotti Sentinel-2 nel catalogo CDSE.
Args:
bbox: Bounding box nell'ordine (lon_min, lat_min, lon_max, lat_max)
start_date: Data inizio (formato ISO 8601)
end_date: Data fine (formato ISO 8601)
cloud_cover_max: Percentuale massima di copertura nuvolosa (0-100)
product_type: S2MSI2A (L2A, consigliato) o S2MSI1C (L1C)
max_results: Numero massimo di risultati
Returns:
Lista di dizionari con metadata prodotto
"""
lon_min, lat_min, lon_max, lat_max = bbox
aoi_wkt = f"POLYGON(( {lon_min} {lat_min}, {lon_max} {lat_min}, {lon_max} {lat_max}, {lon_min} {lat_max}, {lon_min} {lat_min} ))"
filter_query = (
f"Collection/Name eq 'SENTINEL-2' "
f"and Attributes/OData.CSC.DoubleAttribute/any(att:att/Name eq 'cloudCover' "
f" and att/OData.CSC.DoubleAttribute/Value le {cloud_cover_max}) "
f"and Attributes/OData.CSC.StringAttribute/any(att:att/Name eq 'productType' "
f" and att/OData.CSC.StringAttribute/Value eq '{product_type}') "
f"and ContentDate/Start ge {start_date} "
f"and ContentDate/Start lt {end_date} "
f"and OData.CSC.Intersects(area=geography'SRID=4326;{aoi_wkt}')"
)
params = {
"$filter": filter_query,
"$orderby": "ContentDate/Start desc",
"$top": max_results,
"$expand": "Attributes",
}
response = requests.get(
f"{ODATA_BASE_URL}/Products",
params=params,
timeout=60,
)
response.raise_for_status()
data = response.json()
return data.get("value", [])
# Esempio: cerca immagini per la vigna di Manduria (Puglia)
# Campagna estiva: maggio-settembre 2024
bbox_manduria = (17.4, 40.3, 17.7, 40.5) # Zona DOC Primitivo di Manduria
products = search_sentinel2_products(
bbox=bbox_manduria,
start_date="2024-05-01T00:00:00.000Z",
end_date="2024-09-30T23:59:59.000Z",
cloud_cover_max=10.0,
product_type="S2MSI2A",
)
print(f"Trovati {len(products)} prodotti Sentinel-2 L2A con copertura nuvolosa <= 10%")
for p in products[:5]:
cloud = next(
(a["Value"] for a in p.get("Attributes", []) if a["Name"] == "cloudCover"),
"N/A",
)
print(f" {p['Name']} | Cloud: {cloud:.1f}% | Date: {p['ContentDate']['Start'][:10]}")
Download e Accesso alle Bande con Sentinel Hub Python
Per il processing delle bande senza scaricare l'intera scena (che pesa 800 MB - 1.2 GB per prodotto L2A), e più efficiente usare la Sentinel Hub Process API che restituisce solo le bande richieste nella regione di interesse:
from sentinelhub import (
SHConfig,
SentinelHubRequest,
DataCollection,
MimeType,
BBox,
CRS,
bbox_to_dimensions,
)
import numpy as np
# Configurazione SentinelHub via CDSE
config = SHConfig()
config.sh_client_id = os.getenv("SH_CLIENT_ID") # da apps.sentinel-hub.com
config.sh_client_secret = os.getenv("SH_CLIENT_SECRET")
config.sh_base_url = "https://sh.dataspace.copernicus.eu" # endpoint CDSE
# Definizione Area of Interest (AOI) - Vigna Manduria 100 ha circa
aoi_bbox = BBox(
bbox=[17.42, 40.34, 17.52, 40.42],
crs=CRS.WGS84,
)
# Calcola dimensioni raster a 10m di risoluzione
size = bbox_to_dimensions(aoi_bbox, resolution=10)
print(f"Dimensioni raster: {size[0]} x {size[1]} pixel a 10m")
# Evalscript per scaricare B04 (Red) e B08 (NIR) per calcolo NDVI
evalscript_ndvi_bands = """
//VERSION=3
function setup() {
return {
input: [{
bands: ["B04", "B08", "CLM"], // Red, NIR, Cloud Mask
units: "REFLECTANCE"
}],
output: {
bands: 3,
sampleType: "FLOAT32"
}
};
}
function evaluatePixel(sample) {
// Restituisce [Red, NIR, CloudMask]
return [sample.B04, sample.B08, sample.CLM];
}
"""
# Richiesta dati
request = SentinelHubRequest(
evalscript=evalscript_ndvi_bands,
input_data=[
SentinelHubRequest.input_data(
data_collection=DataCollection.SENTINEL2_L2A.define_from(
"s2l2a",
service_url=config.sh_base_url,
),
time_interval=("2024-07-15", "2024-07-20"),
other_args={"dataFilter": {"maxCloudCoverage": 10}},
)
],
responses=[SentinelHubRequest.output_response("default", MimeType.TIFF)],
bbox=aoi_bbox,
size=size,
config=config,
)
# Download dati (restituisce array numpy [H, W, Bande])
images = request.get_data()
band_data = images[0] # shape: (height, width, 3)
red_band = band_data[:, :, 0].astype(np.float32) # B04 - Red
nir_band = band_data[:, :, 1].astype(np.float32) # B08 - NIR
cloud_mask = band_data[:, :, 2] # CLM - 0=clear, 1=cloud
print(f"Bande scaricate - Shape: {red_band.shape}")
print(f"Red B04 - Min: {red_band.min():.4f}, Max: {red_band.max():.4f}, Mean: {red_band.mean():.4f}")
print(f"NIR B08 - Min: {nir_band.min():.4f}, Max: {nir_band.max():.4f}, Mean: {nir_band.mean():.4f}")
print(f"Pixel nuvolosi: {cloud_mask.sum()} su {cloud_mask.size} totali ({100*cloud_mask.mean():.1f}%)")
Calcolo NDVI e Salvataggio GeoTIFF
import rasterio
from rasterio.transform import from_bounds
from rasterio.crs import CRS as RasterioCRS
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
def calculate_ndvi(
red: np.ndarray,
nir: np.ndarray,
cloud_mask: np.ndarray | None = None,
) -> np.ndarray:
"""
Calcola NDVI dalla banda rossa e NIR.
Gestisce divisione per zero con np.errstate.
Maschera i pixel nuvolosi con np.nan.
Returns:
Array NDVI con float32, range [-1, 1], NaN per pixel invalidi
"""
with np.errstate(divide="ignore", invalid="ignore"):
ndvi = np.where(
(nir + red) == 0,
np.nan,
(nir - red) / (nir + red),
).astype(np.float32)
# Maschera nuvole
if cloud_mask is not None:
ndvi = np.where(cloud_mask == 1, np.nan, ndvi)
# Clip valori fisicamente impossibili (artefatti strumentali)
ndvi = np.clip(ndvi, -1.0, 1.0)
return ndvi
def save_ndvi_geotiff(
ndvi: np.ndarray,
bbox_wgs84: tuple[float, float, float, float],
output_path: str,
) -> None:
"""
Salva l'array NDVI come GeoTIFF georeferenziato in WGS84.
Args:
ndvi: Array NDVI 2D (H, W)
bbox_wgs84: (lon_min, lat_min, lon_max, lat_max)
output_path: Path del file output
"""
height, width = ndvi.shape
lon_min, lat_min, lon_max, lat_max = bbox_wgs84
transform = from_bounds(lon_min, lat_min, lon_max, lat_max, width, height)
with rasterio.open(
output_path,
"w",
driver="GTiff",
height=height,
width=width,
count=1,
dtype=rasterio.float32,
crs=RasterioCRS.from_epsg(4326),
transform=transform,
compress="lzw",
nodata=np.nan,
) as dst:
dst.write(ndvi, 1)
dst.update_tags(
NDVI_COMPUTED_AT=datetime.utcnow().isoformat(),
SENTINEL2_PRODUCT_TYPE="S2MSI2A",
FORMULA="(B08-B04)/(B08+B04)",
)
# Calcola e salva NDVI
ndvi_map = calculate_ndvi(red_band, nir_band, cloud_mask)
# Statistiche NDVI per l'AOI (escluso NaN)
valid_pixels = ndvi_map[~np.isnan(ndvi_map)]
print(f"\nStatistiche NDVI - Vigna Manduria (luglio 2024)")
print(f" Pixel validi: {len(valid_pixels)} su {ndvi_map.size}")
print(f" NDVI medio: {valid_pixels.mean():.3f}")
print(f" NDVI mediano: {np.median(valid_pixels):.3f}")
print(f" NDVI p10: {np.percentile(valid_pixels, 10):.3f} (zone a stress)")
print(f" NDVI p90: {np.percentile(valid_pixels, 90):.3f} (zone ottimali)")
bbox_wgs84 = (17.42, 40.34, 17.52, 40.42)
save_ndvi_geotiff(ndvi_map, bbox_wgs84, "ndvi_manduria_20240717.tif")
print("GeoTIFF NDVI salvato: ndvi_manduria_20240717.tif")
API Meteorologiche per AgriTech: Confronto e Implementazione
I dati meteorologici sono la seconda fonte dati fondamentale dell'agricoltura di precisione. Integrati con NDVI e dati IoT al suolo, permettono di costruire modelli predittivi per stress idrico, rischio fitopatie, evapotraspirazione e previsione di resa. Il panorama delle API meteo nel 2025 si articola in provider gratuiti open-source, provider commerciali con tier gratuiti e servizi regionali delle ARPA italiane.
API Meteorologiche per AgriTech - Confronto 2025
| Provider | Prezzo | Risoluzione Spaziale | Forecast | Variabili Agro | Archivio Storico | API |
|---|---|---|---|---|---|---|
| Open-Meteo | Gratuito (non-commerciale); ~$15/mese commercial | 1-11 km (multi-model) | 16 giorni | ET0, soil temp, soil moisture, vapor pressure | 1940-oggi (ERA5) | REST JSON, Python client |
| OpenWeatherMap | Gratuito fino a 60 call/min; $40/mese pro | 2.5 km (ICON) | 5 giorni (free), 16 gg (pro) | Limitato, no ET0 | Solo paid history | REST JSON, Python SDK |
| Tomorrow.io | Gratuito 500 call/giorno; $199/mese core | 1 km | 21 giorni | SoilMoisture, ET0, spray conditions, pest risk | 6 anni | REST, WebSocket, gRPC |
| ARPA Lombardia / Piemonte / Veneto | Gratuito (open data regionale) | Stazioni puntuali (rete ~1500 stazioni in Italia) | No (solo osservato) | Tutte le variabili agro-meteo | Archivio completo (decenni) | WMS, REST, download CSV/JSON |
| Meteoam / CFS/ECMWF | ECMWF open data gratuito; CFS NOAA gratuito | 9-25 km | 10-45 giorni | Variabili base meteo | 1940-oggi via ERA5 | ECMWF API (Python), CDS API |
| Visual Crossing | Gratuito 1000 record/giorno; $35/mese standard | Interpolato da stazioni | 15 giorni | ET0, Growing Degree Days, frost risk | Illimitato paid | REST JSON, CSV, SQL-like |
Per applicazioni AgriTech in produzione, la raccomandazione e usare Open-Meteo come base (gratuito, open-source, dati ERA5 per storici, 16 giorni di forecast) integrandolo con le reti di stazioni ARPA per calibrazione locale. Open-Meteo include variabili agrometeorologiche critiche come ET0 (evapotraspirazione di riferimento), soil moisture a più profondità e vapore acqueo, che non sempre sono disponibili gratuitamente nei provider commerciali.
Implementazione Client Open-Meteo con Cache e Retry
import openmeteo_requests
import requests_cache
import pandas as pd
from retry_requests import retry
from dataclasses import dataclass
from typing import Optional
@dataclass
class WeatherForecast:
"""Dati meteo giornalieri per decisioni agronomiche."""
date: pd.DatetimeIndex
temperature_max: np.ndarray # Celsius
temperature_min: np.ndarray # Celsius
precipitation: np.ndarray # mm
et0: np.ndarray # mm/giorno - evapotraspirazione di riferimento (FAO-56)
wind_speed_max: np.ndarray # km/h
solar_radiation: np.ndarray # MJ/m2/giorno
soil_moisture_0_7cm: np.ndarray # m3/m3 (0-7 cm profondità)
soil_moisture_7_28cm: np.ndarray # m3/m3 (7-28 cm profondità)
soil_temp_0_7cm: np.ndarray # Celsius
def get_weather_forecast(
latitude: float,
longitude: float,
start_date: Optional[str] = None, # "YYYY-MM-DD" per archivio
end_date: Optional[str] = None,
forecast_days: int = 16,
) -> WeatherForecast:
"""
Recupera dati meteo da Open-Meteo con cache locale (1 ora) e retry automatico.
Combina forecast (16 giorni) con archivio storico ERA5 se start_date specificato.
Args:
latitude: Latitudine WGS84
longitude: Longitudine WGS84
start_date: Se fornito, richiede dati storici (non forecast)
end_date: Fine periodo storico
forecast_days: Giorni di forecast (1-16)
Returns:
WeatherForecast con tutti i parametri agrometeorologici
"""
# Cache HTTP locale (1 ora di validita) + retry su errori transitori
cache_session = requests_cache.CachedSession(".cache/open_meteo", expire_after=3600)
retry_session = retry(cache_session, retries=5, backoff_factor=0.2)
client = openmeteo_requests.Client(session=retry_session)
base_url = (
"https://archive-api.open-meteo.com/v1/archive"
if start_date
else "https://api.open-meteo.com/v1/forecast"
)
params = {
"latitude": latitude,
"longitude": longitude,
"daily": [
"temperature_2m_max",
"temperature_2m_min",
"precipitation_sum",
"et0_fao_evapotranspiration",
"wind_speed_10m_max",
"shortwave_radiation_sum",
"soil_moisture_0_to_7cm",
"soil_moisture_7_to_28cm",
"soil_temperature_0_to_7cm",
],
"timezone": "Europe/Rome", # Fuso orario italiano
"wind_speed_unit": "kmh",
}
if start_date:
params["start_date"] = start_date
params["end_date"] = end_date
else:
params["forecast_days"] = forecast_days
# Open-Meteo usa ERA5 per archivio storico (1940-oggi), eccellente per agricoltura
if start_date:
# Archivio ERA5-Land: alta risoluzione 9 km per Italia
params["models"] = "era5_land"
responses = client.weather_api(base_url, params=params)
r = responses[0] # primo (e unico) punto
daily = r.Daily()
return WeatherForecast(
date=pd.date_range(
start=pd.to_datetime(daily.Time(), unit="s", utc=True),
end=pd.to_datetime(daily.TimeEnd(), unit="s", utc=True),
freq=pd.Timedelta(seconds=daily.Interval()),
inclusive="left",
),
temperature_max = daily.Variables(0).ValuesAsNumpy(),
temperature_min = daily.Variables(1).ValuesAsNumpy(),
precipitation = daily.Variables(2).ValuesAsNumpy(),
et0 = daily.Variables(3).ValuesAsNumpy(),
wind_speed_max = daily.Variables(4).ValuesAsNumpy(),
solar_radiation = daily.Variables(5).ValuesAsNumpy(),
soil_moisture_0_7cm = daily.Variables(6).ValuesAsNumpy(),
soil_moisture_7_28cm = daily.Variables(7).ValuesAsNumpy(),
soil_temp_0_7cm = daily.Variables(8).ValuesAsNumpy(),
)
# Esempio: meteo vigna Manduria (campagna 2024)
lat_manduria, lon_manduria = 40.38, 17.47
weather_storico = get_weather_forecast(
latitude=lat_manduria,
longitude=lon_manduria,
start_date="2024-04-01",
end_date="2024-09-30",
)
weather_forecast = get_weather_forecast(
latitude=lat_manduria,
longitude=lon_manduria,
forecast_days=16,
)
df_meteo = pd.DataFrame({
"data": weather_storico.date,
"t_max_C": weather_storico.temperature_max,
"t_min_C": weather_storico.temperature_min,
"pioggia_mm": weather_storico.precipitation,
"et0_mm": weather_storico.et0,
"rad_MJ": weather_storico.solar_radiation,
"sw_0_7cm": weather_storico.soil_moisture_0_7cm,
"sw_7_28cm": weather_storico.soil_moisture_7_28cm,
})
print(df_meteo.describe())
print(f"\nET0 totale campagna apr-set 2024: {df_meteo['et0_mm'].sum():.1f} mm")
print(f"Pioggia totale campagna apr-set 2024: {df_meteo['pioggia_mm'].sum():.1f} mm")
deficit_idrico = df_meteo["et0_mm"].sum() - df_meteo["pioggia_mm"].sum()
print(f"Deficit idrico stagionale (ET0 - pioggia): {deficit_idrico:.1f} mm")
Pipeline Geospaziale Completa: GeoPandas, rasterio e PostGIS
La gestione professionale di dati satellite richiede un'infrastruttura geospaziale completa che integri dati raster (immagini satellite NDVI, mappe meteo) con dati vettoriali (confini appezzamenti, zone gestionali, punti campionamento). La pipeline che descriviamo qui adotta lo stack Python standard di fatto per il geospatial: rasterio per i raster, GeoPandas per i vettori, PostGIS come database spaziale e GDAL come motore sottostante di traslazione di formati.
Architettura della Pipeline Geospaziale
Stack Tecnologico Pipeline Geospaziale AgriTech
┌─────────────────────────────────────────────────────────────────────────┐
│ DATI DI INPUT │
│ [Sentinel-2 GeoTIFF] [Shapefile Appezzamenti] [CSV Meteo/IoT] │
│ │ │ │ │
│ rasterio geopandas pandas │
└─────────┼──────────────────────┼───────────────────────┼────────────────┘
│ │ │
┌─────────▼──────────────────────▼───────────────────────▼────────────────┐
│ PROCESSING LAYER (Python) │
│ [NDVI Calculation] [Zonal Statistics] [Spatial Join] │
│ numpy/rasterio rasterstats geopandas │
│ │ │ │ │
└─────────┼──────────────────────┼───────────────────────┼────────────────┘
│ │ │
└──────────────────────▼───────────────────────┘
│
┌────────────────────────────────▼────────────────────────────────────────┐
│ STORAGE LAYER (PostGIS + S3) │
│ [PostGIS - geometrie + attributi] [S3/MinIO - GeoTIFF raster] │
│ - Tabella parcelle con NDVI medio - File raster originali │
│ - Serie storica per parcella - Archivio scene satellite │
│ - Spatial index GIST - Formato Cloud Optimized GeoTIFF │
└────────────────────────────────┬────────────────────────────────────────┘
│
┌────────────────────────────────▼────────────────────────────────────────┐
│ SERVING LAYER │
│ [REST API - FastAPI] [Dashboard - Grafana] [ML Models] │
│ NDVI per appezzamento Mappe NDVI tempo reale Predizione stress │
└─────────────────────────────────────────────────────────────────────────┘
Zonal Statistics: NDVI Medio per Appezzamento con rasterstats
import geopandas as gpd
from rasterstats import zonal_stats
import rasterio
from sqlalchemy import create_engine, text
from geoalchemy2 import Geometry
import pandas as pd
# Carica shapefile appezzamenti vigna
gdf_parcelle = gpd.read_file("dati/parcelle_vigna_manduria.shp")
gdf_parcelle = gdf_parcelle.to_crs("EPSG:4326") # Riproietta in WGS84
print(f"Appezzamenti caricati: {len(gdf_parcelle)}")
print(gdf_parcelle[["id_parcella", "varieta", "ettari", "anno_impianto"]].head(10))
def compute_zonal_ndvi(
ndvi_raster_path: str,
parcelle_gdf: gpd.GeoDataFrame,
acquisition_date: str,
) -> gpd.GeoDataFrame:
"""
Calcola statistiche NDVI zonali per ogni appezzamento.
Per ogni parcella calcola:
- NDVI medio (indicatore di vigor generale)
- NDVI mediano (robusto agli outlier)
- NDVI std (indica variabilità intra-parcella)
- Percentile 10 (zone a stress all'interno della parcella)
- Percentile 90 (zone ottimali)
- Percentuale pixel validi (esclude NaN da nuvole)
Returns:
GeoDataFrame con colonne NDVI aggiunte
"""
stats = zonal_stats(
vectors=parcelle_gdf.geometry,
raster=ndvi_raster_path,
stats=["mean", "median", "std", "percentile_10", "percentile_90", "count", "nodata"],
nodata=np.nan,
geojson_out=False,
)
df_stats = pd.DataFrame(stats)
df_stats.columns = [
"ndvi_mean", "ndvi_median", "ndvi_std",
"ndvi_p10", "ndvi_p90", "pixel_count", "pixel_nodata",
]
df_stats["acquisition_date"] = pd.to_datetime(acquisition_date)
df_stats["pixel_valid_pct"] = (
df_stats["pixel_count"] /
(df_stats["pixel_count"] + df_stats["pixel_nodata"].fillna(0)) * 100
)
# Classificazione stress basata su NDVI medio (soglie per vite in estate)
df_stats["stress_category"] = pd.cut(
df_stats["ndvi_mean"],
bins=[-1.0, 0.25, 0.40, 0.55, 0.70, 1.0],
labels=["Stress Severo", "Stress Moderato", "Normale", "Buono", "Ottimale"],
)
return gpd.GeoDataFrame(
pd.concat([parcelle_gdf.reset_index(drop=True), df_stats], axis=1),
geometry="geometry",
crs=parcelle_gdf.crs,
)
# Calcola NDVI zonale per tutte le parcelle
gdf_ndvi = compute_zonal_ndvi(
ndvi_raster_path="ndvi_manduria_20240717.tif",
parcelle_gdf=gdf_parcelle,
acquisition_date="2024-07-17",
)
# Parcelle in stress - priorità irrigazione
parcelle_in_stress = gdf_ndvi[
gdf_ndvi["stress_category"].isin(["Stress Severo", "Stress Moderato"])
]
print(f"\nParcelle in stress ({len(parcelle_in_stress)}/{len(gdf_ndvi)}):")
print(parcelle_in_stress[["id_parcella", "varieta", "ndvi_mean", "stress_category", "ettari"]])
# Salva in PostGIS per persistenza e query spaziali
engine = create_engine("postgresql://agritech:pass@localhost:5432/vigna_db")
gdf_ndvi.to_postgis(
name="ndvi_storico",
con=engine,
if_exists="append",
index=False,
dtype={"geometry": Geometry("MULTIPOLYGON", srid=4326)},
)
print("\nDati NDVI salvati in PostGIS (tabella: ndvi_storico)")
Query PostGIS per Analisi Spaziali Avanzate
-- Schema PostGIS per sistema AgriTech
CREATE TABLE IF NOT EXISTS parcelle (
id_parcella SERIAL PRIMARY KEY,
codice_catasto VARCHAR(20) UNIQUE NOT NULL, -- codice catastale italiano
varieta VARCHAR(50),
ettari NUMERIC(8, 4),
anno_impianto INTEGER,
sistema_allevamento VARCHAR(30),
geom GEOMETRY(MULTIPOLYGON, 4326)
);
CREATE INDEX idx_parcelle_geom ON parcelle USING GIST(geom);
CREATE TABLE IF NOT EXISTS ndvi_storico (
id SERIAL PRIMARY KEY,
id_parcella INTEGER REFERENCES parcelle(id_parcella),
data_satellite DATE NOT NULL,
ndvi_mean NUMERIC(6, 4),
ndvi_median NUMERIC(6, 4),
ndvi_std NUMERIC(6, 4),
ndvi_p10 NUMERIC(6, 4),
ndvi_p90 NUMERIC(6, 4),
pixel_valid_pct NUMERIC(5, 2),
stress_category VARCHAR(20),
satellite VARCHAR(20) DEFAULT 'Sentinel-2',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_ndvi_parcella_data ON ndvi_storico(id_parcella, data_satellite);
-- Query: tendenza NDVI ultime 4 acquisizioni per parcella
SELECT
p.codice_catasto,
p.varieta,
n.data_satellite,
n.ndvi_mean,
LAG(n.ndvi_mean, 1) OVER (PARTITION BY n.id_parcella ORDER BY n.data_satellite) AS ndvi_prev,
n.ndvi_mean - LAG(n.ndvi_mean, 1) OVER (PARTITION BY n.id_parcella ORDER BY n.data_satellite) AS ndvi_delta
FROM parcelle p
JOIN ndvi_storico n ON p.id_parcella = n.id_parcella
WHERE n.data_satellite >= NOW() - INTERVAL '60 days'
ORDER BY p.id_parcella, n.data_satellite;
-- Query: parcelle con NDVI in calo > 0.1 nell'ultimo mese (alert stress)
SELECT
p.codice_catasto,
p.varieta,
p.ettari,
latest.ndvi_mean AS ndvi_corrente,
prev.ndvi_mean AS ndvi_30gg_fa,
(latest.ndvi_mean - prev.ndvi_mean) AS variazione
FROM parcelle p
JOIN ndvi_storico latest ON p.id_parcella = latest.id_parcella
AND latest.data_satellite = (SELECT MAX(data_satellite) FROM ndvi_storico WHERE id_parcella = p.id_parcella)
JOIN ndvi_storico prev ON p.id_parcella = prev.id_parcella
AND prev.data_satellite = (SELECT MAX(data_satellite) FROM ndvi_storico
WHERE id_parcella = p.id_parcella AND data_satellite < NOW() - INTERVAL '25 days')
WHERE (latest.ndvi_mean - prev.ndvi_mean) < -0.10
ORDER BY variazione ASC;
Modello Predittivo dello Stress Idrico con ML
Combinare NDVI satellite con dati meteorologici e IoT permette di costruire modelli predittivi dello stress idrico con 3-7 giorni di anticipo, consentendo pianificazione proattiva dell'irrigazione. Il modello descritto qui integra tre fonti di dati: serie temporale NDVI da Sentinel-2, bilancio idrico calcolato da ET0 e precipitazioni, e soil moisture da sensori IoT (o da Open-Meteo ERA5-Land se non disponibili sensori fisici).
Feature Engineering per il Modello
import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import classification_report
import joblib
from typing import Tuple
def build_stress_features(
df_ndvi: pd.DataFrame, # colonne: data, ndvi_mean, ndvi_std, ndvi_p10
df_meteo: pd.DataFrame, # colonne: data, t_max_C, t_min_C, pioggia_mm, et0_mm, sw_0_7cm
lookback_days: int = 14,
) -> pd.DataFrame:
"""
Costruisce feature set per modello predittivo stress idrico.
Combina NDVI satellite con bilancio idrico e meteo.
Features generate:
- NDVI corrente e variazione a 5/10 giorni
- Deficit idrico cumulato (ET0 - pioggia) a 7/14/21 giorni
- Growing Degree Days (GDD) da inizio stagione
- Soil moisture media e tendenza
- Variabilità termica (t_max - t_min)
"""
df = df_meteo.merge(df_ndvi, on="data", how="left").sort_values("data")
df["ndvi_mean"] = df["ndvi_mean"].interpolate(method="linear", limit=5)
# Bilancio idrico cumulato (mm) - positivo = surplus, negativo = deficit
df["bilancio_idrico"] = df["pioggia_mm"] - df["et0_mm"]
df["deficit_7gg"] = df["bilancio_idrico"].rolling(7).sum()
df["deficit_14gg"] = df["bilancio_idrico"].rolling(14).sum()
df["deficit_21gg"] = df["bilancio_idrico"].rolling(21).sum()
# NDVI trend (variazione puntuale e su finestra)
df["ndvi_delta_5gg"] = df["ndvi_mean"] - df["ndvi_mean"].shift(5)
df["ndvi_delta_10gg"] = df["ndvi_mean"] - df["ndvi_mean"].shift(10)
# Growing Degree Days (base 10 gradi - standard per vite)
df["t_media"] = (df["t_max_C"] + df["t_min_C"]) / 2
df["gdd_giornaliero"] = np.maximum(df["t_media"] - 10, 0)
df["gdd_cumulato"] = df["gdd_giornaliero"].cumsum()
# Variabilità termica (indicatore stress termico)
df["escursione_termica"] = df["t_max_C"] - df["t_min_C"]
# Soil moisture media e trend
df["sm_media_7gg"] = df["sw_0_7cm"].rolling(7).mean()
df["sm_trend"] = df["sw_0_7cm"] - df["sw_0_7cm"].shift(3)
feature_cols = [
"ndvi_mean", "ndvi_std", "ndvi_p10",
"ndvi_delta_5gg", "ndvi_delta_10gg",
"deficit_7gg", "deficit_14gg", "deficit_21gg",
"et0_mm", "pioggia_mm",
"t_max_C", "t_min_C", "escursione_termica",
"gdd_cumulato", "gdd_giornaliero",
"sm_media_7gg", "sm_trend",
]
return df[["data"] + feature_cols].dropna()
def train_stress_model(
features_df: pd.DataFrame,
labels: pd.Series, # 0=no stress, 1=stress moderato, 2=stress severo
) -> Tuple[Pipeline, dict]:
"""
Addestra modello GradientBoosting per classificazione stress idrico.
Usa pipeline sklearn con StandardScaler integrato.
Returns:
(pipeline_addestrata, metriche_validazione)
"""
feature_cols = [c for c in features_df.columns if c != "data"]
X = features_df[feature_cols].values
y = labels.values
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
pipeline = Pipeline([
("scaler", StandardScaler()),
("clf", GradientBoostingClassifier(
n_estimators=200,
learning_rate=0.05,
max_depth=4,
subsample=0.8,
random_state=42,
)),
])
pipeline.fit(X_train, y_train)
y_pred = pipeline.predict(X_test)
cv_scores = cross_val_score(pipeline, X, y, cv=5, scoring="f1_weighted")
metriche = {
"f1_cv_mean": cv_scores.mean(),
"f1_cv_std": cv_scores.std(),
"classification_report": classification_report(
y_test, y_pred,
target_names=["No Stress", "Stress Moderato", "Stress Severo"],
),
}
return pipeline, metriche
# Salva modello per deployment
def save_model(pipeline: Pipeline, path: str) -> None:
joblib.dump(pipeline, path)
print(f"Modello salvato: {path}")
def predict_stress_7days(
pipeline: Pipeline,
features_forecast: pd.DataFrame, # features calcolate su dati forecast 7 giorni
) -> pd.DataFrame:
"""
Predici stress idrico per i prossimi 7 giorni.
Usa le features calcolate su dati forecast meteo (Open-Meteo, 16 gg).
Returns:
DataFrame con data, probabilità per classe, classe predetta
"""
feature_cols = [c for c in features_forecast.columns if c != "data"]
X_forecast = features_forecast[feature_cols].values
proba = pipeline.predict_proba(X_forecast)
pred_class = pipeline.predict(X_forecast)
labels = ["no_stress", "stress_moderato", "stress_severo"]
result_df = pd.DataFrame(proba, columns=[f"prob_{l}" for l in labels])
result_df["classe_predetta"] = pred_class
result_df["data"] = features_forecast["data"].values
result_df["label_predetto"] = [labels[c] for c in pred_class]
return result_df[["data", "label_predetto", "prob_no_stress",
"prob_stress_moderato", "prob_stress_severo"]]
Case Study: Monitoraggio NDVI in una Vigna di Primitivo a Manduria
Per rendere concreti i concetti descritti, presentiamo un case study ispirato a un'applicazione reale nel territorio del DOC Primitivo di Manduria (Taranto, Puglia). L'area presenta condizioni tipiche del viticoltura mediterranea: estate calda e siccitosa (precipitazioni estive < 50 mm), suoli argilloso-calcarei, sistemi di allevamento a controspalliera e ad alberello.
Parametri del Case Study - Vigna Primitivo Manduria
| Parametro | Valore |
|---|---|
| Localizzazione | Manduria (TA), Puglia - lat 40.38, lon 17.47 |
| Superficie totale | 35 ettari suddivisi in 12 appezzamenti |
| Varieta principali | Primitivo (80%), Negroamaro (20%) |
| Sistema allevamento | Alberello pugliese (8 app.), Controspalliera (4 app.) |
| Anno impianto | 2003-2015 (vigna giovane/adulta mista) |
| Irrigazione | Goccia a goccia (non tutti gli appezzamenti) |
| Satellite di monitoraggio | Sentinel-2A/B, L2A, risoluzione 10m |
| Frequenza acquisizioni usate | 28 scene in campagna 2024 (cloud < 20%) |
| Sensori IoT integrati | 6 stazioni meteo, 18 sensori umidita suolo a 30/60 cm |
| Periodo analisi | Aprile - Ottobre 2024 |
Risultati NDVI Stagionale e Decisioni Irrigazione
L'analisi NDVI stagionale ha rivelato pattern di stress idrico significativamente differenziati tra gli appezzamenti, con implicazioni dirette per la gestione irrigua e la qualità del prodotto:
Risultati NDVI per Appezzamento - Campagna 2024
| Appezzamento | Varieta | NDVI apr | NDVI giù | NDVI lug (picco) | NDVI ago | NDVI set | Alert Irrigazione |
|---|---|---|---|---|---|---|---|
| Parco A1 | Primitivo/alberello | 0.28 | 0.42 | 0.58 | 0.41 | 0.31 | Stress moderato ago |
| Parco A2 | Primitivo/alberello | 0.22 | 0.36 | 0.47 | 0.29 | 0.24 | Stress severo ago-set |
| Parco B1 | Primitivo/controspalliera | 0.31 | 0.53 | 0.66 | 0.57 | 0.44 | Nessun alert |
| Parco B2 | Primitivo/controspalliera | 0.29 | 0.49 | 0.63 | 0.52 | 0.40 | Nessun alert |
| Parco C1 | Negroamaro/alberello | 0.25 | 0.44 | 0.55 | 0.38 | 0.28 | Stress moderato ago |
| Parco C2 | Negroamaro/controspalliera | 0.30 | 0.51 | 0.61 | 0.50 | 0.38 | Nessun alert |
Il pattern emerso e chiaro e agronomicamente significativo: gli appezzamenti ad alberello senza irrigazione di soccorso mostrano stress idrico più marcato in agosto-settembre rispetto agli appezzamenti a controspalliera con impianto a goccia. L'appezzamento A2, in particolare, ha mostrato un calo NDVI da 0.47 a 0.29 tra luglio e agosto 2024, correlato temporalmente con una sequenza di 23 giorni senza precipitazioni e temperature massime superiori a 36 gradi.
Script di Alerting Automatico
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import List
def check_ndvi_alerts(
gdf_ndvi: gpd.GeoDataFrame,
stress_threshold: float = 0.35,
min_valid_pixels_pct: float = 70.0,
) -> List[dict]:
"""
Controlla le parcelle con NDVI sotto soglia di stress.
Genera lista di alert per notifica agronomo.
Args:
gdf_ndvi: GeoDataFrame con colonna ndvi_mean e pixel_valid_pct
stress_threshold: Soglia NDVI sotto cui scattare alert (default 0.35 per vite)
min_valid_pixels_pct: Percentuale minima pixel validi per affidabilità
Returns:
Lista di dict con dettagli alert per parcella
"""
alerts = []
for _, row in gdf_ndvi.iterrows():
if row["pixel_valid_pct"] < min_valid_pixels_pct:
continue # Troppi pixel nuvolosi, dato inaffidabile
if row["ndvi_mean"] < stress_threshold:
severity = "SEVERO" if row["ndvi_mean"] < 0.25 else "MODERATO"
alerts.append({
"id_parcella": row["id_parcella"],
"varieta": row.get("varieta", "N/A"),
"ettari": row.get("ettari", 0),
"ndvi_corrente": round(row["ndvi_mean"], 3),
"ndvi_soglia": stress_threshold,
"severity": severity,
"data": row.get("acquisition_date", "N/A"),
"pixel_valid": round(row["pixel_valid_pct"], 1),
})
return sorted(alerts, key=lambda x: x["ndvi_corrente"])
def send_alert_email(
alerts: List[dict],
recipient: str,
sender: str,
smtp_host: str = "smtp.gmail.com",
smtp_port: int = 587,
) -> None:
"""Invia email di alert NDVI all'agronomo."""
if not alerts:
return
body_lines = [
f"Alert Monitoraggio NDVI Satellite - {alerts[0]['data']}\n",
f"Parcelle in stress idrico: {len(alerts)}\n",
"=" * 60,
"",
]
for a in alerts:
body_lines.append(
f" [{a['severity']}] Parcella {a['id_parcella']} ({a['varietà]}, {a['ettari']} ha)\n"
f" NDVI: {a['ndvi_corrente']} (soglia: {a['ndvi_soglia']}) | Pixel validi: {a['pixel_valid']}%\n"
)
msg = MIMEMultipart()
msg["Subject"] = f"[ALERT NDVI] {len(alerts)} parcelle in stress - {alerts[0]['data']}"
msg["From"] = sender
msg["To"] = recipient
msg.attach(MIMEText("\n".join(body_lines), "plain"))
smtp_password = os.getenv("SMTP_PASSWORD")
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(sender, smtp_password)
server.send_message(msg)
print(f"Alert inviato a {recipient} per {len(alerts)} parcelle in stress")
ROI del Sistema di Monitoraggio Satellite
La quantificazione del Return on Investment del monitoraggio satellite e fondamentale per giustificare l'investimento tecnologico alle aziende viticole. Per la vigna di Manduria, l'analisi costi-benefici per la campagna 2024 mostra:
Analisi ROI - Sistema Monitoraggio Satellite Vigna Manduria (35 ha, campagna 2024)
| Voce | Valore | Note |
|---|---|---|
| COSTI | ||
| Accesso dati Sentinel-2 (CDSE) | 0 EUR | Open data gratuito |
| API meteo Open-Meteo | 0 EUR | Non-commercial gratuito |
| Sviluppo pipeline Python | 2.500 EUR | 30 ore sviluppatore x 83 EUR/h (una tantum, ammortizzato 3 anni) |
| Hosting cloud (VM + storage) | 600 EUR/anno | VM 4 vCPU + 50 GB storage S3 |
| Consulenza agronomica integrazione | 1.000 EUR | Una tantum, calibrazione soglie coltura-specifica |
| Totale costo anno 1 | 4.100 EUR | Successivi anni: ~1.400 EUR/anno |
| BENEFICI STIMATI (35 ha, 100.000 bottiglie/anno) | ||
| Riduzione irrigazione (ottimizzazione timing) | 2.800 EUR | 15% risparmio acqua, 8.500 m3 x 0.33 EUR/m3 |
| Riduzione trattamenti fitosanitari | 1.750 EUR | Intervento mirato solo zone a rischio (-20% trattamenti) |
| Miglioramento qualità uva | 8.500 EUR | +0.5 punti grado Brix medio in 12 ha, +0.20 EUR/kg premio qualità |
| Riduzione perdite da stress idrico | 3.200 EUR | Recupero 8% resa in 2 appezzamenti a stress severo previsto |
| Totale benefici anno 1 | 16.250 EUR | |
| ROI anno 1 | 296% | Payback in circa 3 mesi |
Google Earth Engine: Cloud Processing per Analisi su Larga Scala
Per analisi su scala regionale o nazionale (ad esempio, mappatura varietale a livello di comprensorio DOC, valutazione danni da gelate primaverili su scala provinciale, monitoraggio della PAC per enti pagatori come AGEA), Google Earth Engine (GEE) offre capacità di calcolo cloud-native che altrimenti richiederebbero infrastrutture HPC dedicate.
import ee
# Autenticazione Google Earth Engine (richiede account GEE)
ee.Authenticate()
ee.Initialize(project="your-gee-project-id")
def calculate_ndvi_gee(
aoi_geojson: dict,
start_date: str,
end_date: str,
cloud_threshold: int = 20,
) -> ee.ImageCollection:
"""
Calcola NDVI su larga scala usando Google Earth Engine.
Adatto per analisi su comprensori DOC o scala regionale.
Args:
aoi_geojson: GeoJSON dell'area di interesse
start_date: "YYYY-MM-DD"
end_date: "YYYY-MM-DD"
cloud_threshold: Percentuale max copertura nuvolosa (0-100)
Returns:
ImageCollection con NDVI calcolato per ogni scena
"""
aoi = ee.Geometry(aoi_geojson)
def mask_s2_clouds(image: ee.Image) -> ee.Image:
"""Maschera nuvole usando QA60 band di Sentinel-2."""
qa = image.select("QA60")
cloud_bit_mask = 1 << 10 # bit 10: nuvole opache
cirrus_bit_mask = 1 << 11 # bit 11: nuvole cirro
mask = qa.bitwiseAnd(cloud_bit_mask).eq(0).And(
qa.bitwiseAnd(cirrus_bit_mask).eq(0))
return image.updateMask(mask).divide(10000) # scala a [0,1]
def add_ndvi(image: ee.Image) -> ee.Image:
"""Aggiunge banda NDVI all'immagine Sentinel-2."""
ndvi = image.normalizedDifference(["B8", "B4"]).rename("NDVI")
return image.addBands(ndvi)
collection = (
ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED")
.filterBounds(aoi)
.filterDate(start_date, end_date)
.filter(ee.Filter.lt("CLOUDY_PIXEL_PERCENTAGE", cloud_threshold))
.map(mask_s2_clouds)
.map(add_ndvi)
)
return collection
# Calcola NDVI mediano per stagione (mosaico cloud-free)
def get_seasonal_ndvi_mosaic(
collection: ee.ImageCollection,
aoi: ee.Geometry,
) -> ee.Image:
"""Crea mosaico NDVI stagionale (mediano = robusto a outlier residui)."""
ndvi_mosaic = collection.select("NDVI").median().clip(aoi)
return ndvi_mosaic
# Esempio: Comprensorio DOC Primitivo di Manduria (~21.000 ha)
aoi_doc_manduria = {
"type": "Polygon",
"coordinates": [[
[17.2, 40.2], [17.8, 40.2], [17.8, 40.6],
[17.2, 40.6], [17.2, 40.2],
]],
}
collection_estate = calculate_ndvi_gee(
aoi_geojson=aoi_doc_manduria,
start_date="2024-07-01",
end_date="2024-08-31",
cloud_threshold=15,
)
print(f"Scene disponibili: {collection_estate.size().getInfo()}")
# Export NDVI mosaic su Google Drive (per analisi successive in Python locale)
ndvi_mosaic = get_seasonal_ndvi_mosaic(collection_estate, ee.Geometry(aoi_doc_manduria))
task = ee.batch.Export.image.toDrive(
image=ndvi_mosaic,
description="NDVI_DOC_Manduria_Estate2024",
folder="AgriTech_Export",
fileNamePrefix="ndvi_manduria_estate2024",
region=ee.Geometry(aoi_doc_manduria),
scale=10,
crs="EPSG:4326",
maxPixels=1e10,
)
task.start()
print(f"Export avviato - ID task: {task.id}")
Normativa e Open Data per l'AgriTech in Italia
L'ecosistema di dati aperti agricoli in Italia e strutturato attorno a tre attori principali: AGEA (Agenzia per le Erogazioni in Agricoltura), il SIAN (Sistema Informativo Agricolo Nazionale) e i portali regionali collegati. Nel febbraio 2025, AGEA ha completato la migrazione del SIAN verso il Polo Strategico Nazionale, aggiornando l'infrastruttura cloud e ampliando le capacità di elaborazione dati geospaziali.
Principali Fonti Open Data Agricoli Italia
| Fonte | URL | Dati Disponibili | Formato | Aggiornamento |
|---|---|---|---|---|
| SIAN Open Data | dati.sian.it | Ortofoto ad alta risoluzione, Mappa Uso del Suolo (AI-based), Piani Grafici Aziendali | GeoTIFF, Shapefile, WFS | Annuale |
| AGEA EO Services | sian.it | Ortofoto multispettrali, immagini radar X-band, DEM, DSM | GeoTIFF, ECW | Periodico (campagna) |
| ISTAT - Statistiche Agricole | istat.it/agricoltura | SAU, produzioni, struttura aziende, censimento agricoltura | CSV, JSON, SDMX | Annuale |
| Geoportale Nazionale MATTM | geoportale.gov.it | Carta Natura, Corine Land Cover, IDT, DBT | WMS, WFS, Shapefile | Variabile |
| ARPA Regionale (15 regioni) | arpa.[regione].it | Dati meteoclimatici stazioni, modelli previsionali regionali | CSV, JSON, NetCDF | Quasi real-time |
| Copernicus Land Service | land.copernicus.eu | CORINE Land Cover, Grassland, Water and Wetness, Urban Atlas | GeoTIFF, Shapefile | Triennale/annuale |
Crediti d'Imposta PNRR Transizione 5.0 per AgriTech
Gli investimenti in sistemi di monitoraggio satellite e agricoltura di precisione possono qualificarsi per i benefici del Piano Transizione 5.0 (Legge 207/2024 - Legge di Bilancio 2025). Le imprese agricole, ammesse dal 2025, possono accedere ai crediti d'imposta per investimenti in beni strumentali materiali e immateriali 4.0, inclusi software di gestione aziendale con componenti di intelligenza artificiale e analisi dati geospaziali.
Aliquote Credito d'Imposta Transizione 5.0 per Imprese Agricole (2025)
| Fascia Investimento | Risparmio Energetico 3-6% | Risparmio 6-10% | Risparmio >10% |
|---|---|---|---|
| Fino a 2.5M EUR | 35% | 40% | 45% |
| Da 2.5M a 10M EUR | 15% | 20% | 25% |
| Da 10M a 50M EUR | 5% | 10% | 15% |
I sistemi di irrigazione di precisione guidati da NDVI e previsioni meteo possono qualificarsi per la fascia "risparmio energetico >6%" se documentano la riduzione del consumo idrico (energia pompaggio) rispetto alla baseline. Richiesta perizia tecnica asseverata.
Architettura di Produzione: Best Practices e Anti-Pattern
Dopo aver descritto le componenti, e importante sintetizzare le best practices per un'implementazione robusta in produzione e identificare gli anti-pattern comuni che portano a sistemi fragili o inaccurati.
Best Practices - Sistema Monitoraggio Satellite AgriTech
- Filtraggio nuvoloso progressivo: Non usare solo la percentuale di cloud cover del prodotto (metadata Level-1). Applicare sempre il masking a livello di pixel con QA60 (Sentinel-2) o BQA (Landsat). Un prodotto al 5% di nuvole può avere un appezzamento specifico completamente oscurato.
- Cloud Optimized GeoTIFF (COG): Archiviare i raster NDVI in formato COG per accesso range-request efficiente da S3/MinIO. Evitare GeoTIFF classici non ottimizzati per storage cloud.
- Proiezione coerente: Standardizzare TUTTO su EPSG:4326 (WGS84) per i dati finali o su UTM zona 32N (EPSG:32632) per l'Italia per mantenere le misure metriche corrette. Mai mescolare CRS senza riproiezione esplicita.
- Validazione dati con ground truth IoT: Calibrare le soglie NDVI con misure in campo (sensori umidita suolo, misuratore LAI portatile, dati agronomici raccolti). NDVI da solo senza calibrazione locale può dare alert falsi positivi/negativi del 15-30%.
- Gestione archivio temporale: Mantenere almeno 3-5 anni di serie storica NDVI per ogni parcella. L'anomalia NDVI rispetto alla media storica (stesso mese, stessa coltura) e molto più informativa del valore assoluto.
- Rate limiting e caching API: CDSE e Open-Meteo hanno rate limit. Implementare sempre cache locale (file-based o Redis) per evitare re-download inutili. Un download Sentinel-2 tarda 30-120 secondi: non richiedere lo stesso dato due volte.
- Logging e audit trail: Ogni calcolo NDVI deve essere tracciabile alla scena satellite sorgente, data acquisizione, percentuale cloud cover, versione algoritmo. Fondamentale per audit PAC/AGEA e per il debug di anomalie.
Anti-Pattern Comuni - Sistemi Satellite AgriTech
- Ignorare la correzione atmosferica: Usare Level-1C (top-of-atmosphere) invece di Level-2A (bottom-of-atmosphere) introduce errori sistematici nell'NDVI dell'ordine del 10-20% in condizioni di alta umidita atmosferica (nebbie padane, mare Adriatico). Usare SEMPRE L2A per applicazioni quantitative.
- NDVI come unico indicatore: Affidarsi esclusivamente a NDVI ignorando EVI (per vegetazione densa), NDWI (stress idrico), Red Edge NDVI (stress precoce prima che sia visibile sull'NDVI). Un sistema professionale usa almeno 3-4 indici in combinazione.
- Dimensione pixel e parcella: Applicare NDVI a parcelle inferiori a 0.3 ha con pixel a 10m produce medie statisticamente inaffidabili (meno di 30 pixel). Per parcelle piccole, considerare Planet Labs (3.7m) o sensori drone.
- Mancanza di temporal compositing: Prendere la singola acquisizione disponibile per una data, anche se al 19% di cloud cover, porta a artefatti NDVI nelle zone nuvolose parziali. Usare sempre medoid compositing su finestra temporale di 10-15 giorni.
- Pipeline senza versionamento dati: Ricalcolare NDVI con versioni diverse di Python/rasterio/sentinelhub produce valori leggermente diversi per lo stesso dato sorgente. Versionare le pipeline di calcolo con DVC o equivalente.
Pipeline Completa: Orchestrazione con Prefect
Integrando tutti i componenti descritti, la pipeline completa di monitoraggio satellite può essere orchestrata con Prefect (vedi la serie Orchestrazione Pipeline) per l'esecuzione automatica ogni 5 giorni (alla frequenza di rivisitazione Sentinel-2).
from prefect import flow, task
from prefect.schedules import CronSchedule
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
@task(retries=3, retry_delay_seconds=60, name="search-sentinel2-products")
def search_products_task(bbox: tuple, lookback_days: int = 7) -> list[dict]:
"""Task Prefect: ricerca prodotti Sentinel-2 recenti."""
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=lookback_days)
products = search_sentinel2_products(
bbox=bbox,
start_date=start_date.strftime("%Y-%m-%dT00:00:00.000Z"),
end_date=end_date.strftime("%Y-%m-%dT23:59:59.000Z"),
cloud_cover_max=20.0,
)
logger.info(f"Trovati {len(products)} prodotti Sentinel-2")
return products
@task(retries=2, retry_delay_seconds=120, name="download-compute-ndvi")
def compute_ndvi_task(
product: dict,
aoi_bbox: tuple,
output_dir: str = "/data/ndvi",
) -> str:
"""Task Prefect: scarica bande e calcola NDVI per un prodotto."""
# Implementazione basata su funzioni definite nelle sezioni precedenti
acquisition_date = product["ContentDate"]["Start"][:10]
output_path = f"{output_dir}/ndvi_{acquisition_date}.tif"
# Download + calcolo (codice omesso per brevita, usa funzioni precedenti)
logger.info(f"NDVI calcolato per {acquisition_date}: {output_path}")
return output_path
@task(name="zonal-stats-postgis")
def zonal_stats_task(ndvi_path: str, parcelle_shapefile: str) -> dict:
"""Task Prefect: calcola statistiche zonali e salva in PostGIS."""
gdf_parcelle = gpd.read_file(parcelle_shapefile).to_crs("EPSG:4326")
acquisition_date = ndvi_path.split("ndvi_")[1].replace(".tif", "")
gdf_ndvi = compute_zonal_ndvi(ndvi_path, gdf_parcelle, acquisition_date)
engine = create_engine(os.getenv("POSTGIS_URL"))
gdf_ndvi.to_postgis("ndvi_storico", engine, if_exists="append", index=False)
alerts = check_ndvi_alerts(gdf_ndvi)
return {"parcelle_processate": len(gdf_ndvi), "alerts": len(alerts), "alert_details": alerts}
@task(name="send-alerts")
def alerts_task(stats: dict) -> None:
"""Task Prefect: invia alert email se presenti."""
if stats["alerts"] > 0:
send_alert_email(
alerts=stats["alert_details"],
recipient=os.getenv("AGRONOMO_EMAIL"),
sender=os.getenv("ALERT_EMAIL"),
)
logger.info(f"Alert inviati per {stats['alerts']} parcelle")
@flow(
name="satellite-monitoring-pipeline",
schedule=CronSchedule(cron="0 8 */5 * *"), # ogni 5 giorni alle 8:00
)
def satellite_monitoring_flow(
bbox: tuple = (17.35, 40.28, 17.60, 40.50),
parcelle_shapefile: str = "/data/parcelle_vigna.shp",
) -> None:
"""
Pipeline completa di monitoraggio satellite per vigna.
Si esegue ogni 5 giorni, allineata alla frequenza Sentinel-2.
"""
products = search_products_task(bbox=bbox)
if not products:
logger.warning("Nessun prodotto trovato nell'ultimo periodo")
return
# Processo il prodotto più recente con minima copertura nuvolosa
best_product = sorted(
products,
key=lambda p: next(
(a["Value"] for a in p.get("Attributes", []) if a["Name"] == "cloudCover"), 100
),
)[0]
ndvi_path = compute_ndvi_task(best_product, bbox)
stats = zonal_stats_task(ndvi_path, parcelle_shapefile)
alerts_task(stats)
logger.info(
f"Pipeline completata: {stats['parcelle_processate']} parcelle, {stats['alerts']} alert"
)
if __name__ == "__main__":
satellite_monitoring_flow()
Conclusioni e Prossimi Passi
I dati satellite Sentinel-2, accessibili gratuitamente tramite CDSE, rappresentano probabilmente la fonte dati più sottoutilizzata nell'AgriTech italiano. Con Python, rasterio, sentinelhub e un account CDSE, e possibile costruire in pochi giorni un sistema di monitoraggio NDVI che supera in qualità e granularità molte soluzioni commerciali a pagamento.
La chiave del successo non e la sofisticazione tecnologica ma la calibrazione agronomica locale: le soglie NDVI, le finestre temporali di alert, i pesi delle features nel modello predittivo devono essere tarate sulla coltura specifica, sul clima locale e sulle pratiche agronomiche dell'azienda. Un sistema calibrato sulla vite in Puglia non funziona ugualmente bene sul grano in Pianura Padana senza intervento umano di calibrazione.
I punti chiave da ricordare per chi vuole implementare un sistema simile:
- Usa sempre Sentinel-2 Level-2A (correzione atmosferica applicata) per confronti quantitativi tra date diverse.
- Combina NDVI con almeno un indice complementare (EVI per vigneti densi, NDWI per stress idrico precoce, Red Edge NDVI per monitoraggio clorofilla).
- Integra Open-Meteo ERA5-Land per il bilancio idrico: ET0 + precipitazioni + soil moisture da modello e un ottimo proxy quando non hai sensori IoT in campo.
- Mantieni una serie storica di almeno 3 anni per ogni parcella: l'anomalia rispetto alla media storica e molto più utile del valore assoluto NDVI.
- Scala con Google Earth Engine solo quando devi elaborare scale regionali o nazionali: per singole aziende, la pipeline Python locale su CDSE e più controllabile e senza lock-in.
Continua la Serie FoodTech
Hai costruito la pipeline satellite. Ora esplora gli altri articoli della serie per completare l'architettura AgriTech:
- Articolo 1: Pipeline IoT per Agricoltura di Precisione con Python e MQTT - Come integrare i sensori al suolo con la pipeline satellite.
- Articolo 2: ML Edge per Monitoraggio Colture: Computer Vision sui Campi - Modelli di computer vision per rilevamento precoce di fitopatie.
- Articolo 4: Tracciabilita Blockchain nel Food: dal Campo al Supermercato - Come i dati NDVI alimentano i certificati di qualità on-chain.
Per approfondire MLOps e deployment dei modelli predittivi descritti in questo articolo, vedi la serie MLOps per Business con MLflow e la serie LLM in Azienda: RAG Enterprise.

Commenti
Caricamento commenti...
Accedi per commentare