alexp97 commited on
Commit
c9bb7d3
·
1 Parent(s): 8d80da2

feat(api): gateway + parcelas + teledetección NDVI/clima (Fase 3)

Browse files

- backend/api/deps.py: proxy efímero BYOK (X-User-* con fallback DEV_*) + get_db.
- backend/services/remote_sensing.py: NDVI vía Sentinel Hub en CDSE (Statistical API
serie mensual 5 años + Process API heatmap PNG); OAuth client_credentials.
- backend/services/weather.py: Open-Meteo (sin llave) + agregación mensual.
- backend/services/parcels.py: CRUD + backfill NDVI de 5 años en background.
- backend/api/{fields,ndvi,weather}.py: /api/fields (CRUD), /api/ndvi (+/raster), /api/weather.
- main.py monta los routers nuevos; repositories.delete_field por id.
- Tests: unitarios (parse stats, agregación clima, deps) + integración en vivo
(NDVI real + cadena parcela->NDVI->persistencia). 52 passed, ruff limpio.
- Desviación: se usa Sentinel Hub (CDSE) en vez de pystac-client+rioxarray.

backend/api/deps.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: deps.py
3
+ Fecha de modificación: 03/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Dependencias compartidas de la API (gateway): el proxy **efímero** de credenciales
8
+ BYOK y la sesión de base de datos. Las llaves del usuario llegan por cabeceras
9
+ `X-User-*` (modelo de cero persistencia) y, para desarrollo local, hacen *fallback* a
10
+ las variables `DEV_*`/`SUPABASE_*` del entorno. La `UserKeys` vive solo durante el
11
+ request: nunca se escribe a disco, log ni BD.
12
+
13
+ Acciones Principales:
14
+ - `get_user_keys`: extrae las llaves del request (header o entorno DEV).
15
+ - `get_db`: cede una sesión async de SQLAlchemy.
16
+
17
+ Entradas / Dependencias:
18
+ - `fastapi.Header`, `backend.db.session`.
19
+
20
+ Salidas / Efectos:
21
+ - Abre una sesión por request; no persiste credenciales.
22
+
23
+ Ejemplo de Integración:
24
+ from backend.api.deps import get_user_keys, get_db
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ from collections.abc import AsyncIterator
31
+ from dataclasses import dataclass
32
+
33
+ from fastapi import Header
34
+ from sqlalchemy.ext.asyncio import AsyncSession
35
+
36
+ from backend.db.session import get_sessionmaker
37
+
38
+
39
+ @dataclass
40
+ class UserKeys:
41
+ """Llaves BYOK de la sesión (efímeras: viven solo durante el request)."""
42
+
43
+ groq: str | None = None
44
+ copernicus_id: str | None = None
45
+ copernicus_secret: str | None = None
46
+ supabase_url: str | None = None
47
+ supabase_key: str | None = None
48
+
49
+
50
+ def get_user_keys(
51
+ x_user_groq_key: str | None = Header(default=None),
52
+ x_user_copernicus_id: str | None = Header(default=None),
53
+ x_user_copernicus_secret: str | None = Header(default=None),
54
+ x_user_supabase_url: str | None = Header(default=None),
55
+ x_user_supabase_key: str | None = Header(default=None),
56
+ ) -> UserKeys:
57
+ """
58
+ Construye las `UserKeys` del request: cabecera primero, entorno DEV como fallback.
59
+
60
+ El fallback permite el desarrollo local (las llaves en `.env` como `DEV_*`), pero en
61
+ producción siempre llegan por cabecera desde la UI y se descartan tras el request.
62
+
63
+ Returns:
64
+ UserKeys: Credenciales de la sesión (nunca se persisten).
65
+ """
66
+ return UserKeys(
67
+ groq=x_user_groq_key or os.getenv("DEV_GROQ_API_KEY") or None,
68
+ copernicus_id=x_user_copernicus_id or os.getenv("DEV_COPERNICUS_CLIENT_ID") or None,
69
+ copernicus_secret=(
70
+ x_user_copernicus_secret or os.getenv("DEV_COPERNICUS_CLIENT_SECRET") or None
71
+ ),
72
+ supabase_url=x_user_supabase_url or os.getenv("SUPABASE_URL") or None,
73
+ supabase_key=x_user_supabase_key or os.getenv("SUPABASE_ANON_KEY") or None,
74
+ )
75
+
76
+
77
+ async def get_db() -> AsyncIterator[AsyncSession]:
78
+ """Cede una sesión async de SQLAlchemy y la cierra al terminar el request."""
79
+ async with get_sessionmaker()() as session:
80
+ yield session
backend/api/fields.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: fields.py
3
+ Fecha de modificación: 03/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Router del módulo *Creación de Parcelas*: CRUD de parcelas (`/api/fields`). Al crear una
8
+ parcela, dispara en segundo plano el backfill NDVI de 5 años (si hay credenciales de
9
+ Copernicus). La geometría se valida con `FieldIn` (polígono cerrado EPSG:4326).
10
+
11
+ Estructura Interna:
12
+ - `POST /api/fields`, `GET /api/fields`, `GET /api/fields/{id}`, `DELETE /api/fields/{id}`.
13
+
14
+ Entradas / Dependencias:
15
+ - `backend.services.parcels`, `backend.api.deps`.
16
+
17
+ Ejemplo de Integración:
18
+ from backend.api.fields import router
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
24
+ from sqlalchemy.ext.asyncio import AsyncSession
25
+
26
+ from backend.api.deps import UserKeys, get_db, get_user_keys
27
+ from backend.core.schemas import FieldIn
28
+ from backend.services import parcels
29
+
30
+ router = APIRouter(prefix="/api/fields", tags=["parcelas"])
31
+
32
+
33
+ @router.post("")
34
+ async def create_field(
35
+ body: FieldIn,
36
+ background: BackgroundTasks,
37
+ keys: UserKeys = Depends(get_user_keys),
38
+ session: AsyncSession = Depends(get_db),
39
+ ) -> dict:
40
+ """Crea una parcela y agenda el backfill NDVI de 5 años en segundo plano."""
41
+ row = await parcels.create_parcel(session, body, user_id=None)
42
+ background.add_task(parcels.run_ndvi_backfill, str(row.id), body.geojson, keys)
43
+ return {"id": str(row.id), "name": row.name, "area_ha": row.area_ha}
44
+
45
+
46
+ @router.get("")
47
+ async def list_fields(session: AsyncSession = Depends(get_db)) -> list[dict]:
48
+ """Lista las parcelas registradas."""
49
+ rows = await parcels.list_parcels(session)
50
+ return [{"id": str(r.id), "name": r.name} for r in rows]
51
+
52
+
53
+ @router.get("/{field_id}")
54
+ async def get_field(field_id: str, session: AsyncSession = Depends(get_db)) -> dict:
55
+ """Devuelve una parcela por id (404 si no existe)."""
56
+ row = await parcels.get_parcel(session, field_id)
57
+ if row is None:
58
+ raise HTTPException(status_code=404, detail="Parcela no encontrada")
59
+ return {"id": str(row.id), "name": row.name, "area_ha": row.area_ha}
60
+
61
+
62
+ @router.delete("/{field_id}")
63
+ async def delete_field(field_id: str, session: AsyncSession = Depends(get_db)) -> dict:
64
+ """Borra una parcela por id (404 si no existía)."""
65
+ if not await parcels.delete_parcel(session, field_id):
66
+ raise HTTPException(status_code=404, detail="Parcela no encontrada")
67
+ return {"deleted": field_id}
backend/api/ndvi.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: ndvi.py
3
+ Fecha de modificación: 03/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Router de Teledetección (NDVI). `POST /api/ndvi` devuelve la serie mensual: si se pasa
8
+ `field_id`, la lee persistida (rápida); si se pasa `geojson`, la calcula en vivo vía
9
+ Sentinel Hub (default últimos 5 años). `POST /api/ndvi/raster` devuelve el heatmap NDVI
10
+ (PNG ~10 m/px) para pintar como capa en el mapa.
11
+
12
+ Estructura Interna:
13
+ - `POST /api/ndvi` (serie), `POST /api/ndvi/raster` (heatmap PNG).
14
+
15
+ Entradas / Dependencias:
16
+ - `backend.services.remote_sensing`, `backend.db.repositories`, `backend.api.deps`.
17
+
18
+ Ejemplo de Integración:
19
+ from backend.api.ndvi import router
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from fastapi import APIRouter, Depends, HTTPException, Response
25
+ from pydantic import BaseModel
26
+ from sqlalchemy.ext.asyncio import AsyncSession
27
+
28
+ from backend.api.deps import UserKeys, get_db, get_user_keys
29
+ from backend.db import repositories as repo
30
+ from backend.services import remote_sensing
31
+
32
+ router = APIRouter(prefix="/api/ndvi", tags=["teledetección"])
33
+
34
+
35
+ class NdviRequest(BaseModel):
36
+ """Petición de serie NDVI: por parcela (persistida) o por geometría (en vivo)."""
37
+
38
+ field_id: str | None = None
39
+ geojson: dict | None = None
40
+ start: str | None = None
41
+ end: str | None = None
42
+
43
+
44
+ class NdviRasterRequest(BaseModel):
45
+ """Petición de heatmap NDVI (PNG) para una geometría."""
46
+
47
+ geojson: dict
48
+ start: str | None = None
49
+ end: str | None = None
50
+
51
+
52
+ def _require_copernicus(keys: UserKeys) -> None:
53
+ """Lanza 400 si faltan las credenciales de Copernicus para una consulta en vivo."""
54
+ if not (keys.copernicus_id and keys.copernicus_secret):
55
+ raise HTTPException(
56
+ status_code=400, detail="Faltan credenciales de Copernicus (pestaña Credenciales)."
57
+ )
58
+
59
+
60
+ @router.post("")
61
+ async def ndvi_series(
62
+ body: NdviRequest,
63
+ keys: UserKeys = Depends(get_user_keys),
64
+ session: AsyncSession = Depends(get_db),
65
+ ) -> dict:
66
+ """Devuelve la serie NDVI mensual (persistida por parcela o calculada en vivo)."""
67
+ if body.field_id:
68
+ series = await repo.get_ndvi_series(session, body.field_id)
69
+ return {"series": series}
70
+ if body.geojson:
71
+ _require_copernicus(keys)
72
+ series = await remote_sensing.ndvi_series_monthly(
73
+ body.geojson, body.start, body.end, keys.copernicus_id, keys.copernicus_secret
74
+ )
75
+ return {"series": series}
76
+ raise HTTPException(status_code=422, detail="Indica 'field_id' o 'geojson'.")
77
+
78
+
79
+ @router.post("/raster")
80
+ async def ndvi_raster(
81
+ body: NdviRasterRequest, keys: UserKeys = Depends(get_user_keys)
82
+ ) -> Response:
83
+ """Devuelve el heatmap NDVI (PNG ~10 m/px) recortado al polígono."""
84
+ _require_copernicus(keys)
85
+ png = await remote_sensing.ndvi_heatmap_png(
86
+ body.geojson, keys.copernicus_id, keys.copernicus_secret, body.start, body.end
87
+ )
88
+ return Response(content=png, media_type="image/png")
backend/api/weather.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: weather.py
3
+ Fecha de modificación: 03/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Router de clima (`/api/weather`). Devuelve la serie agroclimática mensual (precipitación,
8
+ temperatura media, radiación) por coordenadas usando Open-Meteo (sin llave). Por defecto
9
+ cubre los últimos 5 años, para cruzarse con la serie NDVI en Teledetección.
10
+
11
+ Estructura Interna:
12
+ - `POST /api/weather`.
13
+
14
+ Entradas / Dependencias:
15
+ - `backend.services.weather`.
16
+
17
+ Ejemplo de Integración:
18
+ from backend.api.weather import router
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from fastapi import APIRouter
24
+ from pydantic import BaseModel, Field
25
+
26
+ from backend.services import weather
27
+
28
+ router = APIRouter(prefix="/api/weather", tags=["clima"])
29
+
30
+
31
+ class WeatherRequest(BaseModel):
32
+ """Petición de clima por coordenadas y rango opcional (default últimos 5 años)."""
33
+
34
+ lat: float = Field(ge=-90, le=90)
35
+ lon: float = Field(ge=-180, le=180)
36
+ start: str | None = None
37
+ end: str | None = None
38
+
39
+
40
+ @router.post("")
41
+ async def weather_series(body: WeatherRequest) -> dict:
42
+ """Devuelve la serie climática mensual de las coordenadas indicadas."""
43
+ series = await weather.weather_series(body.lat, body.lon, body.start, body.end)
44
+ return {"series": series}
backend/db/repositories.py CHANGED
@@ -108,6 +108,16 @@ async def delete_fields_for_user(session: AsyncSession, user_id: str) -> None:
108
  await session.commit()
