agroplan-ai-cli 1.0.33 → 1.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
- "cli_version": "1.0.33",
3
- "backend_template_version": "1.0.33",
2
+ "cli_version": "1.0.34",
3
+ "backend_template_version": "1.0.34",
4
4
  "zarc_index_version": "2025-2026-fast-index-v2",
5
5
  "price_index_version": "2025-05-reference-v1",
6
6
  "features": [
@@ -22,7 +22,8 @@
22
22
  "manual_field_registration",
23
23
  "crop_calendar_from_manual_field",
24
24
  "expanded_crop_calendar_10_cultures",
25
- "calendar_date_safety"
25
+ "calendar_date_safety",
26
+ "calendar_weather_integration"
26
27
  ],
27
- "generated_at": "2026-05-10T20:00:00Z"
28
+ "generated_at": "2026-05-10T21:00:00Z"
28
29
  }
@@ -1358,6 +1358,16 @@ def gerar_calendario(request: dict):
1358
1358
  zarc_context=request.get("zarc_context")
1359
1359
  )
1360
1360
 
1361
+ # Enriquecer com clima se solicitado
1362
+ usar_clima = request.get("usar_clima", False)
1363
+ if usar_clima:
1364
+ from core.calendar_weather_adapter import enriquecer_calendario_com_clima
1365
+ lat = field_data.get("lat")
1366
+ lon = field_data.get("lon")
1367
+ resultado = enriquecer_calendario_com_clima(resultado, lat=lat, lon=lon)
1368
+ else:
1369
+ resultado["weather_enabled"] = False
1370
+
1361
1371
  return converter_tipos_python(resultado)
1362
1372
 
1363
1373
  except HTTPException:
@@ -1532,6 +1542,7 @@ def gerar_calendario_talhao(field_id: str, request: dict):
1532
1542
  try:
1533
1543
  from core.field_storage import obter_talhao_usuario
1534
1544
  from core.crop_calendar_engine import gerar_calendario_cultura
1545
+ from core.calendar_weather_adapter import enriquecer_calendario_com_clima
1535
1546
  from core.planning_models import Field, SoilType, Slope, WaterAvailability, GenerateCalendarRequest
1536
1547
  from datetime import datetime
1537
1548
 
@@ -1575,6 +1586,15 @@ def gerar_calendario_talhao(field_id: str, request: dict):
1575
1586
  crop_plan_id=None
1576
1587
  )
1577
1588
 
1589
+ # Enriquecer com clima se solicitado
1590
+ usar_clima = request.get("usar_clima", False)
1591
+ if usar_clima:
1592
+ lat = field_data.get("lat")
1593
+ lon = field_data.get("lon")
1594
+ resultado = enriquecer_calendario_com_clima(resultado, lat=lat, lon=lon)
1595
+ else:
1596
+ resultado["weather_enabled"] = False
1597
+
1578
1598
  # Adicionar dados do talhão na resposta
1579
1599
  resultado["field_data"] = field_data
1580
1600
 
