""" Archivo: remote_sensing.py Fecha de modificación: 03/06/2026 Autor: Equipo AgroVisión Descripción: Teledetección NDVI sobre Sentinel-2 usando **Sentinel Hub en Copernicus Data Space (CDSE)** con OAuth client credentials (BYOK). En vez de descargar COGs con rasterio, se usan dos APIs HTTP del propio CDSE: - **Statistical API**: serie temporal NDVI agregada por mes (mean/min/max) sobre el polígono, con default de los últimos 5 años. - **Process API**: PNG colorizado (heatmap NDVI ~10 m/px) para la capa del mapa. Es más fiable y ligero en capa gratuita que el pipeline rasterio/GDAL. Acciones Principales: - Obtiene token CDSE, calcula la serie NDVI mensual y el heatmap PNG. Estructura Interna: - `_get_token`: client_credentials -> access_token (cacheado por expiración). - `_default_range`: rango por defecto (últimos 5 años). - `_parse_stats`: parseo puro de la respuesta de la Statistical API a la serie. - `ndvi_series_monthly` / `ndvi_heatmap_png`: llamadas a SH (async). Entradas / Dependencias: - `httpx`; credenciales Copernicus (client_id/secret) del usuario. Salidas / Efectos: - Llamadas HTTPS a CDSE; no persiste credenciales. Ejemplo de Integración: from backend.services.remote_sensing import ndvi_series_monthly serie = await ndvi_series_monthly(geojson, None, None, cid, secret) """ from __future__ import annotations import datetime as dt import time import httpx from dateutil.relativedelta import relativedelta _TOKEN_URL = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token" _STATS_URL = "https://sh.dataspace.copernicus.eu/api/v1/statistics" _PROCESS_URL = "https://sh.dataspace.copernicus.eu/api/v1/process" _CRS_4326 = "http://www.opengis.net/def/crs/EPSG/0/4326" _RES_DEG = 0.0001 # ~10 m/px en EPSG:4326 (grados) # Evalscript para la Statistical API: NDVI con máscara de nubes vía SCL. _STATS_EVALSCRIPT = """//VERSION=3 function setup() { return { input: [{bands: ["B04", "B08", "SCL", "dataMask"]}], output: [{id: "ndvi", bands: 1}, {id: "dataMask", bands: 1}] }; } function evaluatePixel(s) { let ndvi = (s.B08 - s.B04) / (s.B08 + s.B04); let cloudy = [3, 8, 9, 10, 11].indexOf(s.SCL) > -1; // sombra/nube/cirros/nieve return {ndvi: [ndvi], dataMask: [cloudy ? 0 : s.dataMask]}; }""" # Evalscript para la Process API: NDVI colorizado (rojo->verde) como RGBA. _VIS_EVALSCRIPT = """//VERSION=3 function setup() { return {input: ["B04", "B08", "dataMask"], output: {bands: 4}}; } const STOPS = [-0.2, 0.0, 0.2, 0.4, 0.6, 0.8]; const COLORS = [[0.65,0,0.15],[0.96,0.43,0.26],[0.99,0.68,0.38], [0.85,0.94,0.55],[0.40,0.74,0.39],[0,0.41,0.22]]; function evaluatePixel(s) { let ndvi = (s.B08 - s.B04) / (s.B08 + s.B04); let c = colorBlend(ndvi, STOPS, COLORS); return [c[0], c[1], c[2], s.dataMask]; }""" _token_cache: dict[str, tuple[str, float]] = {} async def _get_token(client_id: str, client_secret: str) -> str: """ Obtiene un access_token de CDSE (client_credentials), cacheado hasta su expiración. Args: client_id (str): Client id del OAuth client de Copernicus. client_secret (str): Client secret correspondiente. Returns: str: Bearer token válido para las APIs de Sentinel Hub en CDSE. """ cached = _token_cache.get(client_id) if cached and cached[1] > time.time() + 60: return cached[0] async with httpx.AsyncClient() as client: response = await client.post( _TOKEN_URL, data={ "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, }, timeout=30, ) response.raise_for_status() payload = response.json() token = payload["access_token"] _token_cache[client_id] = (token, time.time() + float(payload.get("expires_in", 600))) return token def _default_range() -> tuple[str, str]: """Devuelve el rango por defecto (últimos 5 años) como ISO datetimes UTC.""" end = dt.date.today() start = end - relativedelta(years=5) return f"{start.isoformat()}T00:00:00Z", f"{end.isoformat()}T23:59:59Z" def _parse_stats(payload: dict) -> list[dict]: """ Convierte la respuesta de la Statistical API en la serie NDVI mensual. Args: payload (dict): JSON devuelto por la Statistical API. Returns: list[dict]: Puntos {date, mean_ndvi, min_ndvi, max_ndvi, cloud_cover, source} ordenados por fecha; se omiten intervalos sin píxeles válidos. """ series: list[dict] = [] for item in payload.get("data", []): outputs = item.get("outputs", {}) bands = outputs.get("ndvi", {}).get("bands", {}) stats = bands.get("B0", {}).get("stats", {}) mean = stats.get("mean") if mean is None or stats.get("sampleCount", 0) == 0: continue # En meses totalmente nublados/sin escena, mean puede venir como 'NaN'. if isinstance(mean, str): continue month = item["interval"]["from"][:7] series.append( { "date": f"{month}-01", "mean_ndvi": round(float(mean), 4), "min_ndvi": round(float(stats.get("min", mean)), 4), "max_ndvi": round(float(stats.get("max", mean)), 4), "cloud_cover": None, "source": "sentinel2", } ) return sorted(series, key=lambda p: p["date"]) def _stats_body(geojson: dict, start: str, end: str) -> dict: """Construye el cuerpo de la Statistical API para NDVI mensual.""" return { "input": { "bounds": {"geometry": geojson, "properties": {"crs": _CRS_4326}}, "data": [{"type": "sentinel-2-l2a"}], }, "aggregation": { "timeRange": {"from": start, "to": end}, "aggregationInterval": {"of": "P1M"}, "evalscript": _STATS_EVALSCRIPT, "resx": _RES_DEG, "resy": _RES_DEG, }, } async def ndvi_series_monthly( geojson: dict, start: str | None, end: str | None, client_id: str, client_secret: str, ) -> list[dict]: """ Calcula la serie NDVI mensual de un polígono (default: últimos 5 años). Args: geojson (dict): Geometría Polygon (EPSG:4326). start (str | None): Inicio ISO; si es None, se usan los últimos 5 años. end (str | None): Fin ISO; si es None, hoy. client_id (str): Client id de Copernicus. client_secret (str): Client secret de Copernicus. Returns: list[dict]: Serie NDVI mensual lista para persistir/graficar. """ if not start or not end: start, end = _default_range() token = await _get_token(client_id, client_secret) async with httpx.AsyncClient() as client: response = await client.post( _STATS_URL, headers={"Authorization": f"Bearer {token}"}, json=_stats_body(geojson, start, end), timeout=120, ) response.raise_for_status() return _parse_stats(response.json()) async def ndvi_heatmap_png( geojson: dict, client_id: str, client_secret: str, start: str | None = None, end: str | None = None, size: int = 512, ) -> bytes: """ Genera un PNG colorizado del NDVI (heatmap ~10 m/px) recortado al polígono. Args: geojson (dict): Geometría Polygon (EPSG:4326). client_id (str): Client id de Copernicus. client_secret (str): Client secret de Copernicus. start (str | None): Inicio del rango; default últimos 5 años. end (str | None): Fin del rango; default hoy. size (int): Lado del PNG en píxeles. Returns: bytes: Imagen PNG (RGBA) del heatmap NDVI. """ if not start or not end: start, end = _default_range() token = await _get_token(client_id, client_secret) body = { "input": { "bounds": {"geometry": geojson, "properties": {"crs": _CRS_4326}}, "data": [ { "type": "sentinel-2-l2a", "dataFilter": { "timeRange": {"from": start, "to": end}, "mosaickingOrder": "leastCC", }, } ], }, "output": { "width": size, "height": size, "responses": [{"identifier": "default", "format": {"type": "image/png"}}], }, "evalscript": _VIS_EVALSCRIPT, } async with httpx.AsyncClient() as client: response = await client.post( _PROCESS_URL, headers={"Authorization": f"Bearer {token}"}, json=body, timeout=120, ) response.raise_for_status() return response.content