alexp97 commited on
Commit
603f109
·
1 Parent(s): 99a7270

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 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"]