109
 
110
 
 
 
 
 
 
 
 
 
 
 
111
  async def upsert_ndvi_points(
112
  session: AsyncSession, field_id: Any, points: list[dict]
113
  ) -> int:
 
108
  await session.commit()
109
 
110
 
111
+ async def delete_field(session: AsyncSession, field_id: Any) -> bool:
112
+ """Borra una parcela por id (cascada borra su NDVI). Devuelve True si existía."""
113
+ result = await session.execute(
114
+ text("delete from fields where id = :id returning id"), {"id": str(field_id)}
115
+ )
116
+ deleted = result.first() is not None
117
+ await session.commit()
118
+ return deleted
119
+
120
+
121
  async def upsert_ndvi_points(
122
  session: AsyncSession, field_id: Any, points: list[dict]
123
  ) -> int:
backend/main.py CHANGED
@@ -40,6 +40,9 @@ from fastapi import FastAPI
40
  from fastapi.middleware.cors import CORSMiddleware
41
 
42
  from backend.api.count import router as count_router
 
 
 
43
  from backend.config import get_settings
44
  from backend.core.inference import ModelNotAvailableError, create_adapter
45
 
@@ -87,7 +90,7 @@ def create_app() -> FastAPI:
87
  """
88
  settings = get_settings()
89
  app = FastAPI(
90
- title="AgroVisión MVP — Backend",
91
  version=settings.model_version,
92
  lifespan=lifespan,
93
  )
@@ -95,9 +98,12 @@ def create_app() -> FastAPI:
95
  CORSMiddleware,
96
  allow_origins=list(settings.allowed_origins),
97
  allow_methods=["*"],
98
- allow_headers=["*"],
99
  )
100
- app.include_router(count_router)
 
 
 
101
  return app
102
 
103
 
 
40
  from fastapi.middleware.cors import CORSMiddleware
41
 
42
  from backend.api.count import router as count_router
43
+ from backend.api.fields import router as fields_router
44
+ from backend.api.ndvi import router as ndvi_router
45
+ from backend.api.weather import router as weather_router
46
  from backend.config import get_settings
47
  from backend.core.inference import ModelNotAvailableError, create_adapter
48
 
 
90
  """
