Spaces:
Runtime error
Runtime error
feat(core): esquemas de dominio y motores puros NDVI/GSD/densidad (Fase 1)
Browse files- backend/core/schemas.py: FieldIn (valida polígono cerrado 4326), FieldOut,
NDVIPoint, WeatherPoint, ChatMessage.
- backend/core/ndvi.py: ndvi(), zonal_stats(), aggregate_monthly() (1 punto/mes).
- backend/core/geometry.py: gsd_cm_per_px(), density_per_ha(), geodesic_area_m2().
- Tests unitarios (TDD): test_domain_schemas, test_ndvi, test_geometry (verde, ruff limpio).
- backend/core/geometry.py +99 -0
- backend/core/ndvi.py +98 -0
- backend/core/schemas.py +108 -0
- tests/unit/test_domain_schemas.py +73 -0
- tests/unit/test_geometry.py +46 -0
- tests/unit/test_ndvi.py +64 -0
backend/core/geometry.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: geometry.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Funciones geométricas **puras** (Fase 1): distancia de muestreo terrestre (GSD) que
|
| 8 |
+
relaciona la resolución con la geometría de vuelo del dron, densidad de siembra por
|
| 9 |
+
hectárea y área geodésica de un polígono lon/lat. El área geodésica sirve de respaldo
|
| 10 |
+
cuando no hay PostGIS disponible (en producción se usa `ST_Area(geom::geography)`).
|
| 11 |
+
|
| 12 |
+
Acciones Principales:
|
| 13 |
+
- Calcula GSD, densidad por hectárea y área geodésica.
|
| 14 |
+
|
| 15 |
+
Estructura Interna:
|
| 16 |
+
- `gsd_cm_per_px`: cm/píxel a partir de sensor/altura/focal/ancho.
|
| 17 |
+
- `density_per_ha`: plantas por hectárea a partir de conteo y área.
|
| 18 |
+
- `geodesic_area_m2`: área (m²) de un anillo [lon, lat] sobre la esfera.
|
| 19 |
+
|
| 20 |
+
Entradas / Dependencias:
|
| 21 |
+
- `math` (biblioteca estándar).
|
| 22 |
+
|
| 23 |
+
Salidas / Efectos:
|
| 24 |
+
- Ninguno; funciones puras.
|
| 25 |
+
|
| 26 |
+
Ejemplo de Integración:
|
| 27 |
+
from backend.core.geometry import gsd_cm_per_px, density_per_ha, geodesic_area_m2
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import math
|
| 33 |
+
|
| 34 |
+
_EARTH_RADIUS_M: float = 6_378_137.0 # radio ecuatorial WGS84
|
| 35 |
+
SQUARE_METERS_PER_HECTARE: int = 10_000
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def gsd_cm_per_px(
|
| 39 |
+
sensor_w_mm: float, flight_h_m: float, focal_mm: float, img_w_px: float
|
| 40 |
+
) -> float:
|
| 41 |
+
"""
|
| 42 |
+
Calcula la distancia de muestreo terrestre (GSD) en centímetros por píxel.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
sensor_w_mm (float): Ancho del sensor en milímetros.
|
| 46 |
+
flight_h_m (float): Altura de vuelo en metros.
|
| 47 |
+
focal_mm (float): Distancia focal en milímetros.
|
| 48 |
+
img_w_px (float): Ancho de la imagen en píxeles.
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
float: GSD en cm/px; 0.0 si focal o ancho son 0.
|
| 52 |
+
"""
|
| 53 |
+
denom = focal_mm * img_w_px
|
| 54 |
+
if denom == 0:
|
| 55 |
+
return 0.0
|
| 56 |
+
return (sensor_w_mm * flight_h_m) / denom * 100
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def density_per_ha(count: int, area_m2: float) -> float:
|
| 60 |
+
"""
|
| 61 |
+
Calcula la densidad de plantas por hectárea.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
count (int): Número de plantas detectadas.
|
| 65 |
+
area_m2 (float): Área de la parcela en metros cuadrados.
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
float: Plantas por hectárea; 0.0 si el área es 0.
|
| 69 |
+
"""
|
| 70 |
+
if area_m2 <= 0:
|
| 71 |
+
return 0.0
|
| 72 |
+
return count / (area_m2 / SQUARE_METERS_PER_HECTARE)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def geodesic_area_m2(coords: list[list[float]]) -> float:
|
| 76 |
+
"""
|
| 77 |
+
Calcula el área geodésica (m²) de un anillo de coordenadas [lon, lat].
|
| 78 |
+
|
| 79 |
+
Usa la fórmula estándar de área de un polígono sobre la esfera (la misma de la
|
| 80 |
+
librería de geometría de Google Maps). El resultado es el valor absoluto, por lo
|
| 81 |
+
que no depende del sentido de giro.
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
coords (list[list[float]]): Vértices [lon, lat] en grados (anillo cerrado o no).
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
float: Área en metros cuadrados (>= 0).
|
| 88 |
+
"""
|
| 89 |
+
if len(coords) < 3:
|
| 90 |
+
return 0.0
|
| 91 |
+
total = 0.0
|
| 92 |
+
n = len(coords)
|
| 93 |
+
for i in range(n):
|
| 94 |
+
lon1, lat1 = coords[i]
|
| 95 |
+
lon2, lat2 = coords[(i + 1) % n]
|
| 96 |
+
total += math.radians(lon2 - lon1) * (
|
| 97 |
+
2 + math.sin(math.radians(lat1)) + math.sin(math.radians(lat2))
|
| 98 |
+
)
|
| 99 |
+
return abs(total * _EARTH_RADIUS_M * _EARTH_RADIUS_M / 2.0)
|
backend/core/ndvi.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: ndvi.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Motor NDVI **puro** (Fase 1), aislado de toda I/O para poder probar la matemática
|
| 8 |
+
con arrays sintéticos. Incluye el cálculo vectorizado del índice, la estadística
|
| 9 |
+
zonal (media/mín/máx sobre una máscara) y la agregación temporal **mensual** (un
|
| 10 |
+
punto por mes, la escena de menor nubosidad), usada por el flujo de 5 años.
|
| 11 |
+
|
| 12 |
+
Acciones Principales:
|
| 13 |
+
- Calcula NDVI por píxel, agrega estadística zonal y resume por mes.
|
| 14 |
+
|
| 15 |
+
Estructura Interna:
|
| 16 |
+
- `ndvi`: índice normalizado de vegetación, vectorizado y seguro ante /0.
|
| 17 |
+
- `zonal_stats`: media/mín/máx sobre los píxeles de la máscara.
|
| 18 |
+
- `aggregate_monthly`: reduce una serie a 1 punto/mes (menor `cloud_cover`).
|
| 19 |
+
|
| 20 |
+
Entradas / Dependencias:
|
| 21 |
+
- `numpy`.
|
| 22 |
+
|
| 23 |
+
Salidas / Efectos:
|
| 24 |
+
- Ninguno; funciones puras.
|
| 25 |
+
|
| 26 |
+
Ejemplo de Integración:
|
| 27 |
+
from backend.core.ndvi import ndvi, zonal_stats, aggregate_monthly
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def ndvi(nir: np.ndarray, red: np.ndarray) -> np.ndarray:
|
| 36 |
+
"""
|
| 37 |
+
Calcula el NDVI vectorizado evitando la división por cero.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
nir (np.ndarray): Reflectancia en el infrarrojo cercano (Banda 8).
|
| 41 |
+
red (np.ndarray): Reflectancia en el rojo visible (Banda 4).
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
np.ndarray: NDVI por píxel; 0.0 donde NIR+Red == 0.
|
| 45 |
+
"""
|
| 46 |
+
nir = nir.astype(float)
|
| 47 |
+
red = red.astype(float)
|
| 48 |
+
denom = nir + red
|
| 49 |
+
# `where` evita la división por cero sin emitir RuntimeWarning (np.where evaluaría
|
| 50 |
+
# ambas ramas y dispararía el aviso); las posiciones con denom == 0 quedan en 0.0.
|
| 51 |
+
return np.divide(nir - red, denom, out=np.zeros_like(denom), where=denom != 0)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def zonal_stats(ndvi_arr: np.ndarray, mask: np.ndarray) -> dict[str, float]:
|
| 55 |
+
"""
|
| 56 |
+
Calcula la estadística zonal (media/mín/máx) sobre los píxeles de la máscara.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
ndvi_arr (np.ndarray): Array de NDVI por píxel.
|
| 60 |
+
mask (np.ndarray): Máscara booleana de los píxeles dentro de la parcela.
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
dict[str, float]: Claves 'mean_ndvi', 'min_ndvi', 'max_ndvi'.
|
| 64 |
+
"""
|
| 65 |
+
values = ndvi_arr[mask]
|
| 66 |
+
return {
|
| 67 |
+
"mean_ndvi": float(values.mean()),
|
| 68 |
+
"min_ndvi": float(values.min()),
|
| 69 |
+
"max_ndvi": float(values.max()),
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def aggregate_monthly(series: list[dict]) -> list[dict]:
|
| 74 |
+
"""
|
| 75 |
+
Reduce una serie de observaciones a un punto por mes (la de menor nubosidad).
|
| 76 |
+
|
| 77 |
+
La fecha de cada punto resultante se normaliza al primer día del mes
|
| 78 |
+
(`AAAA-MM-01`) para coincidir con el `UNIQUE(field_id, date)` del esquema.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
series (list[dict]): Observaciones con claves 'date' (ISO) y 'cloud_cover'.
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
list[dict]: Observaciones mensuales ordenadas cronológicamente.
|
| 85 |
+
"""
|
| 86 |
+
best_per_month: dict[str, dict] = {}
|
| 87 |
+
for point in series:
|
| 88 |
+
month_key = str(point["date"])[:7] # 'AAAA-MM'
|
| 89 |
+
cloud = point.get("cloud_cover")
|
| 90 |
+
current = best_per_month.get(month_key)
|
| 91 |
+
cloud_value = cloud if cloud is not None else float("inf")
|
| 92 |
+
current_cloud = (
|
| 93 |
+
current.get("cloud_cover") if current and current.get("cloud_cover") is not None
|
| 94 |
+
else float("inf")
|
| 95 |
+
)
|
| 96 |
+
if current is None or cloud_value < current_cloud:
|
| 97 |
+
best_per_month[month_key] = {**point, "date": f"{month_key}-01"}
|
| 98 |
+
return [best_per_month[key] for key in sorted(best_per_month)]
|
backend/core/schemas.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: schemas.py (core/dominio)
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Esquemas de dominio de la plataforma completa (Fase 1). Modelan las entidades
|
| 8 |
+
geoespaciales y analíticas con sus invariantes: una parcela (`FieldIn`/`FieldOut`)
|
| 9 |
+
lleva un polígono GeoJSON validado (anillo cerrado, EPSG:4326); un punto NDVI vive
|
| 10 |
+
en [-1, 1]; un mensaje de chat solo admite los roles 'user' o 'assistant'. Validar
|
| 11 |
+
aquí evita insertar geometría o índices inválidos en PostGIS.
|
| 12 |
+
|
| 13 |
+
Acciones Principales:
|
| 14 |
+
- Declara los contratos de dominio compartidos por servicios, repos y API.
|
| 15 |
+
|
| 16 |
+
Estructura Interna:
|
| 17 |
+
- `FieldIn` / `FieldOut`: alta y lectura de parcelas.
|
| 18 |
+
- `NDVIPoint`: observación de la serie temporal NDVI.
|
| 19 |
+
- `WeatherPoint`: observación agroclimática diaria/mensual.
|
| 20 |
+
- `ChatMessage`: turno de la memoria conversacional.
|
| 21 |
+
|
| 22 |
+
Entradas / Dependencias:
|
| 23 |
+
- `pydantic`.
|
| 24 |
+
|
| 25 |
+
Salidas / Efectos:
|
| 26 |
+
- Ninguno; expone modelos de validación/serialización.
|
| 27 |
+
|
| 28 |
+
Ejemplo de Integración:
|
| 29 |
+
from backend.core.schemas import FieldIn
|
| 30 |
+
parcela = FieldIn(name="Lote A", geojson={"type": "Polygon", "coordinates": [...]})
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import datetime as dt
|
| 36 |
+
from typing import Literal
|
| 37 |
+
|
| 38 |
+
from pydantic import BaseModel, Field, field_validator
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class FieldIn(BaseModel):
|
| 42 |
+
"""Datos de alta de una parcela: nombre + polígono GeoJSON (EPSG:4326)."""
|
| 43 |
+
|
| 44 |
+
name: str = Field(min_length=1, description="Nombre legible de la parcela")
|
| 45 |
+
geojson: dict = Field(description="Geometría GeoJSON tipo Polygon en EPSG:4326")
|
| 46 |
+
|
| 47 |
+
@field_validator("geojson")
|
| 48 |
+
@classmethod
|
| 49 |
+
def _validar_poligono(cls, value: dict) -> dict:
|
| 50 |
+
"""
|
| 51 |
+
Verifica que la geometría sea un Polygon con un anillo cerrado y >=4 vértices.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
value (dict): Geometría GeoJSON propuesta.
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
dict: La misma geometría si es válida.
|
| 58 |
+
|
| 59 |
+
Raises:
|
| 60 |
+
ValueError: Si no es Polygon, el anillo no está cerrado o tiene <4 vértices.
|
| 61 |
+
"""
|
| 62 |
+
if value.get("type") != "Polygon":
|
| 63 |
+
raise ValueError("La geometría debe ser de tipo 'Polygon'.")
|
| 64 |
+
coordinates = value.get("coordinates") or []
|
| 65 |
+
if not coordinates or not coordinates[0]:
|
| 66 |
+
raise ValueError("El polígono no tiene coordenadas.")
|
| 67 |
+
ring = coordinates[0]
|
| 68 |
+
if len(ring) < 4:
|
| 69 |
+
raise ValueError("El anillo del polígono requiere al menos 4 vértices.")
|
| 70 |
+
if ring[0] != ring[-1]:
|
| 71 |
+
raise ValueError("El polígono debe estar cerrado (primer punto == último).")
|
| 72 |
+
return value
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class FieldOut(BaseModel):
|
| 76 |
+
"""Representación de lectura de una parcela persistida."""
|
| 77 |
+
|
| 78 |
+
id: str = Field(description="Identificador único (UUID) de la parcela")
|
| 79 |
+
name: str = Field(description="Nombre legible de la parcela")
|
| 80 |
+
area_ha: float | None = Field(default=None, ge=0, description="Área en hectáreas (PostGIS)")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class NDVIPoint(BaseModel):
|
| 84 |
+
"""Observación de la serie temporal de NDVI (una fecha)."""
|
| 85 |
+
|
| 86 |
+
date: dt.date = Field(description="Fecha de la escena (o primer día del mes si es mensual)")
|
| 87 |
+
mean_ndvi: float = Field(ge=-1, le=1, description="NDVI medio zonal en [-1, 1]")
|
| 88 |
+
min_ndvi: float | None = Field(default=None, ge=-1, le=1)
|
| 89 |
+
max_ndvi: float | None = Field(default=None, ge=-1, le=1)
|
| 90 |
+
cloud_cover: float | None = Field(default=None, ge=0, le=100, description="% de nubes")
|
| 91 |
+
source: str = Field(default="sentinel2", description="Origen de la observación")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class WeatherPoint(BaseModel):
|
| 95 |
+
"""Observación agroclimática por fecha (todas las variables son opcionales)."""
|
| 96 |
+
|
| 97 |
+
date: dt.date = Field(description="Fecha de la observación")
|
| 98 |
+
precip_mm: float | None = Field(default=None, ge=0, description="Precipitación (mm)")
|
| 99 |
+
temp_mean_c: float | None = Field(default=None, description="Temperatura media (°C)")
|
| 100 |
+
radiation: float | None = Field(default=None, ge=0, description="Radiación de onda corta")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class ChatMessage(BaseModel):
|
| 104 |
+
"""Turno de la memoria conversacional del agente RAG."""
|
| 105 |
+
|
| 106 |
+
role: Literal["user", "assistant"] = Field(description="Emisor del mensaje")
|
| 107 |
+
content: str = Field(min_length=1, description="Texto del turno")
|
| 108 |
+
session_id: str = Field(min_length=1, description="Hilo conversacional")
|
tests/unit/test_domain_schemas.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: test_domain_schemas.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Pruebas unitarias de los esquemas de dominio de la plataforma (Fase 1): validación
|
| 8 |
+
de geometría de parcelas (polígono cerrado EPSG:4326), rango de NDVI y rol del chat.
|
| 9 |
+
|
| 10 |
+
Ejemplo de Integración:
|
| 11 |
+
uv run python -m pytest tests/unit/test_domain_schemas.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import pytest
|
| 17 |
+
from pydantic import ValidationError
|
| 18 |
+
|
| 19 |
+
from backend.core.schemas import ChatMessage, FieldIn, NDVIPoint, WeatherPoint
|
| 20 |
+
|
| 21 |
+
_CLOSED_SQUARE = {
|
| 22 |
+
"type": "Polygon",
|
| 23 |
+
"coordinates": [
|
| 24 |
+
[[-58.0, -34.0], [-58.0, -34.1], [-57.9, -34.1], [-57.9, -34.0], [-58.0, -34.0]]
|
| 25 |
+
],
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_field_in_acepta_poligono_cerrado() -> None:
|
| 30 |
+
"""Un polígono con anillo cerrado y >=4 vértices se acepta."""
|
| 31 |
+
field = FieldIn(name="Lote A", geojson=_CLOSED_SQUARE)
|
| 32 |
+
assert field.name == "Lote A"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_field_in_rechaza_poligono_abierto() -> None:
|
| 36 |
+
"""Un anillo cuyo primer punto != último debe rechazarse."""
|
| 37 |
+
abierto = {
|
| 38 |
+
"type": "Polygon",
|
| 39 |
+
"coordinates": [[[-58.0, -34.0], [-58.0, -34.1], [-57.9, -34.1]]],
|
| 40 |
+
}
|
| 41 |
+
with pytest.raises(ValidationError):
|
| 42 |
+
FieldIn(name="x", geojson=abierto)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_field_in_rechaza_tipo_no_poligono() -> None:
|
| 46 |
+
"""Una geometría que no es Polygon debe rechazarse."""
|
| 47 |
+
punto = {"type": "Point", "coordinates": [-58.0, -34.0]}
|
| 48 |
+
with pytest.raises(ValidationError):
|
| 49 |
+
FieldIn(name="x", geojson=punto)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_ndvi_point_acepta_rango_valido() -> None:
|
| 53 |
+
"""Un NDVI dentro de [-1, 1] y nubosidad dentro de [0, 100] se acepta."""
|
| 54 |
+
punto = NDVIPoint(date="2026-04-01", mean_ndvi=0.72, cloud_cover=4.1)
|
| 55 |
+
assert punto.mean_ndvi == 0.72
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_ndvi_point_rechaza_fuera_de_rango() -> None:
|
| 59 |
+
"""Un NDVI fuera de [-1, 1] dispara ValidationError."""
|
| 60 |
+
with pytest.raises(ValidationError):
|
| 61 |
+
NDVIPoint(date="2026-04-01", mean_ndvi=1.5)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_weather_point_acepta_nulos() -> None:
|
| 65 |
+
"""Las variables climáticas son opcionales (pueden faltar en una fecha)."""
|
| 66 |
+
punto = WeatherPoint(date="2026-04-01")
|
| 67 |
+
assert punto.precip_mm is None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_chat_message_rechaza_rol_invalido() -> None:
|
| 71 |
+
"""Solo se permiten los roles 'user' y 'assistant'."""
|
| 72 |
+
with pytest.raises(ValidationError):
|
| 73 |
+
ChatMessage(role="system", content="x", session_id="s1")
|
tests/unit/test_geometry.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: test_geometry.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Pruebas unitarias de las funciones geométricas puras (Fase 1): distancia de muestreo
|
| 8 |
+
terrestre (GSD), densidad por hectárea y área geodésica de un polígono lon/lat.
|
| 9 |
+
|
| 10 |
+
Ejemplo de Integración:
|
| 11 |
+
uv run python -m pytest tests/unit/test_geometry.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from backend.core.geometry import density_per_ha, geodesic_area_m2, gsd_cm_per_px
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_gsd_valor_conocido() -> None:
|
| 20 |
+
"""GSD = (sensor*altura)/(focal*ancho)*100; caso redondo -> 10 cm/px."""
|
| 21 |
+
g = gsd_cm_per_px(sensor_w_mm=10, flight_h_m=100, focal_mm=10, img_w_px=1000)
|
| 22 |
+
assert g == 10.0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_density_per_ha_una_hectarea() -> None:
|
| 26 |
+
"""100 plantas en 10.000 m² (1 ha) -> 100 pl/Ha."""
|
| 27 |
+
assert density_per_ha(100, 10_000) == 100.0
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_density_per_ha_area_cero() -> None:
|
| 31 |
+
"""Área cero no debe dividir por cero; devuelve 0.0."""
|
| 32 |
+
assert density_per_ha(5, 0) == 0.0
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_geodesic_area_caja_un_grado_cerca_ecuador() -> None:
|
| 36 |
+
"""Una caja de 1°x1° cerca del ecuador mide ~1.23e10 m² (≈12.300 km²)."""
|
| 37 |
+
box = [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]
|
| 38 |
+
area = geodesic_area_m2(box)
|
| 39 |
+
assert 1.1e10 < area < 1.4e10
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_geodesic_area_independiente_del_sentido() -> None:
|
| 43 |
+
"""El área no depende del sentido de giro del polígono (se toma valor absoluto)."""
|
| 44 |
+
horario = [[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]
|
| 45 |
+
antihorario = [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]
|
| 46 |
+
assert round(geodesic_area_m2(horario), 2) == round(geodesic_area_m2(antihorario), 2)
|
tests/unit/test_ndvi.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: test_ndvi.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Pruebas unitarias del motor NDVI puro (Fase 1): cálculo vectorizado, evitación de
|
| 8 |
+
división por cero, estadística zonal y agregación mensual (1 punto/mes, menor nube).
|
| 9 |
+
|
| 10 |
+
Ejemplo de Integración:
|
| 11 |
+
uv run python -m pytest tests/unit/test_ndvi.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
from backend.core.ndvi import aggregate_monthly, ndvi, zonal_stats
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_ndvi_valor_conocido() -> None:
|
| 22 |
+
"""NDVI(0.8, 0.2) = (0.8-0.2)/(0.8+0.2) = 0.6."""
|
| 23 |
+
arr = ndvi(np.array([0.8]), np.array([0.2]))
|
| 24 |
+
assert round(float(arr[0]), 4) == 0.6
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_ndvi_evita_division_por_cero() -> None:
|
| 28 |
+
"""Cuando NIR+Red = 0, el NDVI debe ser 0.0 (sin error)."""
|
| 29 |
+
arr = ndvi(np.array([0.0]), np.array([0.0]))
|
| 30 |
+
assert float(arr[0]) == 0.0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_zonal_stats_mean_min_max() -> None:
|
| 34 |
+
"""La estadística zonal devuelve media, mínimo y máximo sobre la máscara."""
|
| 35 |
+
arr = np.array([0.2, 0.4, 0.6])
|
| 36 |
+
mask = np.array([True, True, True])
|
| 37 |
+
stats = zonal_stats(arr, mask)
|
| 38 |
+
assert stats["min_ndvi"] == 0.2
|
| 39 |
+
assert stats["max_ndvi"] == 0.6
|
| 40 |
+
assert round(stats["mean_ndvi"], 2) == 0.4
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_aggregate_monthly_elige_menor_nube_por_mes() -> None:
|
| 44 |
+
"""Por cada mes se conserva la escena de menor nubosidad; la fecha se normaliza al día 1."""
|
| 45 |
+
series = [
|
| 46 |
+
{"date": "2026-04-03", "mean_ndvi": 0.70, "cloud_cover": 40},
|
| 47 |
+
{"date": "2026-04-20", "mean_ndvi": 0.80, "cloud_cover": 5}, # mejor de abril
|
| 48 |
+
{"date": "2026-05-10", "mean_ndvi": 0.60, "cloud_cover": 10},
|
| 49 |
+
]
|
| 50 |
+
out = aggregate_monthly(series)
|
| 51 |
+
assert len(out) == 2
|
| 52 |
+
abril = next(p for p in out if p["date"] == "2026-04-01")
|
| 53 |
+
assert abril["mean_ndvi"] == 0.80
|
| 54 |
+
assert abril["cloud_cover"] == 5
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_aggregate_monthly_ordena_cronologicamente() -> None:
|
| 58 |
+
"""La salida queda ordenada por fecha ascendente."""
|
| 59 |
+
series = [
|
| 60 |
+
{"date": "2026-05-10", "mean_ndvi": 0.6, "cloud_cover": 10},
|
| 61 |
+
{"date": "2026-04-20", "mean_ndvi": 0.8, "cloud_cover": 5},
|
| 62 |
+
]
|
| 63 |
+
out = aggregate_monthly(series)
|
| 64 |
+
assert [p["date"] for p in out] == ["2026-04-01", "2026-05-01"]
|