agroplan-ai-cli 1.0.13 → 1.0.15

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.
@@ -747,3 +747,57 @@ def limpar_cache(request: Request):
747
747
  if __name__ == "__main__":
748
748
  import uvicorn
749
749
  uvicorn.run(app, host=HOST, port=PORT)
750
+
751
+ @app.get("/dados/zarc")
752
+ def get_zarc(
753
+ cultura: Optional[str] = Query(None, description="Nome da cultura"),
754
+ uf: Optional[str] = Query(None, description="Unidade Federativa (ex: SP, PR)"),
755
+ municipio: Optional[str] = Query(None, description="Nome do município"),
756
+ solo: Optional[str] = Query(None, description="Tipo de solo"),
757
+ safra: str = Query("2025/2026", description="Safra (ex: 2025/2026)")
758
+ ):
759
+ """Obtém dados ZARC (Zoneamento Agrícola de Risco Climático)"""
760
+ try:
761
+ # Importar provider ZARC
762
+ from providers.zarc_provider import buscar_zarc
763
+
764
+ # Se cultura não foi fornecida, retornar mensagem amigável
765
+ if not cultura:
766
+ return {
767
+ "message": "Informe a cultura para consultar dados ZARC.",
768
+ "exemplo_soja_sp": "/dados/zarc?cultura=soja&uf=SP&municipio=Sao%20Paulo&solo=argiloso",
769
+ "exemplo_milho_pr": "/dados/zarc?cultura=milho&uf=PR&municipio=Londrina&solo=argiloso",
770
+ "parametros": {
771
+ "cultura": "Nome da cultura (obrigatório)",
772
+ "uf": "Unidade Federativa (opcional)",
773
+ "municipio": "Nome do município (opcional)",
774
+ "solo": "Tipo de solo (opcional)",
775
+ "safra": "Safra, padrão 2025/2026"
776
+ },
777
+ "culturas_disponiveis": ["soja", "milho", "feijao", "cafe", "cana", "trigo", "algodao"],
778
+ "safras_disponiveis": ["2025/2026", "2026/2027"]
779
+ }
780
+
781
+ # Buscar dados ZARC
782
+ zarc_data = buscar_zarc(
783
+ cultura=cultura,
784
+ uf=uf,
785
+ municipio=municipio,
786
+ solo=solo,
787
+ safra=safra
788
+ )
789
+
790
+ if zarc_data:
791
+ return zarc_data
792
+ else:
793
+ return {
794
+ "message": "Dados ZARC não encontrados para os parâmetros fornecidos.",
795
+ "cultura": cultura,
796
+ "uf": uf,
797
+ "municipio": municipio,
798
+ "solo": solo,
799
+ "safra": safra,
800
+ "sugestao": "Tente com parâmetros mais genéricos (apenas cultura e UF)"
801
+ }
802
+ except Exception as e:
803
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,733 @@
1
+ """
2
+ Provedor de dados ZARC (Zoneamento Agrícola de Risco Climático)
3
+ Fonte: Portal de Dados Abertos do Ministério da Agricultura
4
+ """
5
+ import urllib.request
6
+ import urllib.parse
7
+ import csv
8
+ import os
9
+ import json
10
+ from datetime import datetime, timedelta
11
+ from typing import Optional, Dict, Any, List
12
+ from .cache import get_cache, set_cache
13
+
14
+ # Configurações
15
+ ZARC_CACHE_DIR = os.path.join(os.path.dirname(__file__), '..', 'data', 'zarc')
16
+ ZARC_CACHE_TTL = int(os.getenv("ZARC_CACHE_TTL", "86400")) # 24 horas
17
+ ZARC_SOURCE = os.getenv("ZARC_SOURCE", "official") # official, fallback
18
+ ZARC_SAFRA_DEFAULT = os.getenv("ZARC_SAFRA", "2025/2026")
19
+
20
+ # URLs oficiais do Portal de Dados Abertos do Ministério da Agricultura
21
+ ZARC_URLS = {
22
+ "2025/2026": "https://dados.agricultura.gov.br/dataset/6d3d141c-885e-41a4-ab7f-dc8ff323b96f/resource/f9d597f9-0fee-47eb-9344-8642274ca9da/download/dados-abertos-tabua-de-risco-safra-2025-2026.csv",
23
+ "2026/2027": None # TODO: Adicionar quando disponível
24
+ }
25
+
26
+ # Mapeamento de colunas do CSV oficial para formato interno
27
+ COLUMN_MAP = {
28
+ "cultura": ["Nome_cultura", "cultura"],
29
+ "uf": ["UF", "uf"],
30
+ "municipio": ["municipio", "Municipio"],
31
+ "solo": ["Cod_Solo", "solo", "tipo_solo"],
32
+ # Janelas de plantio são representadas por decêndios (dec1-dec36)
33
+ # Cada decêndio representa 10 dias do ano
34
+ # Precisaremos processar isso de forma especial
35
+ }
36
+
37
+ # Mapeamento de códigos de solo (valores reais do CSV)
38
+ # O CSV usa códigos mais complexos, vamos mapear os principais
39
+ SOLO_MAP = {
40
+ "1": "arenoso",
41
+ "2": "medio",
42
+ "3": "argiloso",
43
+ # Códigos reais do CSV (baseado em textura)
44
+ "11": "arenoso",
45
+ "12": "arenoso",
46
+ "13": "arenoso",
47
+ "14": "medio",
48
+ "15": "medio",
49
+ "16": "medio",
50
+ "17": "argiloso",
51
+ "18": "argiloso",
52
+ "19": "argiloso"
53
+ }
54
+
55
+ def decendio_para_periodo(dec: int) -> Dict[str, str]:
56
+ """
57
+ Converte número do decêndio para período do ano
58
+
59
+ Args:
60
+ dec: Número do decêndio (1-36)
61
+
62
+ Returns:
63
+ Dicionário com inicio e fim do período (formato DD/MM)
64
+ """
65
+ if dec < 1 or dec > 36:
66
+ return {"inicio": "??/??", "fim": "??/??"}
67
+
68
+ # Cada mês tem 3 decêndios
69
+ mes = ((dec - 1) // 3) + 1
70
+ decendio_no_mes = ((dec - 1) % 3) + 1
71
+
72
+ # Dias de início e fim por decêndio no mês
73
+ if decendio_no_mes == 1:
74
+ dia_inicio = 1
75
+ dia_fim = 10
76
+ elif decendio_no_mes == 2:
77
+ dia_inicio = 11
78
+ dia_fim = 20
79
+ else: # decendio_no_mes == 3
80
+ dia_inicio = 21
81
+ # Último decêndio vai até o fim do mês
82
+ dias_no_mes = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
83
+ dia_fim = dias_no_mes[mes - 1]
84
+
85
+ return {
86
+ "inicio": f"{dia_inicio:02d}/{mes:02d}",
87
+ "fim": f"{dia_fim:02d}/{mes:02d}"
88
+ }
89
+
90
+ def mapear_codigo_solo(cod_solo: str) -> str:
91
+ """
92
+ Mapeia código de solo para nome
93
+
94
+ Args:
95
+ cod_solo: Código do solo (1-3 ou 11-19)
96
+
97
+ Returns:
98
+ Nome do solo (arenoso, medio, argiloso)
99
+ """
100
+ return SOLO_MAP.get(str(cod_solo), "desconhecido")
101
+
102
+ def extrair_janelas_plantio(registro: Dict[str, Any]) -> List[Dict[str, Any]]:
103
+ """
104
+ Extrai janelas de plantio dos decêndios
105
+
106
+ O CSV ZARC usa valores de risco em percentual:
107
+ - 20: Risco de 20% (baixo)
108
+ - 30: Risco de 30% (médio)
109
+ - 40: Risco de 40% (alto)
110
+ - 0 ou vazio: Não recomendado
111
+
112
+ Args:
113
+ registro: Registro do CSV com colunas dec1-dec36
114
+
115
+ Returns:
116
+ Lista de janelas de plantio
117
+ """
118
+ janelas = []
119
+ janela_atual = None
120
+
121
+ for dec in range(1, 37):
122
+ col_name = f"dec{dec}"
123
+ valor = registro.get(col_name, "")
124
+
125
+ # Converter para string e limpar
126
+ valor_str = str(valor).strip()
127
+
128
+ # Valores válidos: 20 (baixo), 30 (médio), 40 (alto)
129
+ # 0 ou vazio = não recomendado
130
+ if valor_str and valor_str != "0":
131
+ try:
132
+ risco_percent = int(valor_str)
133
+
134
+ # Classificar risco baseado nos valores reais do CSV
135
+ if risco_percent == 20:
136
+ risco = "baixo"
137
+ risco_num = 20
138
+ elif risco_percent == 30:
139
+ risco = "medio"
140
+ risco_num = 30
141
+ elif risco_percent == 40:
142
+ risco = "alto"
143
+ risco_num = 40
144
+ else:
145
+ # Valor inesperado, pular
146
+ continue
147
+
148
+ if janela_atual is None:
149
+ # Iniciar nova janela
150
+ periodo = decendio_para_periodo(dec)
151
+ janela_atual = {
152
+ "inicio": periodo["inicio"],
153
+ "fim": periodo["fim"],
154
+ "decendios": [dec],
155
+ "riscos": [risco_num]
156
+ }
157
+ else:
158
+ # Continuar janela atual
159
+ periodo = decendio_para_periodo(dec)
160
+ janela_atual["fim"] = periodo["fim"]
161
+ janela_atual["decendios"].append(dec)
162
+ janela_atual["riscos"].append(risco_num)
163
+ except ValueError:
164
+ # Valor inválido, tratar como fim de janela
165
+ if janela_atual is not None:
166
+ # Calcular risco predominante
167
+ risco_medio = sum(janela_atual["riscos"]) / len(janela_atual["riscos"])
168
+ if risco_medio <= 25: # Média até 25 = predominantemente baixo
169
+ janela_atual["risco_predominante"] = "baixo"
170
+ elif risco_medio <= 35: # Média até 35 = predominantemente médio
171
+ janela_atual["risco_predominante"] = "medio"
172
+ else:
173
+ janela_atual["risco_predominante"] = "alto"
174
+
175
+ del janela_atual["riscos"]
176
+ janelas.append(janela_atual)
177
+ janela_atual = None
178
+ else:
179
+ # Fim da janela atual (se houver)
180
+ if janela_atual is not None:
181
+ # Calcular risco predominante
182
+ risco_medio = sum(janela_atual["riscos"]) / len(janela_atual["riscos"])
183
+ if risco_medio <= 25: # Média até 25 = predominantemente baixo
184
+ janela_atual["risco_predominante"] = "baixo"
185
+ elif risco_medio <= 35: # Média até 35 = predominantemente médio
186
+ janela_atual["risco_predominante"] = "medio"
187
+ else:
188
+ janela_atual["risco_predominante"] = "alto"
189
+
190
+ del janela_atual["riscos"]
191
+ janelas.append(janela_atual)
192
+ janela_atual = None
193
+
194
+ # Adicionar última janela se houver
195
+ if janela_atual is not None:
196
+ risco_medio = sum(janela_atual["riscos"]) / len(janela_atual["riscos"])
197
+ if risco_medio <= 25: # Média até 25 = predominantemente baixo
198
+ janela_atual["risco_predominante"] = "baixo"
199
+ elif risco_medio <= 35: # Média até 35 = predominantemente médio
200
+ janela_atual["risco_predominante"] = "medio"
201
+ else:
202
+ janela_atual["risco_predominante"] = "alto"
203
+
204
+ del janela_atual["riscos"]
205
+ janelas.append(janela_atual)
206
+
207
+ return janelas
208
+
209
+ def escolher_melhor_janela(janelas: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
210
+ """
211
+ Escolhe a melhor janela de plantio
212
+
213
+ Critérios:
214
+ 1. Menor risco predominante
215
+ 2. Maior duração
216
+ 3. Primeira janela (em caso de empate)
217
+
218
+ Args:
219
+ janelas: Lista de janelas de plantio
220
+
221
+ Returns:
222
+ Melhor janela ou None
223
+ """
224
+ if not janelas:
225
+ return None
226
+
227
+ # Ordenar por risco (baixo > medio > alto) e depois por duração (maior > menor)
228
+ risco_ordem = {"baixo": 1, "medio": 2, "alto": 3}
229
+
230
+ janelas_ordenadas = sorted(
231
+ janelas,
232
+ key=lambda j: (risco_ordem.get(j["risco_predominante"], 999), -len(j["decendios"]))
233
+ )
234
+
235
+ return janelas_ordenadas[0]
236
+
237
+ def normalizar_registro_oficial(row: Dict[str, Any]) -> Dict[str, Any]:
238
+ """
239
+ Normaliza registro do CSV oficial para formato padrão
240
+
241
+ Args:
242
+ row: Linha do CSV oficial
243
+
244
+ Returns:
245
+ Registro normalizado
246
+ """
247
+ return {
248
+ "cultura": row.get("Nome_cultura", ""),
249
+ "uf": row.get("UF", ""),
250
+ "municipio": row.get("municipio", ""),
251
+ "solo_codigo": row.get("Cod_Solo", ""),
252
+ "solo": mapear_codigo_solo(row.get("Cod_Solo", "")),
253
+ "safra_ini": row.get("SafraIni", ""),
254
+ "safra_fin": row.get("SafraFin", ""),
255
+ "geocodigo": row.get("geocodigo", ""),
256
+ "decendios": {f"dec{i}": row.get(f"dec{i}", "") for i in range(1, 37)}
257
+ }
258
+
259
+ def normalizar_texto(texto: str) -> str:
260
+ """Normaliza texto removendo acentos e convertendo para minúsculas"""
261
+ if not texto:
262
+ return ""
263
+
264
+ # Mapeamento de acentos
265
+ mapa_acentos = {
266
+ 'á': 'a', 'à': 'a', 'ã': 'a', 'â': 'a',
267
+ 'é': 'e', 'ê': 'e',
268
+ 'í': 'i',
269
+ 'ó': 'o', 'ô': 'o', 'õ': 'o',
270
+ 'ú': 'u', 'ü': 'u',
271
+ 'ç': 'c',
272
+ 'Á': 'a', 'À': 'a', 'Ã': 'a', 'Â': 'a',
273
+ 'É': 'e', 'Ê': 'e',
274
+ 'Í': 'i',
275
+ 'Ó': 'o', 'Ô': 'o', 'Õ': 'o',
276
+ 'Ú': 'u', 'Ü': 'u',
277
+ 'Ç': 'c'
278
+ }
279
+
280
+ texto_normalizado = texto.lower().strip()
281
+ for acento, sem_acento in mapa_acentos.items():
282
+ texto_normalizado = texto_normalizado.replace(acento, sem_acento)
283
+
284
+ return texto_normalizado
285
+
286
+ def normalizar_cultura(cultura: str) -> str:
287
+ """Normaliza nome de cultura"""
288
+ return normalizar_texto(cultura)
289
+
290
+ def normalizar_municipio(municipio: str) -> str:
291
+ """Normaliza nome de município"""
292
+ return normalizar_texto(municipio)
293
+
294
+ def normalizar_uf(uf: str) -> str:
295
+ """Normaliza UF"""
296
+ return uf.upper().strip() if uf else ""
297
+
298
+ def normalizar_solo(solo: str) -> str:
299
+ """Normaliza tipo de solo"""
300
+ return normalizar_texto(solo)
301
+
302
+ def get_cache_path(safra: str) -> str:
303
+ """Retorna caminho do arquivo de cache para a safra"""
304
+ os.makedirs(ZARC_CACHE_DIR, exist_ok=True)
305
+ safra_filename = safra.replace("/", "-")
306
+ return os.path.join(ZARC_CACHE_DIR, f"zarc_{safra_filename}.csv")
307
+
308
+ def is_cache_valid(cache_path: str) -> bool:
309
+ """Verifica se o cache ainda é válido"""
310
+ if not os.path.exists(cache_path):
311
+ return False
312
+
313
+ # Verifica idade do arquivo
314
+ file_age = datetime.now() - datetime.fromtimestamp(os.path.getmtime(cache_path))
315
+ return file_age.total_seconds() < ZARC_CACHE_TTL
316
+
317
+ def download_zarc_dataset(safra: str) -> Optional[str]:
318
+ """
319
+ Baixa dataset ZARC oficial
320
+
321
+ Returns:
322
+ Caminho do arquivo baixado ou None se falhar
323
+ """
324
+ try:
325
+ url = ZARC_URLS.get(safra)
326
+ if not url:
327
+ print(f"URL não disponível para safra {safra}")
328
+ return None
329
+
330
+ cache_path = get_cache_path(safra)
331
+
332
+ print(f"Baixando ZARC oficial de {url}...")
333
+
334
+ # Criar request com User-Agent
335
+ req = urllib.request.Request(
336
+ url,
337
+ headers={
338
+ 'User-Agent': 'AgroPlan-AI/1.0 (https://github.com/Kuuhaku-Allan/agroplan-ai)'
339
+ }
340
+ )
341
+
342
+ # Download
343
+ with urllib.request.urlopen(req, timeout=30) as response:
344
+ content = response.read().decode('utf-8')
345
+
346
+ # Salvar
347
+ with open(cache_path, 'w', encoding='utf-8') as f:
348
+ f.write(content)
349
+
350
+ print(f"ZARC oficial baixado e salvo em {cache_path}")
351
+ return cache_path
352
+
353
+ except Exception as e:
354
+ print(f"Erro ao baixar ZARC oficial: {e}")
355
+ return None
356
+
357
+ def get_zarc_dataset(safra: str = ZARC_SAFRA_DEFAULT) -> Dict[str, Any]:
358
+ """
359
+ Obtém dataset ZARC (cache ou download)
360
+
361
+ Returns:
362
+ Dicionário com:
363
+ - records: Lista de registros ZARC
364
+ - source: "zarc-oficial" | "zarc-cache" | "zarc-fallback"
365
+ - fallback: bool
366
+ - cache_path: str ou None
367
+ - error: str ou None
368
+ """
369
+ cache_path = get_cache_path(safra)
370
+
371
+ # Verificar cache válido
372
+ if is_cache_valid(cache_path):
373
+ try:
374
+ records = load_zarc_from_file(cache_path)
375
+ return {
376
+ "records": records,
377
+ "source": "zarc-cache",
378
+ "fallback": False,
379
+ "cache_path": cache_path,
380
+ "error": None
381
+ }
382
+ except Exception as e:
383
+ print(f"Erro ao carregar cache ZARC: {e}")
384
+
385
+ # Tentar download se source for official
386
+ if ZARC_SOURCE == "official":
387
+ downloaded_path = download_zarc_dataset(safra)
388
+ if downloaded_path:
389
+ try:
390
+ records = load_zarc_from_file(downloaded_path)
391
+ return {
392
+ "records": records,
393
+ "source": "zarc-oficial",
394
+ "fallback": False,
395
+ "cache_path": downloaded_path,
396
+ "error": None
397
+ }
398
+ except Exception as e:
399
+ print(f"Erro ao carregar ZARC baixado: {e}")
400
+
401
+ # Usar cache antigo se existir (mesmo expirado)
402
+ if os.path.exists(cache_path):
403
+ try:
404
+ records = load_zarc_from_file(cache_path)
405
+ return {
406
+ "records": records,
407
+ "source": "zarc-cache",
408
+ "fallback": False,
409
+ "cache_path": cache_path,
410
+ "error": "Cache expirado mas usado"
411
+ }
412
+ except Exception as e:
413
+ print(f"Erro ao carregar cache antigo: {e}")
414
+
415
+ # Fallback para dados simplificados
416
+ print("Usando fallback ZARC simplificado")
417
+ return {
418
+ "records": get_zarc_fallback(),
419
+ "source": "zarc-fallback",
420
+ "fallback": True,
421
+ "cache_path": None,
422
+ "error": "CSV oficial não disponível, usando dados simplificados"
423
+ }
424
+
425
+ def load_zarc_from_file(file_path: str) -> List[Dict[str, Any]]:
426
+ """Carrega dados ZARC de arquivo CSV"""
427
+ registros = []
428
+
429
+ with open(file_path, 'r', encoding='utf-8-sig') as f: # utf-8-sig remove BOM
430
+ # Detectar delimitador (CSV oficial usa ponto-e-vírgula)
431
+ primeira_linha = f.readline()
432
+ f.seek(0)
433
+
434
+ delimiter = ';' if ';' in primeira_linha else ','
435
+
436
+ reader = csv.DictReader(f, delimiter=delimiter)
437
+
438
+ # Log das colunas encontradas (primeira vez)
439
+ if reader.fieldnames:
440
+ print(f"Colunas ZARC encontradas ({len(reader.fieldnames)} colunas, delimiter='{delimiter}')")
441
+
442
+ for row in reader:
443
+ registros.append(row)
444
+
445
+ return registros
446
+
447
+ def inspect_zarc_columns(safra: str = ZARC_SAFRA_DEFAULT) -> Optional[List[str]]:
448
+ """
449
+ Inspeciona colunas do CSV ZARC oficial
450
+
451
+ Returns:
452
+ Lista de nomes de colunas ou None se falhar
453
+ """
454
+ try:
455
+ dataset = get_zarc_dataset(safra)
456
+
457
+ if not dataset or not dataset.get("records"):
458
+ print("Nenhum registro ZARC disponível")
459
+ return None
460
+
461
+ # Pegar colunas do primeiro registro
462
+ if dataset["records"]:
463
+ colunas = list(dataset["records"][0].keys())
464
+ print(f"\nColunas do CSV ZARC ({dataset['source']}):")
465
+ for i, col in enumerate(colunas, 1):
466
+ print(f" {i}. {col}")
467
+ return colunas
468
+
469
+ return None
470
+
471
+ except Exception as e:
472
+ print(f"Erro ao inspecionar colunas ZARC: {e}")
473
+ return None
474
+
475
+ def get_zarc_fallback() -> List[Dict[str, Any]]:
476
+ """
477
+ Retorna dados ZARC simplificados como fallback
478
+
479
+ Baseado em conhecimento geral de janelas de plantio no Brasil
480
+ """
481
+ return [
482
+ # Soja
483
+ {
484
+ "cultura": "soja",
485
+ "uf": "SP",
486
+ "municipio": "sao paulo",
487
+ "solo": "argiloso",
488
+ "janela_inicio": "10/10",
489
+ "janela_fim": "15/12",
490
+ "risco": "baixo",
491
+ "safra": "2025/2026"
492
+ },
493
+ {
494
+ "cultura": "soja",
495
+ "uf": "PR",
496
+ "municipio": "londrina",
497
+ "solo": "argiloso",
498
+ "janela_inicio": "01/10",
499
+ "janela_fim": "10/12",
500
+ "risco": "baixo",
501
+ "safra": "2025/2026"
502
+ },
503
+ {
504
+ "cultura": "soja",
505
+ "uf": "MS",
506
+ "municipio": "campo grande",
507
+ "solo": "argiloso",
508
+ "janela_inicio": "15/09",
509
+ "janela_fim": "30/11",
510
+ "risco": "baixo",
511
+ "safra": "2025/2026"
512
+ },
513
+ # Milho
514
+ {
515
+ "cultura": "milho",
516
+ "uf": "SP",
517
+ "municipio": "ribeirao preto",
518
+ "solo": "argiloso",
519
+ "janela_inicio": "15/09",
520
+ "janela_fim": "30/11",
521
+ "risco": "baixo",
522
+ "safra": "2025/2026"
523
+ },
524
+ {
525
+ "cultura": "milho",
526
+ "uf": "PR",
527
+ "municipio": "londrina",
528
+ "solo": "argiloso",
529
+ "janela_inicio": "01/09",
530
+ "janela_fim": "15/11",
531
+ "risco": "baixo",
532
+ "safra": "2025/2026"
533
+ },
534
+ # Feijão
535
+ {
536
+ "cultura": "feijao",
537
+ "uf": "SP",
538
+ "municipio": "sao paulo",
539
+ "solo": "misto",
540
+ "janela_inicio": "15/08",
541
+ "janela_fim": "30/10",
542
+ "risco": "medio",
543
+ "safra": "2025/2026"
544
+ },
545
+ # Café
546
+ {
547
+ "cultura": "cafe",
548
+ "uf": "SP",
549
+ "municipio": "ribeirao preto",
550
+ "solo": "argiloso",
551
+ "janela_inicio": "01/10",
552
+ "janela_fim": "31/12",
553
+ "risco": "baixo",
554
+ "safra": "2025/2026"
555
+ },
556
+ # Cana
557
+ {
558
+ "cultura": "cana",
559
+ "uf": "SP",
560
+ "municipio": "ribeirao preto",
561
+ "solo": "argiloso",
562
+ "janela_inicio": "01/09",
563
+ "janela_fim": "31/03",
564
+ "risco": "baixo",
565
+ "safra": "2025/2026"
566
+ }
567
+ ]
568
+
569
+ def buscar_zarc(
570
+ cultura: str,
571
+ uf: Optional[str] = None,
572
+ municipio: Optional[str] = None,
573
+ solo: Optional[str] = None,
574
+ safra: str = ZARC_SAFRA_DEFAULT
575
+ ) -> Optional[Dict[str, Any]]:
576
+ """
577
+ Busca dados ZARC para cultura/região específica
578
+
579
+ Args:
580
+ cultura: Nome da cultura
581
+ uf: Unidade Federativa (opcional)
582
+ municipio: Nome do município (opcional)
583
+ solo: Tipo de solo (opcional)
584
+ safra: Safra (padrão: 2025/2026)
585
+
586
+ Returns:
587
+ Dicionário com dados ZARC ou None se não encontrar
588
+ """
589
+ dataset_info = get_zarc_dataset(safra)
590
+ if not dataset_info or not dataset_info.get("records"):
591
+ return None
592
+
593
+ dataset = dataset_info["records"]
594
+ source = dataset_info["source"]
595
+ is_fallback = dataset_info["fallback"]
596
+
597
+ # Normalizar parâmetros de busca
598
+ cultura_norm = normalizar_cultura(cultura)
599
+ uf_norm = normalizar_uf(uf) if uf else None
600
+ municipio_norm = normalizar_municipio(municipio) if municipio else None
601
+ solo_norm = normalizar_solo(solo) if solo else None
602
+
603
+ # Se estiver usando dados oficiais, processar decêndios
604
+ if not is_fallback:
605
+ # Buscar no CSV oficial
606
+ melhor_match = None
607
+ melhor_score = 0
608
+
609
+ for registro in dataset:
610
+ score = 0
611
+
612
+ # Cultura deve bater
613
+ if normalizar_cultura(registro.get("Nome_cultura", "")) != cultura_norm:
614
+ continue
615
+ score += 10
616
+
617
+ # UF (se fornecida)
618
+ if uf_norm and normalizar_uf(registro.get("UF", "")) == uf_norm:
619
+ score += 5
620
+
621
+ # Município (se fornecido)
622
+ if municipio_norm and normalizar_municipio(registro.get("municipio", "")) == municipio_norm:
623
+ score += 3
624
+
625
+ # Solo (se fornecido)
626
+ if solo_norm:
627
+ solo_registro = mapear_codigo_solo(registro.get("Cod_Solo", ""))
628
+ if normalizar_solo(solo_registro) == solo_norm:
629
+ score += 2
630
+
631
+ if score > melhor_score:
632
+ melhor_score = score
633
+ melhor_match = registro
634
+
635
+ if melhor_match:
636
+ # Extrair janelas de plantio dos decêndios
637
+ janelas = extrair_janelas_plantio(melhor_match)
638
+ melhor_janela = escolher_melhor_janela(janelas)
639
+
640
+ if melhor_janela:
641
+ # Determinar observação baseada na fonte
642
+ if source == "zarc-oficial":
643
+ observacao = "Dados obtidos da Tábua de Risco do ZARC (Ministério da Agricultura)."
644
+ else: # zarc-cache
645
+ observacao = "Dados obtidos do cache local da Tábua de Risco do ZARC."
646
+
647
+ return {
648
+ "source": source,
649
+ "safra": safra,
650
+ "cultura": melhor_match.get("Nome_cultura"),
651
+ "uf": melhor_match.get("UF"),
652
+ "municipio": melhor_match.get("municipio"),
653
+ "geocodigo": melhor_match.get("geocodigo"),
654
+ "solo_codigo": melhor_match.get("Cod_Solo"),
655
+ "solo": mapear_codigo_solo(melhor_match.get("Cod_Solo", "")),
656
+ "janela_plantio": {
657
+ "inicio": melhor_janela["inicio"],
658
+ "fim": melhor_janela["fim"]
659
+ },
660
+ "risco": melhor_janela["risco_predominante"],
661
+ "decendios_recomendados": melhor_janela["decendios"],
662
+ "fallback": False,
663
+ "encontrado": True,
664
+ "observacao": observacao
665
+ }
666
+ else:
667
+ # Registro encontrado mas sem janelas válidas
668
+ return {
669
+ "source": source,
670
+ "safra": safra,
671
+ "cultura": melhor_match.get("Nome_cultura"),
672
+ "uf": melhor_match.get("UF"),
673
+ "municipio": melhor_match.get("municipio"),
674
+ "fallback": False,
675
+ "encontrado": False,
676
+ "message": "Registro ZARC encontrado mas sem janelas de plantio recomendadas."
677
+ }
678
+ else:
679
+ # Nenhum registro encontrado no CSV oficial
680
+ return {
681
+ "source": source,
682
+ "fallback": False,
683
+ "encontrado": False,
684
+ "message": "Nenhuma recomendação ZARC encontrada para os parâmetros informados."
685
+ }
686
+
687
+ # Fallback: usar dados simplificados
688
+ melhor_match = None
689
+ melhor_score = 0
690
+
691
+ for registro in dataset:
692
+ score = 0
693
+
694
+ # Cultura deve bater
695
+ if normalizar_cultura(registro.get("cultura", "")) != cultura_norm:
696
+ continue
697
+ score += 10
698
+
699
+ # UF (se fornecida)
700
+ if uf_norm and normalizar_uf(registro.get("uf", "")) == uf_norm:
701
+ score += 5
702
+
703
+ # Município (se fornecido)
704
+ if municipio_norm and normalizar_municipio(registro.get("municipio", "")) == municipio_norm:
705
+ score += 3
706
+
707
+ # Solo (se fornecido)
708
+ if solo_norm and normalizar_solo(registro.get("solo", "")) == solo_norm:
709
+ score += 2
710
+
711
+ if score > melhor_score:
712
+ melhor_score = score
713
+ melhor_match = registro
714
+
715
+ if melhor_match:
716
+ return {
717
+ "source": "zarc-fallback",
718
+ "safra": safra,
719
+ "cultura": melhor_match.get("cultura"),
720
+ "uf": melhor_match.get("uf"),
721
+ "municipio": melhor_match.get("municipio"),
722
+ "solo": melhor_match.get("solo"),
723
+ "janela_plantio": {
724
+ "inicio": melhor_match.get("janela_inicio"),
725
+ "fim": melhor_match.get("janela_fim")
726
+ },
727
+ "risco": melhor_match.get("risco", "indeterminado"),
728
+ "fallback": True,
729
+ "encontrado": True,
730
+ "observacao": "Dados simplificados locais usados porque o CSV oficial não estava disponível."
731
+ }
732
+
733
+ return None
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agroplan-ai-cli",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "CLI global para AgroPlan AI - modo local acelerado",
5
5
  "type": "module",
6
6
  "bin": {