agroplan-ai-cli 1.0.14 → 1.0.17

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.
@@ -24,3 +24,22 @@ PROVIDER_CACHE_TTL=3600
24
24
  # If set, /cache/limpar endpoint requires X-Cache-Token header
25
25
  # Leave empty to disable protection (not recommended for production)
26
26
  CACHE_ADMIN_TOKEN=your-secret-token-here
27
+
28
+ # ZARC Configuration
29
+ # Source: official (download from gov portal) or fallback (use local simplified data)
30
+ ZARC_SOURCE=official
31
+
32
+ # Safra padrão
33
+ ZARC_SAFRA=2025/2026
34
+
35
+ # Cache TTL em segundos (padrão: 86400 = 24 horas)
36
+ ZARC_CACHE_TTL=86400
37
+
38
+ # Fast Index: usa índice compacto pré-processado (recomendado: true)
39
+ # O índice contém apenas regiões/culturas de interesse (~35KB vs 214MB)
40
+ ZARC_FAST_INDEX_ENABLED=true
41
+
42
+ # Allow Full Scan: permite varrer CSV completo se não encontrar no índice
43
+ # Produção (Render): false - evita timeout e uso excessivo de CPU
44
+ # Desenvolvimento local: true - permite consultar qualquer região
45
+ ZARC_ALLOW_FULL_SCAN=false
@@ -73,6 +73,9 @@ class OtimizarRequest(BaseModel):
73
73
  lat: Optional[float] = None
74
74
  lon: Optional[float] = None
75
75
  days: Optional[int] = 30
76
+ uf: Optional[str] = None
77
+ municipio: Optional[str] = None
78
+ safra: Optional[str] = "2025/2026"
76
79
 
77
80
  class ValidarRequest(BaseModel):
78
81
  objetivo: str = "equilibrado"
@@ -84,6 +87,9 @@ class RelatorioRequest(BaseModel):
84
87
  lat: Optional[float] = None
85
88
  lon: Optional[float] = None
86
89
  days: Optional[int] = 30
90
+ uf: Optional[str] = None
91
+ municipio: Optional[str] = None
92
+ safra: Optional[str] = "2025/2026"
87
93
 
88
94
  class RodadasRequest(BaseModel):
89
95
  objetivo: str = "equilibrado"
@@ -141,6 +147,10 @@ def health():
141
147
  culturas, talhoes, regras = get_dados()
142
148
  provider_cache_stats = get_cache_stats()
143
149
 
150
+ # Verificar status ZARC (memory safe - não carrega CSV)
151
+ from providers.zarc_provider import get_zarc_status
152
+ zarc_status = get_zarc_status()
153
+
144
154
  return {
145
155
  "status": "healthy",
146
156
  "culturas": len(culturas),
@@ -149,7 +159,8 @@ def health():
149
159
  "cache_items": len(_resultados_cache),
150
160
  "data_mode": DATA_MODE,
151
161
  "providers": {
152
- "weather": "available" if WEATHER_PROVIDER else "disabled"
162
+ "weather": "available" if WEATHER_PROVIDER else "disabled",
163
+ "zarc": zarc_status
153
164
  },
154
165
  "provider_cache": provider_cache_stats
155
166
  }