91
  settings = get_settings()
92
  app = FastAPI(
93
+ title="AgroVisión — Backend (plataforma)",
94
  version=settings.model_version,
95
  lifespan=lifespan,
96
  )
 
98
  CORSMiddleware,
99
  allow_origins=list(settings.allowed_origins),
100
  allow_methods=["*"],
101
+ allow_headers=["*"], # necesario para las cabeceras BYOK X-User-*
102
  )
103
+ app.include_router(fields_router) # Creación de Parcelas
104
+ app.include_router(ndvi_router) # Teledetección NDVI
105
+ app.include_router(weather_router) # Clima
106
+ app.include_router(count_router) # Conteo (en desarrollo / standby)
107
  return app
108
 
109
 
backend/services/__init__.py ADDED
File without changes
backend/services/parcels.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: parcels.py
3
+ Fecha de modificación: 03/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Servicio de parcelas (módulo *Creación de Parcelas*). Orquesta el alta/listado/borrado
8
+ de parcelas sobre el repositorio y dispara el **backfill** NDVI de 5 años al crear una
9
+ parcela. El backfill corre como tarea de fondo (no bloquea la respuesta) y persiste la
10
+ serie mensual en `ndvi_timeseries` de forma idempotente.
11
+
12
+ Acciones Principales:
13
+ - CRUD de parcelas y disparo del relleno histórico de NDVI.
14
+
15
+ Estructura Interna:
16
+ - `create_parcel` / `list_parcels` / `get_parcel` / `delete_parcel`.
17
+ - `run_ndvi_backfill`: tarea de fondo (sesión propia) que llama a teledetección.
18
+
19
+ Entradas / Dependencias:
20
+ - `backend.db` (repos/sesión), `backend.services.remote_sensing`.
21
+
22
+ Salidas / Efectos:
23
+ - Escribe parcelas y serie NDVI en la BD del usuario.
24
+
25
+ Ejemplo de Integración:
26
+ from backend.services import parcels
27
+ row = await parcels.create_parcel(session, field_in)
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ from typing import Any
34
+
35
+ from sqlalchemy import Row
36
+ from sqlalchemy.ext.asyncio import AsyncSession
37
+
38
+ from backend.api.deps import UserKeys
39
+ from backend.core.schemas import FieldIn
40
+ from backend.db import repositories as repo
41
+ from backend.db.session import get_sessionmaker
42
+ from backend.services import remote_sensing
43
+
44
+ _logger = logging.getLogger("agrovision.parcels")
45
+
46
+
47
+ async def create_parcel(
48
+ session: AsyncSession, field_in: FieldIn, user_id: str | None = None
49
+ ) -> Row:
50
+ """Crea una parcela (geometría validada por `FieldIn`) y devuelve id/nombre/área."""
51
+ return await repo.create_field(
52
+ session, name=field_in.name, geojson=field_in.geojson, user_id=user_id
53
+ )
54
+
55
+
56
+ async def list_parcels(session: AsyncSession, user_id: str | None = None) -> list[Row]:
57
+ """Lista las parcelas (opcionalmente filtradas por usuario)."""
58
+ return await repo.list_fields(session, user_id=user_id)
59
+
60
+
61
+ async def get_parcel(session: AsyncSession, field_id: Any) -> Row | None:
62
+ """Devuelve una parcela por id o None."""
63
+ return await repo.get_field(session, field_id)
64
+
65
+
66
+ async def delete_parcel(session: AsyncSession, field_id: Any) -> bool:
67
+ """Borra una parcela por id; True si existía."""
68
+ return await repo.delete_field(session, field_id)
69
+
70
+
71
+ async def run_ndvi_backfill(field_id: str, geojson: dict, keys: UserKeys) -> int:
72
+ """
73
+ Tarea de fondo: calcula la serie NDVI de 5 años y la persiste (idempotente).
74
+
75
+ Abre su propia sesión (se ejecuta después de responder). Captura errores para no
76
+ romper el request ya finalizado; los registra sin exponer secretos.
77
+
78
+ Args:
79
+ field_id (str): Parcela recién creada.
80
+ geojson (dict): Geometría de la parcela.
81
+ keys (UserKeys): Credenciales BYOK de la sesión (Copernicus).
82
+
83
+ Returns:
84
+ int: Número de puntos NDVI persistidos (0 si no hubo credenciales o falló).
85
+ """
86
+ if not (keys.copernicus_id and keys.copernicus_secret):
87
+ _logger.info("Backfill NDVI omitido: faltan credenciales de Copernicus.")
88
+ return 0
89
+ try:
90
+ series = await remote_sensing.ndvi_series_monthly(
91
+ geojson, None, None, keys.copernicus_id, keys.copernicus_secret
92
+ )
93
+ async with get_sessionmaker()() as session:
94
+ return await repo.upsert_ndvi_points(session, field_id, series)
95
+ except Exception as error: # noqa: BLE001 - el request ya respondió; solo registramos
96
+ _logger.warning("Backfill NDVI falló para %s: %s", field_id, error)
97
+ return 0
backend/services/remote_sensing.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: remote_sensing.py
3
+ Fecha de modificación: 03/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Teledetección NDVI sobre Sentinel-2 usando **Sentinel Hub en Copernicus Data Space
8
+ (CDSE)** con OAuth client credentials (BYOK). En vez de descargar COGs con rasterio,
9
+ se usan dos APIs HTTP del propio CDSE:
10
+ - **Statistical API**: serie temporal NDVI agregada por mes (mean/min/max) sobre el
11
+ polígono, con default de los últimos 5 años.
12
+ - **Process API**: PNG colorizado (heatmap NDVI ~10 m/px) para la capa del mapa.
13
+ Es más fiable y ligero en capa gratuita que el pipeline rasterio/GDAL.
14
+
15
+ Acciones Principales:
16
+ - Obtiene token CDSE, calcula la serie NDVI mensual y el heatmap PNG.
17
+
18
+ Estructura Interna:
19
+ - `_get_token`: client_credentials -> access_token (cacheado por expiración).
20
+ - `_default_range`: rango por defecto (últimos 5 años).
21
+ - `_parse_stats`: parseo puro de la respuesta de la Statistical API a la serie.
22
+ - `ndvi_series_monthly` / `ndvi_heatmap_png`: llamadas a SH (async).
23
+
24
+ Entradas / Dependencias:
25
+ - `httpx`; credenciales Copernicus (client_id/secret) del usuario.
26
+
27
+ Salidas / Efectos:
28
+ - Llamadas HTTPS a CDSE; no persiste credenciales.
29
+
30
+ Ejemplo de Integración:
31
+ from backend.services.remote_sensing import ndvi_series_monthly
32
+ serie = await ndvi_series_monthly(geojson, None, None, cid, secret)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import datetime as dt
38
+ import time
39
+
40
+ import httpx
41
+ from dateutil.relativedelta import relativedelta
42
+
43
+ _TOKEN_URL = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token"
44
+ _STATS_URL = "https://sh.dataspace.copernicus.eu/api/v1/statistics"
45
+ _PROCESS_URL = "https://sh.dataspace.copernicus.eu/api/v1/process"
46
+ _CRS_4326 = "http://www.opengis.net/def/crs/EPSG/0/4326"
47
+ _RES_DEG = 0.0001 # ~10 m/px en EPSG:4326 (grados)
48
+
49
+ # Evalscript para la Statistical API: NDVI con máscara de nubes vía SCL.
50
+ _STATS_EVALSCRIPT = """//VERSION=3
51
+ function setup() {
52
+ return {
53
+ input: [{bands: ["B04", "B08", "SCL", "dataMask"]}],
54
+ output: [{id: "ndvi", bands: 1}, {id: "dataMask", bands: 1}]
55
+ };
56
+ }
57
+ function evaluatePixel(s) {
58
+ let ndvi = (s.B08 - s.B04) / (s.B08 + s.B04);
59
+ let cloudy = [3, 8, 9, 10, 11].indexOf(s.SCL) > -1; // sombra/nube/cirros/nieve
60
+ return {ndvi: [ndvi], dataMask: [cloudy ? 0 : s.dataMask]};
61
+ }"""
62
+
63
+ # Evalscript para la Process API: NDVI colorizado (rojo->verde) como RGBA.
64
+ _VIS_EVALSCRIPT = """//VERSION=3
65
+ function setup() {
66
+ return {input: ["B04", "B08", "dataMask"], output: {bands: 4}};
67
+ }
68
+ const STOPS = [-0.2, 0.0, 0.2, 0.4, 0.6, 0.8];
69
+ const COLORS = [[0.65,0,0.15],[0.96,0.43,0.26],[0.99,0.68,0.38],
70
+ [0.85,0.94,0.55],[0.40,0.74,0.39],[0,0.41,0.22]];
71
+ function evaluatePixel(s) {
72
+ let ndvi = (s.B08 - s.B04) / (s.B08 + s.B04);
73
+ let c = colorBlend(ndvi, STOPS, COLORS);
74
+ return [c[0], c[1], c[2], s.dataMask];
75
+ }"""
76
+
77
+ _token_cache: dict[str, tuple[str, float]] = {}
78
+
79
+
80
+ async def _get_token(client_id: str, client_secret: str) -> str:
81
+ """
82
+ Obtiene un access_token de CDSE (client_credentials), cacheado hasta su expiración.
83
+
84
+ Args:
85
+ client_id (str): Client id del OAuth client de Copernicus.
86
+ client_secret (str): Client secret correspondiente.
87
+
88
+ Returns:
89
+ str: Bearer token válido para las APIs de Sentinel Hub en CDSE.
90
+ """
91
+ cached = _token_cache.get(client_id)
92
+ if cached and cached[1] > time.time() + 60:
93
+ return cached[0]
94
+ async with httpx.AsyncClient() as client:
95
+ response = await client.post(
96
+ _TOKEN_URL,
97
+ data={
98
+ "grant_type": "client_credentials",
99
+ "client_id": client_id,
100
+ "client_secret": client_secret,
101
+ },
102
+ timeout=30,
103
+ )
104
+ response.raise_for_status()
105
+ payload = response.json()
106
+ token = payload["access_token"]
107
+ _token_cache[client_id] = (token, time.time() + float(payload.get("expires_in", 600)))
108
+ return token
109
+
110
+
111
+ def _default_range() -> tuple[str, str]:
112
+ """Devuelve el rango por defecto (últimos 5 años) como ISO datetimes UTC."""
113
+ end = dt.date.today()
114
+ start = end - relativedelta(years=5)
115
+ return f"{start.isoformat()}T00:00:00Z", f"{end.isoformat()}T23:59:59Z"
116
+
117
+
118
+ def _parse_stats(payload: dict) -> list[dict]:
119
+ """
120
+ Convierte la respuesta de la Statistical API en la serie NDVI mensual.
121
+
122
+ Args:
123
+ payload (dict): JSON devuelto por la Statistical API.
124
+
125
+ Returns:
126
+ list[dict]: Puntos {date, mean_ndvi, min_ndvi, max_ndvi, cloud_cover, source}
127
+ ordenados por fecha; se omiten intervalos sin píxeles válidos.
128
+ """
129
+ series: list[dict] = []
130
+ for item in payload.get("data", []):
131
+ outputs = item.get("outputs", {})
132
+ bands = outputs.get("ndvi", {}).get("bands", {})
133
+ stats = bands.get("B0", {}).get("stats", {})
134
+ mean = stats.get("mean")
135
+ if mean is None or stats.get("sampleCount", 0) == 0:
136
+ continue
137
+ # En meses totalmente nublados/sin escena, mean puede venir como 'NaN'.
138
+ if isinstance(mean, str):
139
+ continue
140
+ month = item["interval"]["from"][:7]
141
+ series.append(
142
+ {
143
+ "date": f"{month}-01",
144
+ "mean_ndvi": round(float(mean), 4),
145
+ "min_ndvi": round(float(stats.get("min", mean)), 4),
146
+ "max_ndvi": round(float(stats.get("max", mean)), 4),
147
+ "cloud_cover": None,
148
+ "source": "sentinel2",
149
+ }
150
+ )
151
+ return sorted(series, key=lambda p: p["date"])
152
+
153
+
154
+ def _stats_body(geojson: dict, start: str, end: str) -> dict:
155
+ """Construye el cuerpo de la Statistical API para NDVI mensual."""
156
+ return {
157
+ "input": {
158
+ "bounds": {"geometry": geojson, "properties": {"crs": _CRS_4326}},
159
+ "data": [{"type": "sentinel-2-l2a"}],
160
+ },
161
+ "aggregation": {
162
+ "timeRange": {"from": start, "to": end},
163
+ "aggregationInterval": {"of": "P1M"},
164
+ "evalscript": _STATS_EVALSCRIPT,
165
+ "resx": _RES_DEG,
166
+ "resy": _RES_DEG,
167
+ },
168
+ }
169
+
170
+
171
+ async def ndvi_series_monthly(
172
+ geojson: dict,
173
+ start: str | None,
174
+ end: str | None,
175
+ client_id: str,
176
+ client_secret: str,
177
+ ) -> list[dict]:
178
+ """
179
+ Calcula la serie NDVI mensual de un polígono (default: últimos 5 años).
180
+
181
+ Args:
182
+ geojson (dict): Geometría Polygon (EPSG:4326).
183
+ start (str | None): Inicio ISO; si es None, se usan los últimos 5 años.
184
+ end (str | None): Fin ISO; si es None, hoy.
185
+ client_id (str): Client id de Copernicus.
186
+ client_secret (str): Client secret de Copernicus.
187
+
188
+ Returns:
189
+ list[dict]: Serie NDVI mensual lista para persistir/graficar.
190
+ """
191
+ if not start or not end:
192
+ start, end = _default_range()
193
+ token = await _get_token(client_id, client_secret)
194
+ async with httpx.AsyncClient() as client:
195
+ response = await client.post(
196
+ _STATS_URL,
197
+ headers={"Authorization": f"Bearer {token}"},
198
+ json=_stats_body(geojson, start, end),
199
+ timeout=120,
200
+ )
201
+ response.raise_for_status()
202
+ return _parse_stats(response.json())
203
+
204
+
205
+ async def ndvi_heatmap_png(
206
+ geojson: dict,
207
+ client_id: str,
208
+ client_secret: str,
209
+ start: str | None = None,
210
+ end: str | None = None,
211
+ size: int = 512,
212
+ ) -> bytes:
213
+ """
214
+ Genera un PNG colorizado del NDVI (heatmap ~10 m/px) recortado al polígono.
215
+
216
+ Args:
217
+ geojson (dict): Geometría Polygon (EPSG:4326).
218
+ client_id (str): Client id de Copernicus.
219
+ client_secret (str): Client secret de Copernicus.
220
+ start (str | None): Inicio del rango; default últimos 5 años.
221
+ end (str | None): Fin del rango; default hoy.
222
+ size (int): Lado del PNG en píxeles.
223
+
224
+ Returns:
225
+ bytes: Imagen PNG (RGBA) del heatmap NDVI.
226
+ """
227
+ if not start or not end:
228
+ start, end = _default_range()
229
+ token = await _get_token(client_id, client_secret)
230
+ body = {
231
+ "input": {
232
+ "bounds": {"geometry": geojson, "properties": {"crs": _CRS_4326}},
233
+ "data": [
234
+ {
235
+ "type": "sentinel-2-l2a",
236
+ "dataFilter": {
237
+ "timeRange": {"from": start, "to": end},
238
+ "mosaickingOrder": "leastCC",
239
+ },
240
+ }
241
+ ],
242
+ },
243
+ "output": {
244
+ "width": size,
245
+ "height": size,
246
+ "responses": [{"identifier": "default", "format": {"type": "image/png"}}],
247
+ },
248
+ "evalscript": _VIS_EVALSCRIPT,
249
+ }
250
+ async with httpx.AsyncClient() as client:
251
+ response = await client.post(
252
+ _PROCESS_URL,
253
+ headers={"Authorization": f"Bearer {token}"},
254
+ json=body,
255
+ timeout=120,
256
+ )
257
+ response.raise_for_status()
258
+ return response.content
backend/services/weather.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: weather.py
3
+ Fecha de modificación: 03/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Datos agroclimáticos por coordenadas usando **Open-Meteo** (archivo histórico, sin
8
+ llave). Se consulta on-demand y se **agrega por mes** para los gráficos de Teledetección
9
+ (precipitación sumada, temperatura media, radiación sumada). Por defecto cubre los
10
+ últimos 5 años, alineado con la serie NDVI.
11
+
12
+ Acciones Principales:
13
+ - Descarga el histórico diario y lo resume a puntos mensuales.
14
+
15
+ Estructura Interna:
16
+ - `_default_range`: rango por defecto (últimos 5 años).
17
+ - `aggregate_weather_monthly`: agregación mensual pura (testeable sin red).
18
+ - `open_meteo` / `weather_series`: descarga async + serie mensual.
19
+
20
+ Entradas / Dependencias:
21
+ - `httpx`; Open-Meteo (endpoint público, sin credenciales).
22
+
23
+ Salidas / Efectos:
24
+ - Llamada HTTPS a Open-Meteo; sin persistencia.
25
+
26
+ Ejemplo de Integración:
27
+ from backend.services.weather import weather_series
28
+ serie = await weather_series(-34.6, -58.38)
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import datetime as dt
34
+ from collections import defaultdict
35
+
36
+ import httpx
37
+ from dateutil.relativedelta import relativedelta
38
+
39
+ _ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
40
+ _DAILY_VARS = "precipitation_sum,temperature_2m_mean,shortwave_radiation_sum"
41
+
42
+
43
+ def _default_range() -> tuple[str, str]:
44
+ """Devuelve el rango por defecto (últimos 5 años) como fechas ISO."""
45
+ end = dt.date.today()
46
+ start = end - relativedelta(years=5)
47
+ return start.isoformat(), end.isoformat()
48
+
49
+
50
+ def aggregate_weather_monthly(daily: dict) -> list[dict]:
51
+ """
52
+ Agrega el histórico diario de Open-Meteo a puntos mensuales.
53
+
54
+ Args:
55
+ daily (dict): Bloque 'daily' de Open-Meteo con listas paralelas 'time',
56
+ 'precipitation_sum', 'temperature_2m_mean', 'shortwave_radiation_sum'.
57
+
58
+ Returns:
59
+ list[dict]: Puntos {date, precip_mm, temp_mean_c, radiation} por mes,
60
+ ordenados cronológicamente.
61
+ """
62
+ times = daily.get("time", [])
63
+ precip = daily.get("precipitation_sum", [])
64
+ temp = daily.get("temperature_2m_mean", [])
65
+ radiation = daily.get("shortwave_radiation_sum", [])
66
+
67
+ buckets: dict[str, dict[str, list[float]]] = defaultdict(
68
+ lambda: {"precip": [], "temp": [], "rad": []}
69
+ )
70
+ for i, day in enumerate(times):
71
+ month = str(day)[:7]
72
+ if i < len(precip) and precip[i] is not None:
73
+ buckets[month]["precip"].append(precip[i])
74
+ if i < len(temp) and temp[i] is not None:
75
+ buckets[month]["temp"].append(temp[i])
76
+ if i < len(radiation) and radiation[i] is not None:
77
+ buckets[month]["rad"].append(radiation[i])
78
+
79
+ series: list[dict] = []
80
+ for month in sorted(buckets):
81
+ values = buckets[month]
82
+ temps = values["temp"]
83
+ series.append(
84
+ {
85
+ "date": f"{month}-01",
86
+ "precip_mm": round(sum(values["precip"]), 1),
87
+ "temp_mean_c": round(sum(temps) / len(temps), 1) if temps else None,
88
+ "radiation": round(sum(values["rad"]), 1),
89
+ }
90
+ )
91
+ return series
92
+
93
+
94
+ async def open_meteo(lat: float, lon: float, start: str, end: str) -> dict:
95
+ """
96
+ Descarga el bloque 'daily' del histórico de Open-Meteo para unas coordenadas.
97
+
98
+ Args:
99
+ lat (float): Latitud.
100
+ lon (float): Longitud.
101
+ start (str): Fecha inicial (YYYY-MM-DD).
102
+ end (str): Fecha final (YYYY-MM-DD).
103
+
104
+ Returns:
105
+ dict: Bloque 'daily' de la respuesta.
106
+ """
107
+ params = {
108
+ "latitude": lat,
109
+ "longitude": lon,
110
+ "start_date": start,
111
+ "end_date": end,
112
+ "daily": _DAILY_VARS,
113
+ "timezone": "UTC",
114
+ }
115
+ async with httpx.AsyncClient() as client:
116
+ response = await client.get(_ARCHIVE_URL, params=params, timeout=60)
117
+ response.raise_for_status()
118
+ return response.json().get("daily", {})
119
+
120
+
121
+ async def weather_series(
122
+ lat: float, lon: float, start: str | None = None, end: str | None = None
123
+ ) -> list[dict]:
124
+ """
125
+ Devuelve la serie climática mensual de unas coordenadas (default: últimos 5 años).
126
+
127
+ Args:
128
+ lat (float): Latitud.
129
+ lon (float): Longitud.
130
+ start (str | None): Fecha inicial; default últimos 5 años.
131
+ end (str | None): Fecha final; default hoy.
132
+
133
+ Returns:
134
+ list[dict]: Serie mensual {date, precip_mm, temp_mean_c, radiation}.
135
+ """
136
+ if not start or not end:
137
+ start, end = _default_range()
138
+ daily = await open_meteo(lat, lon, start, end)
139
+ return aggregate_weather_monthly(daily)
tests/integration/test_teledeteccion.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: test_teledeteccion.py
3
+ Fecha de modificación: 03/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Pruebas de integración de Teledetección (Fase 3) contra Sentinel Hub (CDSE) y la BD del
8
+ usuario. Verifican que la serie NDVI mensual se obtiene en vivo y que la cadena
9
+ parcela → NDVI → persistencia funciona. Se omiten si faltan credenciales de Copernicus
10
+ o `DATABASE_URL`.
11
+
12
+ Ejecución:
13
+ uv run python -m pytest tests/integration/test_teledeteccion.py -v
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import contextlib
20
+ import os
21
+ import uuid
22
+
23
+ import pytest
24
+ from dotenv import load_dotenv
25
+
26
+ load_dotenv()
27
+
28
+ _HAS_COP = bool(
29
+ os.getenv("DEV_COPERNICUS_CLIENT_ID") and os.getenv("DEV_COPERNICUS_CLIENT_SECRET")
30
+ )
31
+ _HAS_DB = bool(os.getenv("DATABASE_URL"))
32
+
33
+ from backend.db import repositories as repo # noqa: E402
34
+ from backend.db.session import get_engine, get_sessionmaker # noqa: E402
35
+ from backend.services import remote_sensing # noqa: E402
36
+
37
+ _SMALL_GEOM = {
38
+ "type": "Polygon",
39
+ "coordinates": [
40
+ [[-58.50, -34.60], [-58.50, -34.58], [-58.47, -34.58], [-58.47, -34.60], [-58.50, -34.60]]
41
+ ],
42
+ }
43
+ _RANGE = ("2024-09-01T00:00:00Z", "2024-12-31T23:59:59Z")
44
+
45
+
46
+ @contextlib.asynccontextmanager
47
+ async def _engine_scope():
48
+ """Engine fresco por test (cada test usa su propio event loop)."""
49
+ get_engine.cache_clear()
50
+ get_sessionmaker.cache_clear()
51
+ try:
52
+ yield get_sessionmaker()
53
+ finally:
54
+ await get_engine().dispose()
55
+ get_engine.cache_clear()
56
+ get_sessionmaker.cache_clear()
57
+
58
+
59
+ @pytest.mark.skipif(not _HAS_COP, reason="Faltan credenciales de Copernicus")
60
+ def test_ndvi_series_live() -> None:
61
+ """La Statistical API devuelve una serie NDVI mensual con valores en [-1, 1]."""
62
+
63
+ async def _run() -> None:
64
+ series = await remote_sensing.ndvi_series_monthly(
65
+ _SMALL_GEOM,
66
+ _RANGE[0],
67
+ _RANGE[1],
68
+ os.environ["DEV_COPERNICUS_CLIENT_ID"],
69
+ os.environ["DEV_COPERNICUS_CLIENT_SECRET"],
70
+ )
71
+ assert len(series) >= 1
72
+ for point in series:
73
+ assert -1 <= point["mean_ndvi"] <= 1
74
+ assert point["date"].endswith("-01")
75
+
76
+ asyncio.run(_run())
77
+
78
+
79
+ @pytest.mark.skipif(not (_HAS_COP and _HAS_DB), reason="Faltan Copernicus o DATABASE_URL")
80
+ def test_parcel_ndvi_persist() -> None:
81
+ """Cadena completa: crear parcela → calcular NDVI → persistir → leer la serie."""
82
+ user_id = str(uuid.uuid4())
83
+
84
+ async def _run() -> None:
85
+ async with _engine_scope() as sm, sm() as session:
86
+ try:
87
+ field = await repo.create_field(
88
+ session, name="Lote Teledet", geojson=_SMALL_GEOM, user_id=user_id
89
+ )
90
+ series = await remote_sensing.ndvi_series_monthly(
91
+ _SMALL_GEOM,
92
+ _RANGE[0],
93
+ _RANGE[1],
94
+ os.environ["DEV_COPERNICUS_CLIENT_ID"],
95
+ os.environ["DEV_COPERNICUS_CLIENT_SECRET"],
96
+ )
97
+ await repo.upsert_ndvi_points(session, field.id, series)
98
+ persisted = await repo.get_ndvi_series(session, field.id)
99
+ assert len(persisted) == len(series)
100
+ assert len(persisted) >= 1
101
+ finally:
102
+ await repo.delete_fields_for_user(session, user_id)
103
+
104
+ asyncio.run(_run())
tests/unit/test_deps.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: test_deps.py
3
+ Fecha de modificación: 03/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Pruebas unitarias del proxy efímero de credenciales: la cabecera tiene prioridad y, si
8
+ falta, se hace fallback al entorno DEV (desarrollo local).
9
+
10
+ Ejecución:
11
+ uv run python -m pytest tests/unit/test_deps.py
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pytest
17
+
18
+ from backend.api.deps import get_user_keys
19
+
20
+
21
+ def test_get_user_keys_fallback_a_entorno(monkeypatch: pytest.MonkeyPatch) -> None:
22
+ """Sin cabecera, las llaves se toman de las variables DEV_* del entorno."""
23
+ monkeypatch.setenv("DEV_COPERNICUS_CLIENT_ID", "env-id")
24
+ monkeypatch.setenv("DEV_COPERNICUS_CLIENT_SECRET", "env-secret")
25
+ monkeypatch.setenv("DEV_GROQ_API_KEY", "env-groq")
26
+ keys = get_user_keys(
27
+ x_user_groq_key=None,
28
+ x_user_copernicus_id=None,
29
+ x_user_copernicus_secret=None,
30
+ x_user_supabase_url=None,
31
+ x_user_supabase_key=None,
32
+ )
33
+ assert keys.copernicus_id == "env-id"
34
+ assert keys.copernicus_secret == "env-secret"
35
+ assert keys.groq == "env-groq"
36
+
37
+
38
+ def test_get_user_keys_cabecera_tiene_prioridad(monkeypatch: pytest.MonkeyPatch) -> None:
39
+ """La cabecera X-User-* gana sobre el entorno DEV."""
40
+ monkeypatch.setenv("DEV_COPERNICUS_CLIENT_ID", "env-id")
41
+ keys = get_user_keys(
42
+ x_user_groq_key=None,
43
+ x_user_copernicus_id="hdr-id",
44
+ x_user_copernicus_secret=None,
45
+ x_user_supabase_url=None,
46
+ x_user_supabase_key=None,
47
+ )
48
+ assert keys.copernicus_id == "hdr-id"
tests/unit/test_remote_sensing.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: test_remote_sensing.py
3
+ Fecha de modificación: 03/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Pruebas unitarias (sin red) del parseo de la Statistical API de Sentinel Hub y del
8
+ rango por defecto de 5 años.
9
+
10
+ Ejecución:
11
+ uv run python -m pytest tests/unit/test_remote_sensing.py
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime as dt
17
+
18
+ from backend.services.remote_sensing import _default_range, _parse_stats
19
+
20
+
21
+ def _item(month: str, stats: dict) -> dict:
22
+ """Construye un item de la respuesta Statistical API con las stats dadas."""
23
+ return {
24
+ "interval": {"from": f"{month}-01T00:00:00Z", "to": f"{month}-28T00:00:00Z"},
25
+ "outputs": {"ndvi": {"bands": {"B0": {"stats": stats}}}},
26
+ }
27
+
28
+
29
+ _SAMPLE = {
30
+ "data": [
31
+ _item("2024-09", {"mean": 0.274, "min": -0.143, "max": 0.923, "sampleCount": 60000}),
32
+ _item("2024-10", {"mean": 0.321, "min": -0.13, "max": 0.928, "sampleCount": 60000}),
33
+ _item("2024-12", {"sampleCount": 0}), # mes sin píxeles válidos -> se descarta
34
+ ]
35
+ }
36
+
37
+
38
+ def test_parse_stats_extrae_y_ordena() -> None:
39
+ """Parsea la serie, normaliza la fecha al día 1 y descarta meses vacíos."""
40
+ series = _parse_stats(_SAMPLE)
41
+ assert [p["date"] for p in series] == ["2024-09-01", "2024-10-01"]
42
+ assert series[0]["mean_ndvi"] == 0.274
43
+ assert series[0]["min_ndvi"] == -0.143
44
+ assert series[0]["source"] == "sentinel2"
45
+
46
+
47
+ def test_parse_stats_serie_vacia() -> None:
48
+ """Una respuesta sin datos produce una serie vacía (no error)."""
49
+ assert _parse_stats({"data": []}) == []
50
+
51
+
52
+ def test_default_range_cinco_anios() -> None:
53
+ """El rango por defecto abarca 5 años y termina en formato ISO con Z."""
54
+ start, end = _default_range()
55
+ assert end.endswith("Z") and start.endswith("Z")
56
+ start_year = int(start[:4])
57
+ end_year = int(end[:4])
58
+ assert end_year - start_year == 5
59
+ assert end[:10] == f"{dt.date.today().isoformat()}"
tests/unit/test_weather.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: test_weather.py
3
+ Fecha de modificación: 03/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Pruebas unitarias (sin red) de la agregación mensual del clima de Open-Meteo.
8
+
9
+ Ejecución:
10
+ uv run python -m pytest tests/unit/test_weather.py
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from backend.services.weather import aggregate_weather_monthly
16
+
17
+ _DAILY = {
18
+ "time": ["2026-04-01", "2026-04-02", "2026-05-01"],
19
+ "precipitation_sum": [10.0, 5.0, 20.0],
20
+ "temperature_2m_mean": [18.0, 20.0, 22.0],
21
+ "shortwave_radiation_sum": [100.0, 110.0, 120.0],
22
+ }
23
+
24
+
25
+ def test_aggregate_weather_monthly_suma_y_promedia() -> None:
26
+ """Precipitación y radiación se suman por mes; la temperatura se promedia."""
27
+ series = aggregate_weather_monthly(_DAILY)
28
+ assert [p["date"] for p in series] == ["2026-04-01", "2026-05-01"]
29
+ abril = series[0]
30
+ assert abril["precip_mm"] == 15.0
31
+ assert abril["temp_mean_c"] == 19.0
32
+ assert abril["radiation"] == 210.0
33
+
34
+
35
+ def test_aggregate_weather_monthly_vacio() -> None:
36
+ """Un bloque diario vacío produce una serie vacía."""
37
+ assert aggregate_weather_monthly({}) == []