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.
package/backend-template/api.py
CHANGED
|
@@ -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
|