@@ -186,13 +197,71 @@ def get_clima(
186
197
  except Exception as e:
187
198
  raise HTTPException(status_code=500, detail=str(e))
188
199
 
200
+ @app.get("/dados/zarc")
201
+ def get_zarc(
202
+ cultura: Optional[str] = Query(None, description="Nome da cultura"),
203
+ uf: Optional[str] = Query(None, description="Unidade Federativa (ex: SP, PR)"),
204
+ municipio: Optional[str] = Query(None, description="Nome do município"),
205
+ solo: Optional[str] = Query(None, description="Tipo de solo"),
206
+ safra: str = Query("2025/2026", description="Safra (ex: 2025/2026)")
207
+ ):
208
+ """Obtém dados ZARC (Zoneamento Agrícola de Risco Climático)"""
209
+ try:
210
+ # Importar provider ZARC
211
+ from providers.zarc_provider import buscar_zarc
212
+
213
+ # Se cultura não foi fornecida, retornar mensagem amigável
214
+ if not cultura:
215
+ return {
216
+ "message": "Informe a cultura para consultar dados ZARC.",
217
+ "exemplo_soja_sp": "/dados/zarc?cultura=soja&uf=SP&municipio=Sao%20Paulo&solo=argiloso",
218
+ "exemplo_milho_pr": "/dados/zarc?cultura=milho&uf=PR&municipio=Londrina&solo=argiloso",
219
+ "parametros": {
220
+ "cultura": "Nome da cultura (obrigatório)",
221
+ "uf": "Unidade Federativa (opcional)",
222
+ "municipio": "Nome do município (opcional)",
223
+ "solo": "Tipo de solo (opcional)",
224
+ "safra": "Safra, padrão 2025/2026"
225
+ },
226
+ "culturas_disponiveis": ["soja", "milho", "feijao", "cafe", "cana", "trigo", "algodao"],
227
+ "safras_disponiveis": ["2025/2026", "2026/2027"]
228
+ }
229
+
230
+ # Buscar dados ZARC
231
+ zarc_data = buscar_zarc(
232
+ cultura=cultura,
233
+ uf=uf,
234
+ municipio=municipio,
235
+ solo=solo,
236
+ safra=safra
237
+ )
238
+
239
+ if zarc_data:
240
+ return zarc_data
241
+ else:
242
+ return {
243
+ "message": "Dados ZARC não encontrados para os parâmetros fornecidos.",
244
+ "cultura": cultura,
245
+ "uf": uf,
246
+ "municipio": municipio,
247
+ "solo": solo,
248
+ "safra": safra,
249
+ "sugestao": "Tente com parâmetros mais genéricos (apenas cultura e UF)"
250
+ }
251
+
252
+ except Exception as e:
253
+ raise HTTPException(status_code=500, detail=str(e))
254
+
189
255
  @app.get("/dashboard")
190
256
  def get_dashboard(
191
257
  lat: Optional[float] = None,
192
258
  lon: Optional[float] = None,
193
- days: int = Query(30, description="Número de dias para análise climática")
259
+ days: int = Query(30, description="Número de dias para análise climática"),
260
+ uf: Optional[str] = None,
261
+ municipio: Optional[str] = None,
262
+ safra: str = Query("2025/2026", description="Safra ZARC")
194
263
  ):
195
- """Retorna resumo do dashboard com contexto climático opcional"""
264
+ """Retorna resumo do dashboard com contexto climático e ZARC opcional"""
196
265
  try:
197
266
  culturas, talhoes, regras = get_dados()
198
267
 
@@ -202,10 +271,12 @@ def get_dashboard(
202
271
  from core.climate_adapter import obter_contexto_climatico_por_coordenadas
203
272
  contexto_climatico = obter_contexto_climatico_por_coordenadas(lat, lon, days)
204
273
 
205
- # Gerar chave de cache considerando clima
274
+ # Gerar chave de cache considerando clima e ZARC
206
275
  cache_params = {"objetivo": "equilibrado", "seed": 42}
207
276
  if lat is not None and lon is not None:
208
277
  cache_params.update({"lat": lat, "lon": lon, "days": days})
278
+ if uf:
279
+ cache_params.update({"uf": uf, "municipio": municipio or "", "safra": safra})
209
280
 
210
281
  cache_key = get_cache_key("dashboard", **cache_params)
211
282
 
@@ -301,6 +372,18 @@ def get_dashboard(
301
372
  else:
302
373
  resultado_base["clima_real"] = {"ativo": False}
303
374
 
375
+ # Enriquecer com ZARC se UF foi fornecida
376
+ if uf:
377
+ from core.zarc_adapter import enriquecer_plano_com_zarc
378
+ resultado_base = enriquecer_plano_com_zarc(
379
+ resultado_base,
380
+ uf=uf,
381
+ municipio=municipio,
382
+ safra=safra
383
+ )
384
+ else:
385
+ resultado_base["zarc"] = {"ativo": False}
386
+
304
387
  return resultado_base
305
388
 
306
389
  # Usa cache para dashboard com contexto climático
@@ -323,8 +406,12 @@ def get_talhoes():
323
406
  raise HTTPException(status_code=500, detail=str(e))
324
407
 
325
408
  @app.get("/recomendacoes")
326
- def get_recomendacoes():
327
- """Retorna recomendações de culturas por talhão"""
409
+ def get_recomendacoes(
410
+ uf: Optional[str] = None,
411
+ municipio: Optional[str] = None,
412
+ safra: str = Query("2025/2026", description="Safra ZARC")
413
+ ):
414
+ """Retorna recomendações de culturas por talhão com ZARC opcional"""
328
415
  try:
329
416
  culturas, talhoes, regras = get_dados()
330
417
 
@@ -347,9 +434,24 @@ def get_recomendacoes():
347
434
  "agua": str(p['agua'])
348
435
  })
349
436
 
350
- return {
351
- "recomendacoes": recomendacoes
352
- }
437
+ resultado = {"recomendacoes": recomendacoes}
438
+
439
+ # Enriquecer com ZARC se UF foi fornecida
440
+ if uf:
441
+ from core.zarc_adapter import enriquecer_plano_com_zarc
442
+ resultado_temp = {"plano": recomendacoes}
443
+ resultado_temp = enriquecer_plano_com_zarc(
444
+ resultado_temp,
445
+ uf=uf,
446
+ municipio=municipio,
447
+ safra=safra
448
+ )
449
+ resultado["recomendacoes"] = resultado_temp["plano"]
450
+ resultado["zarc"] = resultado_temp.get("zarc", {"ativo": False})
451
+ else:
452
+ resultado["zarc"] = {"ativo": False}
453
+
454
+ return resultado
353
455
  except Exception as e:
354
456
  raise HTTPException(status_code=500, detail=str(e))
355
457
 
@@ -590,6 +692,18 @@ def otimizar(request: OtimizarRequest):
590
692
  # Converte tipos numpy para Python nativos
591
693
  resultado_convertido = converter_tipos_python(resultado)
592
694
 
695
+ # Enriquecer com ZARC se UF foi fornecida
696
+ if request.uf:
697
+ from core.zarc_adapter import enriquecer_plano_com_zarc
698
+ resultado_convertido = enriquecer_plano_com_zarc(
699
+ resultado_convertido,
700
+ uf=request.uf,
701
+ municipio=request.municipio,
702
+ safra=request.safra
703
+ )
704
+ else:
705
+ resultado_convertido["zarc"] = {"ativo": False}
706
+
593
707
  return resultado_convertido
594
708
  except HTTPException:
595
709
  raise
@@ -677,12 +791,15 @@ def relatorio(request: RelatorioRequest):
677
791
  from core.climate_adapter import obter_contexto_climatico_por_coordenadas
678
792
  contexto_climatico = obter_contexto_climatico_por_coordenadas(request.lat, request.lon, request.days)
679
793
 
680
- # Gera relatório com contexto climático integrado
794
+ # Gera relatório com contexto climático e ZARC integrado
681
795
  caminho = gerar_relatorio_completo(
682
796
  culturas, talhoes, regras,
683
797
  objetivo=request.objetivo,
684
798
  formato=request.formato,
685
- contexto_climatico=contexto_climatico
799
+ contexto_climatico=contexto_climatico,
800
+ uf=request.uf,
801
+ municipio=request.municipio,
802
+ safra=request.safra
686
803
  )
687
804
 
688
805
  # Lê conteúdo
@@ -0,0 +1,290 @@
1
+ """
2
+ Adaptador ZARC - Integra dados do ZARC no planejamento
3
+ """
4
+ from typing import Dict, Any, Optional, List
5
+ from providers.zarc_provider import buscar_zarc
6
+
7
+ def enriquecer_plano_com_zarc(
8
+ resultado: Dict[str, Any],
9
+ uf: Optional[str] = None,
10
+ municipio: Optional[str] = None,
11
+ safra: str = "2025/2026"
12
+ ) -> Dict[str, Any]:
13
+ """
14
+ Enriquece resultado do planejamento com dados ZARC
15
+
16
+ Args:
17
+ resultado: Resultado do planejamento (AG ou cenário)
18
+ uf: Unidade Federativa
19
+ municipio: Nome do município
20
+ safra: Safra (padrão: 2025/2026)
21
+
22
+ Returns:
23
+ Resultado enriquecido com dados ZARC
24
+ """
25
+ # Se não tiver UF, não adiciona ZARC
26
+ if not uf:
27
+ resultado["zarc"] = {"ativo": False}
28
+ return resultado
29
+
30
+ # Cache local por requisição para evitar lookups repetidos
31
+ lookup_cache = {}
32
+
33
+ # Processar cada item do plano
34
+ culturas_com_zarc = 0
35
+ total_culturas = 0
36
+ sources = set()
37
+ tem_fallback = False
38
+
39
+ for item in resultado.get("plano", []):
40
+ total_culturas += 1
41
+ cultura = item.get("cultura")
42
+ solo = item.get("solo")
43
+
44
+ # Chave de cache: cultura|uf|municipio|solo|safra
45
+ cache_key = f"{cultura}|{uf}|{municipio}|{solo}|{safra}"
46
+
47
+ # Verificar cache local
48
+ if cache_key in lookup_cache:
49
+ zarc_data = lookup_cache[cache_key]
50
+ else:
51
+ # Buscar ZARC para esta cultura/solo
52
+ zarc_data = buscar_zarc(
53
+ cultura=cultura,
54
+ uf=uf,
55
+ municipio=municipio,
56
+ solo=solo,
57
+ safra=safra
58
+ )
59
+ # Cachear resultado
60
+ lookup_cache[cache_key] = zarc_data
61
+
62
+ if zarc_data and zarc_data.get("encontrado"):
63
+ # ZARC encontrado
64
+ item["zarc"] = {
65
+ "ativo": True,
66
+ "source": zarc_data.get("source"),
67
+ "fallback": zarc_data.get("fallback", False),
68
+ "janela_plantio": zarc_data.get("janela_plantio"),
69
+ "risco": zarc_data.get("risco"),
70
+ "safra": zarc_data.get("safra"),
71
+ "observacao": zarc_data.get("observacao"),
72
+ "decendios_recomendados": zarc_data.get("decendios_recomendados"),
73
+ "municipio_zarc": zarc_data.get("municipio"),
74
+ "geocodigo": zarc_data.get("geocodigo")
75
+ }
76
+ culturas_com_zarc += 1
77
+ sources.add(zarc_data.get("source"))
78
+ if zarc_data.get("fallback"):
79
+ tem_fallback = True
80
+ else:
81
+ # ZARC não encontrado
82
+ item["zarc"] = {
83
+ "ativo": False,
84
+ "message": zarc_data.get("message") if zarc_data else "ZARC não consultado"
85
+ }
86
+
87
+ # Determinar source geral
88
+ if len(sources) == 0:
89
+ source_geral = "unavailable"
90
+ elif len(sources) == 1:
91
+ source_geral = list(sources)[0]
92
+ else:
93
+ source_geral = "mixed"
94
+
95
+ # Adicionar resumo ZARC no resultado
96
+ resultado["zarc"] = {
97
+ "ativo": True,
98
+ "uf": uf,
99
+ "municipio": municipio,
100
+ "safra": safra,
101
+ "source": source_geral,
102
+ "fallback": tem_fallback,
103
+ "culturas_com_zarc": culturas_com_zarc,
104
+ "total_culturas": total_culturas
105
+ }
106
+
107
+ return resultado
108
+
109
+ def aplicar_ajuste_zarc(
110
+ risco_base: float,
111
+ risco_zarc: str,
112
+ aplicar: bool = False
113
+ ) -> Dict[str, Any]:
114
+ """
115
+ Calcula ajuste de risco baseado no ZARC
116
+
117
+ Args:
118
+ risco_base: Risco base em pontos percentuais
119
+ risco_zarc: Risco ZARC (baixo, medio, alto, indeterminado)
120
+ aplicar: Se deve aplicar o ajuste (padrão: False)
121
+
122
+ Returns:
123
+ Dicionário com risco ajustado e informações do ajuste
124
+ """
125
+ # Mapeamento de ajustes ZARC (conservadores)
126
+ ajustes = {
127
+ "baixo": -2, # Reduz 2 pontos percentuais
128
+ "medio": +4, # Aumenta 4 pontos percentuais
129
+ "alto": +10, # Aumenta 10 pontos percentuais
130
+ "indeterminado": 0
131
+ }
132
+
133
+ ajuste = ajustes.get(risco_zarc, 0)
134
+
135
+ if aplicar and ajuste != 0:
136
+ risco_ajustado = min(95, max(5, risco_base + ajuste))
137
+ else:
138
+ risco_ajustado = risco_base
139
+
140
+ return {
141
+ "risco_original": risco_base,
142
+ "risco_ajustado": risco_ajustado,
143
+ "ajuste_zarc": ajuste,
144
+ "ajuste_aplicado": aplicar,
145
+ "risco_zarc": risco_zarc
146
+ }
147
+
148
+ def gerar_secao_zarc_relatorio(
149
+ plano: List[Dict[str, Any]],
150
+ uf: Optional[str] = None,
151
+ municipio: Optional[str] = None,
152
+ safra: str = "2025/2026",
153
+ formato: str = "md"
154
+ ) -> str:
155
+ """
156
+ Gera seção ZARC para relatório
157
+
158
+ Args:
159
+ plano: Lista de itens do plano
160
+ uf: Unidade Federativa
161
+ municipio: Nome do município
162
+ safra: Safra
163
+ formato: Formato do relatório (md ou txt)
164
+
165
+ Returns:
166
+ Texto da seção ZARC
167
+ """
168
+ if not uf:
169
+ return ""
170
+
171
+ if formato == "md":
172
+ secao = "\n## 🌾 Zoneamento Agrícola de Risco Climático (ZARC)\n\n"
173
+ secao += f"**Região:** {municipio or 'Não especificado'}/{uf}\n"
174
+ secao += f"**Safra:** {safra}\n\n"
175
+
176
+ # Contar culturas com ZARC
177
+ com_zarc = sum(1 for item in plano if item.get("zarc", {}).get("ativo"))
178
+ total = len(plano)
179
+
180
+ secao += f"**Cobertura:** {com_zarc}/{total} culturas com recomendação ZARC\n\n"
181
+
182
+ # Tabela por talhão
183
+ secao += "| Talhão | Cultura | Solo | Janela de Plantio | Risco ZARC | Fonte |\n"
184
+ secao += "|--------|---------|------|-------------------|------------|-------|\n"
185
+
186
+ for item in plano:
187
+ talhao = item.get("talhao")
188
+ cultura = item.get("cultura")
189
+ solo = item.get("solo")
190
+ zarc = item.get("zarc", {})
191
+
192
+ if zarc.get("ativo"):
193
+ janela = zarc.get("janela_plantio", {})
194
+ inicio = janela.get("inicio", "N/A")
195
+ fim = janela.get("fim", "N/A")
196
+ risco = zarc.get("risco", "N/A")
197
+ source = zarc.get("source", "N/A")
198
+
199
+ # Emoji por fonte
200
+ if source == "zarc-oficial":
201
+ fonte_emoji = "✅ Oficial"
202
+ elif source == "zarc-cache":
203
+ fonte_emoji = "💾 Cache"
204
+ elif source == "zarc-fallback":
205
+ fonte_emoji = "⚠️ Fallback"
206
+ else:
207
+ fonte_emoji = source
208
+
209
+ secao += f"| {talhao} | {cultura} | {solo} | {inicio} a {fim} | {risco} | {fonte_emoji} |\n"
210
+ else:
211
+ message = zarc.get("message", "Não encontrado")
212
+ secao += f"| {talhao} | {cultura} | {solo} | - | - | ⚠️ {message} |\n"
213
+
214
+ secao += "\n"
215
+
216
+ # Observações
217
+ secao += "### Observações ZARC\n\n"
218
+
219
+ observacoes_unicas = set()
220
+ for item in plano:
221
+ zarc = item.get("zarc", {})
222
+ if zarc.get("ativo") and zarc.get("observacao"):
223
+ observacoes_unicas.add(zarc.get("observacao"))
224
+
225
+ if observacoes_unicas:
226
+ for obs in observacoes_unicas:
227
+ secao += f"- {obs}\n"
228
+ else:
229
+ secao += "- Nenhuma recomendação ZARC encontrada para os parâmetros informados.\n"
230
+
231
+ secao += "\n"
232
+
233
+ else: # txt
234
+ secao = "\n" + "="*80 + "\n"
235
+ secao += "ZONEAMENTO AGRÍCOLA DE RISCO CLIMÁTICO (ZARC)\n"
236
+ secao += "="*80 + "\n\n"
237
+ secao += f"Região: {municipio or 'Não especificado'}/{uf}\n"
238
+ secao += f"Safra: {safra}\n\n"
239
+
240
+ # Contar culturas com ZARC
241
+ com_zarc = sum(1 for item in plano if item.get("zarc", {}).get("ativo"))
242
+ total = len(plano)
243
+
244
+ secao += f"Cobertura: {com_zarc}/{total} culturas com recomendação ZARC\n\n"
245
+
246
+ # Lista por talhão
247
+ for item in plano:
248
+ talhao = item.get("talhao")
249
+ cultura = item.get("cultura")
250
+ solo = item.get("solo")
251
+ zarc = item.get("zarc", {})
252
+
253
+ secao += f"Talhão {talhao} - {cultura} ({solo})\n"
254
+
255
+ if zarc.get("ativo"):
256
+ janela = zarc.get("janela_plantio", {})
257
+ inicio = janela.get("inicio", "N/A")
258
+ fim = janela.get("fim", "N/A")
259
+ risco = zarc.get("risco", "N/A")
260
+ source = zarc.get("source", "N/A")
261
+
262
+ secao += f" Janela de Plantio: {inicio} a {fim}\n"
263
+ secao += f" Risco ZARC: {risco}\n"
264
+ secao += f" Fonte: {source}\n"
265
+ else:
266
+ message = zarc.get("message", "Não encontrado")
267
+ secao += f" Status: {message}\n"
268
+
269
+ secao += "\n"
270
+
271
+ # Observações
272
+ secao += "-"*80 + "\n"
273
+ secao += "OBSERVAÇÕES ZARC\n"
274
+ secao += "-"*80 + "\n\n"
275
+
276
+ observacoes_unicas = set()
277
+ for item in plano:
278
+ zarc = item.get("zarc", {})
279
+ if zarc.get("ativo") and zarc.get("observacao"):
280
+ observacoes_unicas.add(zarc.get("observacao"))
281
+
282
+ if observacoes_unicas:
283
+ for obs in observacoes_unicas:
284
+ secao += f"- {obs}\n"
285
+ else:
286
+ secao += "- Nenhuma recomendação ZARC encontrada para os parâmetros informados.\n"
287
+
288
+ secao += "\n"
289
+
290
+ return secao