Spaces:
Runtime error
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 +80 -0
- backend/api/fields.py +67 -0
- backend/api/ndvi.py +88 -0
- backend/api/weather.py +44 -0
- backend/db/repositories.py +10 -0
- backend/main.py +9 -3
- backend/services/__init__.py +0 -0
- backend/services/parcels.py +97 -0
- backend/services/remote_sensing.py +258 -0
- backend/services/weather.py +139 -0
- tests/integration/test_teledeteccion.py +104 -0
- tests/unit/test_deps.py +48 -0
- tests/unit/test_remote_sensing.py +59 -0
- tests/unit/test_weather.py +37 -0
|
@@ -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
|
|
@@ -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}
|
|
@@ -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")
|
|
@@ -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}
|
|
@@ -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:
|
|
@@ -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
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
File without changes
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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())
|
|
@@ -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"
|
|
@@ -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()}"
|
|
@@ -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({}) == []
|