@@ -0,0 +1,176 @@
1
+ """
2
+ Adaptador de Clima para Calendário Agrícola
3
+
4
+ Enriquece tarefas do calendário com contexto climático:
5
+ - Previsão real (0-16 dias)
6
+ - Climatologia (17+ dias)
7
+ - Recomendações situacionais
8
+ """
9
+
10
+ from datetime import date, timedelta
11
+ from typing import Dict, List, Optional
12
+ from providers.calendar_weather_provider import (
13
+ buscar_previsao_curto_prazo,
14
+ buscar_climatologia_longo_prazo,
15
+ gerar_recomendacao_climatica
16
+ )
17
+
18
+
19
+ def enriquecer_calendario_com_clima(
20
+ calendar: Dict,
21
+ lat: Optional[float] = None,
22
+ lon: Optional[float] = None
23
+ ) -> Dict:
24
+ """
25
+ Enriquece calendário com contexto climático.
26
+
27
+ Args:
28
+ calendar: Calendário gerado
29
+ lat: Latitude do talhão
30
+ lon: Longitude do talhão
31
+
32
+ Returns:
33
+ Calendário enriquecido com weather_context em cada tarefa
34
+ """
35
+
36
+ # Verificar se coordenadas foram fornecidas
37
+ if lat is None or lon is None:
38
+ calendar["weather_enabled"] = False
39
+ calendar["weather_warnings"] = [
40
+ "Para usar clima integrado, informe latitude e longitude do talhão."
41
+ ]
42
+ return calendar
43
+
44
+ try:
45
+ today = date.today()
46
+ planting_date = date.fromisoformat(calendar["planting_date"])
47
+ estimated_harvest = date.fromisoformat(calendar["estimated_harvest_date"])
48
+
49
+ # Buscar previsão de curto prazo (0-16 dias)
50
+ forecast_end = today + timedelta(days=16)
51
+ forecast_data = buscar_previsao_curto_prazo(lat, lon, today, days=16)
52
+
53
+ # Criar mapa de previsão por data
54
+ forecast_map = {item["date"]: item for item in forecast_data}
55
+
56
+ # Buscar climatologia de longo prazo (17+ dias até colheita)
57
+ climatology_data = []
58
+ if estimated_harvest > forecast_end:
59
+ climatology_start = forecast_end + timedelta(days=1)
60
+ climatology_data = buscar_climatologia_longo_prazo(
61
+ lat, lon, climatology_start, estimated_harvest
62
+ )
63
+
64
+ # Criar mapa de climatologia por data
65
+ climatology_map = {item["date"]: item for item in climatology_data}
66
+
67
+ # Contadores
68
+ forecast_tasks = 0
69
+ climatology_tasks = 0
70
+ no_weather_tasks = 0
71
+ sources_used = set()
72
+
73
+ # Enriquecer cada tarefa
74
+ for task in calendar.get("tasks", []):
75
+ task_date_str = task["date"]
76
+ task_date = date.fromisoformat(task_date_str)
77
+
78
+ # Apenas enriquecer tarefas sensíveis ao clima
79
+ if not task.get("weather_sensitive", False):
80
+ task["weather_context"] = {
81
+ "active": False,
82
+ "reason": "Tarefa não sensível ao clima"
83
+ }
84
+ no_weather_tasks += 1
85
+ continue
86
+
87
+ # Verificar se está no passado
88
+ if task_date < today:
89
+ task["weather_context"] = {
90
+ "active": False,
91
+ "reason": "Tarefa no passado"
92
+ }
93
+ no_weather_tasks += 1
94
+ continue
95
+
96
+ # Tentar usar previsão real (0-16 dias)
97
+ if task_date_str in forecast_map:
98
+ weather_data = forecast_map[task_date_str]
99
+ forecast_tasks += 1
100
+ sources_used.add("open-meteo")
101
+ # Usar climatologia (17+ dias)
102
+ elif task_date_str in climatology_map:
103
+ weather_data = climatology_map[task_date_str]
104
+ climatology_tasks += 1
105
+ sources_used.add(weather_data["source"])
106
+ else:
107
+ # Sem dados disponíveis
108
+ task["weather_context"] = {
109
+ "active": False,
110
+ "reason": "Dados climáticos não disponíveis para esta data"
111
+ }
112
+ no_weather_tasks += 1
113
+ continue
114
+
115
+ # Gerar recomendação
116
+ recommendation = gerar_recomendacao_climatica(
117
+ task.get("type", ""),
118
+ task.get("title", ""),
119
+ weather_data
120
+ )
121
+
122
+ # Gerar resumo
123
+ forecast_type = weather_data.get("forecast_type", "climatology")
124
+ if forecast_type == "forecast":
125
+ summary = f"Previsão: {weather_data.get('precipitation_sum', 0):.1f}mm de chuva, {weather_data.get('temperature_min', 0):.0f}°C a {weather_data.get('temperature_max', 0):.0f}°C"
126
+ else:
127
+ summary = f"Climatologia: {weather_data.get('precipitation_expected', 'Condições típicas')}"
128
+
129
+ # Adicionar contexto climático à tarefa
130
+ task["weather_context"] = {
131
+ "active": True,
132
+ "source": weather_data.get("source", "unknown"),
133
+ "forecast_type": forecast_type,
134
+ "summary": summary,
135
+ "precipitation_mm": weather_data.get("precipitation_sum") or weather_data.get("precipitation_mm_avg"),
136
+ "precipitation_probability": weather_data.get("precipitation_probability"),
137
+ "temperature_min": weather_data.get("temperature_min"),
138
+ "temperature_max": weather_data.get("temperature_max"),
139
+ "recommendation": recommendation,
140
+ "confidence": weather_data.get("confidence", "media")
141
+ }
142
+
143
+ # Adicionar resumo geral
144
+ calendar["weather_enabled"] = True
145
+ calendar["weather_summary"] = {
146
+ "forecast_tasks": forecast_tasks,
147
+ "climatology_tasks": climatology_tasks,
148
+ "no_weather_tasks": no_weather_tasks,
149
+ "sources": list(sources_used)
150
+ }
151
+
152
+ # Adicionar avisos
153
+ weather_warnings = []
154
+
155
+ if climatology_tasks > 0:
156
+ weather_warnings.append(
157
+ f"{climatology_tasks} tarefa(s) usam climatologia/histórico (17+ dias). "
158
+ "Não é previsão exata, apenas condições típicas do período."
159
+ )
160
+
161
+ if forecast_tasks > 0:
162
+ weather_warnings.append(
163
+ f"{forecast_tasks} tarefa(s) usam previsão meteorológica real (0-16 dias)."
164
+ )
165
+
166
+ calendar["weather_warnings"] = weather_warnings
167
+
168
+ return calendar
169
+
170
+ except Exception as e:
171
+ print(f"Erro ao enriquecer calendário com clima: {e}")
172
+ calendar["weather_enabled"] = False
173
+ calendar["weather_warnings"] = [
174
+ f"Erro ao buscar dados climáticos: {str(e)}"
175
+ ]
176
+ return calendar
@@ -0,0 +1,272 @@
1
+ """
2
+ Provider de Clima para Calendário Agrícola
3
+
4
+ Estratégia honesta sobre previsão climática:
5
+ - 0-16 dias: Previsão meteorológica real (Open-Meteo)
6
+ - 17+ dias: Climatologia/histórico (NASA POWER ou fallback local)
7
+
8
+ Não fingimos ter previsão exata para ciclos longos (120+ dias).
9
+ """
10
+
11
+ from datetime import date, timedelta
12
+ from typing import Dict, List, Optional
13
+ import requests
14
+ from .cache import get_cache, set_cache
15
+
16
+
17
+ def buscar_previsao_curto_prazo(
18
+ lat: float,
19
+ lon: float,
20
+ start_date: date,
21
+ days: int = 16
22
+ ) -> List[Dict]:
23
+ """
24
+ Busca previsão meteorológica real para os próximos 16 dias.
25
+
26
+ Usa Open-Meteo Forecast API.
27
+
28
+ Args:
29
+ lat: Latitude
30
+ lon: Longitude
31
+ start_date: Data inicial
32
+ days: Número de dias (máximo 16)
33
+
34
+ Returns:
35
+ Lista de dicionários com previsão por data
36
+ """
37
+
38
+ # Limitar a 16 dias (limite confiável do Open-Meteo)
39
+ days = min(days, 16)
40
+
41
+ # Cache key
42
+ cache_key = f"calendar_forecast:{lat}:{lon}:{start_date.isoformat()}:{days}"
43
+ cached = get_cache(cache_key)
44
+ if cached:
45
+ return cached
46
+
47
+ try:
48
+ # Open-Meteo Forecast API
49
+ url = "https://api.open-meteo.com/v1/forecast"
50
+ params = {
51
+ "latitude": lat,
52
+ "longitude": lon,
53
+ "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max",
54
+ "timezone": "auto",
55
+ "forecast_days": days
56
+ }
57
+
58
+ response = requests.get(url, params=params, timeout=10)
59
+ response.raise_for_status()
60
+ data = response.json()
61
+
62
+ # Processar resposta
63
+ daily = data.get("daily", {})
64
+ dates = daily.get("time", [])
65
+ temp_max = daily.get("temperature_2m_max", [])
66
+ temp_min = daily.get("temperature_2m_min", [])
67
+ precip_sum = daily.get("precipitation_sum", [])
68
+ precip_prob = daily.get("precipitation_probability_max", [])
69
+
70
+ result = []
71
+ for i, date_str in enumerate(dates):
72
+ result.append({
73
+ "date": date_str,
74
+ "source": "open-meteo",
75
+ "forecast_type": "forecast",
76
+ "temperature_max": temp_max[i] if i < len(temp_max) else None,
77
+ "temperature_min": temp_min[i] if i < len(temp_min) else None,
78
+ "precipitation_sum": precip_sum[i] if i < len(precip_sum) else None,
79
+ "precipitation_probability": precip_prob[i] if i < len(precip_prob) else None,
80
+ "confidence": "alta"
81
+ })
82
+
83
+ # Cache por 6 horas
84
+ set_cache(cache_key, result, ttl_seconds=21600)
85
+
86
+ return result
87
+
88
+ except Exception as e:
89
+ print(f"Erro ao buscar previsão Open-Meteo: {e}")
90
+ return []
91
+
92
+
93
+ def buscar_climatologia_longo_prazo(
94
+ lat: float,
95
+ lon: float,
96
+ start_date: date,
97
+ end_date: date
98
+ ) -> List[Dict]:
99
+ """
100
+ Busca climatologia/histórico para períodos longos (17+ dias).
101
+
102
+ Inicialmente usa fallback local mensal.
103
+ Futuramente pode integrar NASA POWER.
104
+
105
+ Args:
106
+ lat: Latitude
107
+ lon: Longitude
108
+ start_date: Data inicial
109
+ end_date: Data final
110
+
111
+ Returns:
112
+ Lista de dicionários com climatologia por data/mês
113
+ """
114
+
115
+ # Por enquanto, usar fallback local baseado em médias mensais
116
+ # Futuramente: integrar NASA POWER Climatology API
117
+
118
+ result = []
119
+ current = start_date
120
+
121
+ while current <= end_date:
122
+ month = current.month
123
+
124
+ # Climatologia simplificada por mês (Brasil)
125
+ climatology = _get_monthly_climatology(month, lat, lon)
126
+
127
+ result.append({
128
+ "date": current.isoformat(),
129
+ "source": "climate-fallback",
130
+ "forecast_type": "climatology",
131
+ "temperature_max": climatology["temp_max"],
132
+ "temperature_min": climatology["temp_min"],
133
+ "precipitation_expected": climatology["precip_desc"],
134
+ "precipitation_mm_avg": climatology["precip_mm"],
135
+ "confidence": "media"
136
+ })
137
+
138
+ current += timedelta(days=1)
139
+
140
+ return result
141
+
142
+
143
+ def _get_monthly_climatology(month: int, lat: float, lon: float) -> Dict:
144
+ """
145
+ Retorna climatologia simplificada por mês.
146
+
147
+ Baseado em médias históricas do Brasil.
148
+ Futuramente: usar NASA POWER ou dados regionais.
149
+ """
150
+
151
+ # Determinar região aproximada (simplificado)
152
+ if lat < -23: # Sul
153
+ region = "sul"
154
+ elif lat < -15: # Sudeste/Centro-Oeste
155
+ region = "sudeste"
156
+ else: # Norte/Nordeste
157
+ region = "norte"
158
+
159
+ # Climatologia simplificada (valores aproximados)
160
+ climatology_data = {
161
+ "sul": {
162
+ 1: {"temp_max": 28, "temp_min": 18, "precip_mm": 150, "precip_desc": "Chuvas frequentes"},
163
+ 2: {"temp_max": 28, "temp_min": 18, "precip_mm": 140, "precip_desc": "Chuvas frequentes"},
164
+ 3: {"temp_max": 26, "temp_min": 16, "precip_mm": 120, "precip_desc": "Chuvas moderadas"},
165
+ 4: {"temp_max": 23, "temp_min": 13, "precip_mm": 100, "precip_desc": "Chuvas moderadas"},
166
+ 5: {"temp_max": 20, "temp_min": 10, "precip_mm": 90, "precip_desc": "Chuvas ocasionais"},
167
+ 6: {"temp_max": 18, "temp_min": 8, "precip_mm": 80, "precip_desc": "Chuvas ocasionais"},
168
+ 7: {"temp_max": 18, "temp_min": 8, "precip_mm": 70, "precip_desc": "Período seco"},
169
+ 8: {"temp_max": 20, "temp_min": 10, "precip_mm": 80, "precip_desc": "Período seco"},
170
+ 9: {"temp_max": 22, "temp_min": 12, "precip_mm": 110, "precip_desc": "Chuvas aumentando"},
171
+ 10: {"temp_max": 24, "temp_min": 14, "precip_mm": 130, "precip_desc": "Chuvas frequentes"},
172
+ 11: {"temp_max": 26, "temp_min": 16, "precip_mm": 120, "precip_desc": "Chuvas frequentes"},
173
+ 12: {"temp_max": 28, "temp_min": 18, "precip_mm": 140, "precip_desc": "Chuvas frequentes"},
174
+ },
175
+ "sudeste": {
176
+ 1: {"temp_max": 30, "temp_min": 20, "precip_mm": 220, "precip_desc": "Estação chuvosa"},
177
+ 2: {"temp_max": 30, "temp_min": 20, "precip_mm": 200, "precip_desc": "Estação chuvosa"},
178
+ 3: {"temp_max": 29, "temp_min": 19, "precip_mm": 160, "precip_desc": "Chuvas frequentes"},
179
+ 4: {"temp_max": 27, "temp_min": 17, "precip_mm": 80, "precip_desc": "Chuvas diminuindo"},
180
+ 5: {"temp_max": 25, "temp_min": 15, "precip_mm": 50, "precip_desc": "Período seco"},
181
+ 6: {"temp_max": 24, "temp_min": 13, "precip_mm": 40, "precip_desc": "Período seco"},
182
+ 7: {"temp_max": 24, "temp_min": 13, "precip_mm": 30, "precip_desc": "Período seco"},
183
+ 8: {"temp_max": 26, "temp_min": 15, "precip_mm": 40, "precip_desc": "Período seco"},
184
+ 9: {"temp_max": 27, "temp_min": 16, "precip_mm": 70, "precip_desc": "Chuvas aumentando"},
185
+ 10: {"temp_max": 28, "temp_min": 18, "precip_mm": 130, "precip_desc": "Chuvas frequentes"},
186
+ 11: {"temp_max": 29, "temp_min": 19, "precip_mm": 170, "precip_desc": "Chuvas frequentes"},
187
+ 12: {"temp_max": 29, "temp_min": 20, "precip_mm": 200, "precip_desc": "Estação chuvosa"},
188
+ },
189
+ "norte": {
190
+ 1: {"temp_max": 31, "temp_min": 23, "precip_mm": 280, "precip_desc": "Estação chuvosa"},
191
+ 2: {"temp_max": 31, "temp_min": 23, "precip_mm": 300, "precip_desc": "Estação chuvosa"},
192
+ 3: {"temp_max": 31, "temp_min": 23, "precip_mm": 320, "precip_desc": "Estação chuvosa"},
193
+ 4: {"temp_max": 31, "temp_min": 23, "precip_mm": 280, "precip_desc": "Chuvas frequentes"},
194
+ 5: {"temp_max": 31, "temp_min": 23, "precip_mm": 200, "precip_desc": "Chuvas moderadas"},
195
+ 6: {"temp_max": 32, "temp_min": 23, "precip_mm": 100, "precip_desc": "Chuvas ocasionais"},
196
+ 7: {"temp_max": 33, "temp_min": 23, "precip_mm": 60, "precip_desc": "Período seco"},
197
+ 8: {"temp_max": 34, "temp_min": 24, "precip_mm": 50, "precip_desc": "Período seco"},
198
+ 9: {"temp_max": 34, "temp_min": 24, "precip_mm": 80, "precip_desc": "Chuvas aumentando"},
199
+ 10: {"temp_max": 33, "temp_min": 24, "precip_mm": 130, "precip_desc": "Chuvas aumentando"},
200
+ 11: {"temp_max": 32, "temp_min": 23, "precip_mm": 180, "precip_desc": "Chuvas frequentes"},
201
+ 12: {"temp_max": 31, "temp_min": 23, "precip_mm": 250, "precip_desc": "Estação chuvosa"},
202
+ }
203
+ }
204
+
205
+ return climatology_data.get(region, climatology_data["sudeste"]).get(month, {
206
+ "temp_max": 28,
207
+ "temp_min": 18,
208
+ "precip_mm": 100,
209
+ "precip_desc": "Chuvas moderadas"
210
+ })
211
+
212
+
213
+ def gerar_recomendacao_climatica(
214
+ task_type: str,
215
+ task_title: str,
216
+ weather_data: Dict
217
+ ) -> str:
218
+ """
219
+ Gera recomendação baseada no contexto climático.
220
+
221
+ Args:
222
+ task_type: Tipo da tarefa (irrigate, plant, etc)
223
+ task_title: Título da tarefa
224
+ weather_data: Dados climáticos
225
+
226
+ Returns:
227
+ Recomendação textual
228
+ """
229
+
230
+ forecast_type = weather_data.get("forecast_type", "climatology")
231
+ precip = weather_data.get("precipitation_sum") or weather_data.get("precipitation_mm_avg", 0)
232
+ precip_prob = weather_data.get("precipitation_probability", 0)
233
+ temp_max = weather_data.get("temperature_max", 25)
234
+ temp_min = weather_data.get("temperature_min", 15)
235
+
236
+ # Recomendações para irrigação
237
+ if "irrigar" in task_title.lower() or task_type == "irrigate":
238
+ if forecast_type == "forecast":
239
+ if precip >= 8:
240
+ return f"Chuva prevista suficiente ({precip:.1f}mm). Verifique o solo antes de irrigar."
241
+ elif precip >= 3:
242
+ return f"Chuva moderada prevista ({precip:.1f}mm). Irrigação pode ser parcial."
243
+ else:
244
+ return f"Pouca chuva prevista ({precip:.1f}mm). Irrigação provavelmente necessária."
245
+ else:
246
+ return f"Climatologia: {weather_data.get('precipitation_expected', 'Chuvas moderadas')}. Monitore o solo."
247
+
248
+ # Recomendações para plantio
249
+ if "plantar" in task_title.lower() or task_type == "plant":
250
+ if forecast_type == "forecast":
251
+ if precip > 50:
252
+ return f"Chuva elevada prevista ({precip:.1f}mm). Avalie adiar o plantio para evitar solo encharcado."
253
+ elif precip < 5 and precip_prob < 30:
254
+ return f"Tempo seco previsto. Bom para plantio, mas prepare irrigação."
255
+ else:
256
+ return f"Condições adequadas para plantio. Chuva moderada prevista ({precip:.1f}mm)."
257
+ else:
258
+ return f"Climatologia: {weather_data.get('precipitation_expected', 'Chuvas moderadas')}. Planeje conforme histórico."
259
+
260
+ # Recomendações para temperatura
261
+ if temp_max > 35:
262
+ return f"Calor elevado previsto ({temp_max:.1f}°C). Monitorar estresse hídrico da cultura."
263
+ elif temp_min < 5:
264
+ return f"Frio intenso previsto ({temp_min:.1f}°C). Avaliar risco para a cultura."
265
+ elif temp_min < 10:
266
+ return f"Temperatura baixa prevista ({temp_min:.1f}°C). Monitorar desenvolvimento da cultura."
267
+
268
+ # Recomendação genérica
269
+ if forecast_type == "forecast":
270
+ return f"Temperatura: {temp_min:.1f}°C a {temp_max:.1f}°C. Chuva: {precip:.1f}mm."
271
+ else:
272
+ return f"Climatologia: {weather_data.get('precipitation_expected', 'Condições típicas do período')}."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agroplan-ai-cli",
3
- "version": "1.0.33",
3
+ "version": "1.0.34",
4
4
  "description": "CLI global para AgroPlan AI - modo local acelerado",
5
5
  "type": "module",
6
6
  "bin": {