agroplan-ai-cli 1.0.7 → 1.0.9

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.
@@ -12,8 +12,13 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
12
12
 
13
13
  # Data Mode
14
14
  # simulated: Uses CSV files with simulated data
15
- # real: Uses real APIs (future implementation)
16
- DATA_MODE=simulated
15
+ # real: Uses real APIs only
16
+ # hybrid: Uses real APIs with fallback to simulated data
17
+ DATA_MODE=hybrid
18
+
19
+ # Weather Provider Configuration
20
+ WEATHER_PROVIDER=open-meteo
21
+ PROVIDER_CACHE_TTL=3600
17
22
 
18
23
  # Cache Administration (optional)
19
24
  # If set, /cache/limpar endpoint requires X-Cache-Token header
@@ -2,7 +2,7 @@
2
2
  FastAPI Backend para AgroPlan AI
3
3
  """
4
4
 
5
- from fastapi import FastAPI, HTTPException, Request
5
+ from fastapi import FastAPI, HTTPException, Request, Query
6
6
  from fastapi.middleware.cors import CORSMiddleware
7
7
  from pydantic import BaseModel
8
8
  from typing import Optional
@@ -17,10 +17,17 @@ from core.planner import gerar_cenarios, gerar_plano_genetico
17
17
  from core.bruteforce_validator import comparar_ag_com_forca_bruta, executar_multiplas_rodadas
18
18
  from core.report_generator import gerar_relatorio_completo
19
19
 
20
+ # Importar provedores de dados reais
21
+ from providers.weather_provider import get_weather_summary
22
+ from providers.cache import clear_provider_cache, get_cache_stats
23
+
20
24
  # Configurações de ambiente
21
25
  HOST = os.getenv("HOST", "0.0.0.0")
22
26
  PORT = int(os.getenv("PORT", "8000"))
23
27
  CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
28
+ DATA_MODE = os.getenv("DATA_MODE", "hybrid") # simulated, real, hybrid
29
+ WEATHER_PROVIDER = os.getenv("WEATHER_PROVIDER", "open-meteo")
30
+ PROVIDER_CACHE_TTL = int(os.getenv("PROVIDER_CACHE_TTL", "3600"))
24
31
 
25
32
  # Cache em memória para resultados pesados
26
33
  _resultados_cache = {}
@@ -63,6 +70,9 @@ class OtimizarRequest(BaseModel):
63
70
  seed: Optional[int] = 42
64
71
  geracoes: Optional[int] = 100
65
72
  populacao: Optional[int] = 50
73
+ lat: Optional[float] = None
74
+ lon: Optional[float] = None
75
+ days: Optional[int] = 30
66
76
 
67
77
  class ValidarRequest(BaseModel):
68
78
  objetivo: str = "equilibrado"
@@ -71,6 +81,9 @@ class ValidarRequest(BaseModel):
71
81
  class RelatorioRequest(BaseModel):
72
82
  objetivo: str = "equilibrado"
73
83
  formato: str = "md"
84
+ lat: Optional[float] = None
85
+ lon: Optional[float] = None
86
+ days: Optional[int] = 30
74
87
 
75
88
  class RodadasRequest(BaseModel):
76
89
  objetivo: str = "equilibrado"
@@ -126,32 +139,94 @@ def health():
126
139
  """Verifica saúde da API"""
127
140
  try:
128
141
  culturas, talhoes, regras = get_dados()
142
+ provider_cache_stats = get_cache_stats()
143
+
129
144
  return {
130
145
  "status": "healthy",
131
146
  "culturas": len(culturas),
132
147
  "talhoes": len(talhoes),
133
148
  "regras": len(regras),
134
- "cache_items": len(_resultados_cache)
149
+ "cache_items": len(_resultados_cache),
150
+ "data_mode": DATA_MODE,
151
+ "providers": {
152
+ "weather": "available" if WEATHER_PROVIDER else "disabled"
153
+ },
154
+ "provider_cache": provider_cache_stats
135
155
  }
136
156
  except Exception as e:
137
157
  raise HTTPException(status_code=500, detail=str(e))
138
158
 
159
+ @app.get("/dados/clima")
160
+ def get_clima(
161
+ lat: Optional[float] = Query(None, description="Latitude"),
162
+ lon: Optional[float] = Query(None, description="Longitude"),
163
+ days: int = Query(30, description="Número de dias para análise")
164
+ ):
165
+ """Obtém dados climáticos reais ou simulados"""
166
+ try:
167
+ # Se lat ou lon não foram fornecidos, retornar mensagem amigável
168
+ if lat is None or lon is None:
169
+ return {
170
+ "message": "Informe latitude e longitude para consultar dados climáticos reais.",
171
+ "exemplo_sao_paulo": "/dados/clima?lat=-23.55&lon=-46.63&days=30",
172
+ "exemplo_brasilia": "/dados/clima?lat=-15.78&lon=-47.93&days=30",
173
+ "parametros": {
174
+ "lat": "Latitude da localização",
175
+ "lon": "Longitude da localização",
176
+ "days": "Número de dias analisados, padrão 30"
177
+ }
178
+ }
179
+
180
+ if days < 1 or days > 365:
181
+ raise HTTPException(status_code=400, detail="Days deve estar entre 1 e 365")
182
+
183
+ weather_data = get_weather_summary(lat, lon, days)
184
+ return weather_data.to_dict()
185
+
186
+ except Exception as e:
187
+ raise HTTPException(status_code=500, detail=str(e))
188
+
139
189
  @app.get("/dashboard")
140
- def get_dashboard():
141
- """Retorna resumo do dashboard"""
190
+ def get_dashboard(
191
+ lat: Optional[float] = None,
192
+ lon: Optional[float] = None,
193
+ days: int = Query(30, description="Número de dias para análise climática")
194
+ ):
195
+ """Retorna resumo do dashboard com contexto climático opcional"""
142
196
  try:
143
- def montar_dashboard():
144
- culturas, talhoes, regras = get_dados()
145
-
146
- # Usa AG cacheado
147
- resultado_ag = get_ag_cacheado(objetivo='equilibrado', seed=42)
197
+ culturas, talhoes, regras = get_dados()
198
+
199
+ # Obter contexto climático se coordenadas foram fornecidas
200
+ contexto_climatico = None
201
+ if lat is not None and lon is not None:
202
+ from core.climate_adapter import obter_contexto_climatico_por_coordenadas
203
+ contexto_climatico = obter_contexto_climatico_por_coordenadas(lat, lon, days)
204
+
205
+ # Gerar chave de cache considerando clima
206
+ cache_params = {"objetivo": "equilibrado", "seed": 42}
207
+ if lat is not None and lon is not None:
208
+ cache_params.update({"lat": lat, "lon": lon, "days": days})
209
+
210
+ cache_key = get_cache_key("dashboard", **cache_params)
211
+
212
+ def compute_dashboard():
213
+ # Usar AG com clima se disponível
214
+ if contexto_climatico:
215
+ from core.climate_adapter import gerar_plano_com_clima
216
+ resultado_ag = gerar_plano_com_clima(
217
+ culturas, talhoes, regras,
218
+ objetivo='equilibrado', seed=42,
219
+ lat=lat, lon=lon, days=days
220
+ )
221
+ else:
222
+ resultado_ag = get_ag_cacheado(objetivo='equilibrado', seed=42)
148
223
 
149
224
  # Tenta validar
150
225
  validacao = comparar_ag_com_forca_bruta(culturas, talhoes, regras, objetivo='equilibrado', seed=42)
151
226
 
152
- # Se força bruta é inviável, retorna dados especiais
227
+ # Preparar resultado base
153
228
  if validacao.get('erro'):
154
- return {
229
+ resultado_base = {
155
230
  "lucro_total": float(resultado_ag['lucro_total']),
156
231
  "risco_medio": float(resultado_ag['risco_medio']),
157
232
  "fitness": float(resultado_ag['fitness']),
@@ -179,47 +254,62 @@ def get_dashboard():
179
254
  for p in resultado_ag['plano']
180
255
  ]
181
256
  }
257
+ else:
258
+ resultado_base = {
259
+ "lucro_total": float(resultado_ag['lucro_total']),
260
+ "risco_medio": float(resultado_ag['risco_medio']),
261
+ "fitness": float(resultado_ag['fitness']),
262
+ "diversidade": int(resultado_ag['diversidade']),
263
+ "objetivo": str(resultado_ag['objetivo']),
264
+ "culturas_escolhidas": [str(p['cultura']) for p in resultado_ag['plano']],
265
+ "validacao": {
266
+ "otimo_global": bool(validacao.get('ag_encontrou_otimo_global', False)),
267
+ "total_combinacoes": int(validacao.get('forca_bruta', {}).get('total_combinacoes', 0))
268
+ },
269
+ "plano": [
270
+ {
271
+ "talhao": int(p['talhao']),
272
+ "area": float(p['area']),
273
+ "solo": str(p['solo']),
274
+ "clima": str(p['clima']),
275
+ "relevo": str(p['relevo']),
276
+ "agua": str(p['agua']),
277
+ "cultura": str(p['cultura']),
278
+ "lucro_estimado": float(p['lucro_estimado']),
279
+ "risco": float(p['risco']),
280
+ "nota": float(p['nota']),
281
+ "tempo": int(p['tempo'])
282
+ }
283
+ for p in resultado_ag['plano']
284
+ ]
285
+ }
182
286
 
183
- # Converte tipos numpy para Python nativos
184
- return {
185
- "lucro_total": float(resultado_ag['lucro_total']),
186
- "risco_medio": float(resultado_ag['risco_medio']),
187
- "fitness": float(resultado_ag['fitness']),
188
- "diversidade": int(resultado_ag['diversidade']),
189
- "objetivo": str(resultado_ag['objetivo']),
190
- "culturas_escolhidas": [str(p['cultura']) for p in resultado_ag['plano']],
191
- "validacao": {
192
- "otimo_global": bool(validacao.get('ag_encontrou_otimo_global', False)),
193
- "total_combinacoes": int(validacao.get('forca_bruta', {}).get('total_combinacoes', 0))
194
- },
195
- "plano": [
196
- {
197
- "talhao": int(p['talhao']),
198
- "area": float(p['area']),
199
- "solo": str(p['solo']),
200
- "clima": str(p['clima']),
201
- "relevo": str(p['relevo']),
202
- "agua": str(p['agua']),
203
- "cultura": str(p['cultura']),
204
- "lucro_estimado": float(p['lucro_estimado']),
205
- "risco": float(p['risco']),
206
- "nota": float(p['nota']),
207
- "tempo": int(p['tempo'])
208
- }
209
- for p in resultado_ag['plano']
210
- ]
211
- }
287
+ # Adicionar informações de clima real
288
+ if contexto_climatico:
289
+ resultado_base["clima_real"] = {
290
+ "ativo": True,
291
+ "source": contexto_climatico.get("fonte", "unknown"),
292
+ "temperatura_media": contexto_climatico.get("temperatura_media"),
293
+ "precipitacao_total": contexto_climatico.get("precipitacao_total"),
294
+ "risco_climatico_estimado": contexto_climatico.get("risco_climatico_estimado"),
295
+ "clima_observado": contexto_climatico.get("clima_observado"),
296
+ "agua_observada": contexto_climatico.get("agua_observada"),
297
+ "ajuste_risco": contexto_climatico.get("ajuste_risco", 0),
298
+ "fallback": contexto_climatico.get("fallback", False),
299
+ "error": contexto_climatico.get("error")
300
+ }
301
+ else:
302
+ resultado_base["clima_real"] = {"ativo": False}
303
+
304
+ return resultado_base
212
305
 
213
- # Usa cache para dashboard
214
- key = get_cache_key("dashboard", objetivo="equilibrado", seed=42)
215
- resultado = get_or_compute_cache(key, montar_dashboard)
306
+ # Usa cache para dashboard com contexto climático
307
+ resultado = get_or_compute_cache(cache_key, compute_dashboard)
216
308
 
217
309
  # Converte tipos Python (por segurança)
218
310
  return converter_tipos_python(resultado)
219
311
  except Exception as e:
220
312
  raise HTTPException(status_code=500, detail=str(e))
221
- except Exception as e:
222
- raise HTTPException(status_code=500, detail=str(e))
223
313
 
224
314
  @app.get("/talhoes")
225
315
  def get_talhoes():
@@ -289,17 +379,42 @@ def get_culturas():
289
379
  raise HTTPException(status_code=500, detail=str(e))
290
380
 
291
381
  @app.get("/cenarios")
292
- def get_cenarios():
293
- """Retorna comparação de cenários"""
382
+ def get_cenarios(
383
+ lat: Optional[float] = None,
384
+ lon: Optional[float] = None,
385
+ days: int = Query(30, description="Número de dias para análise climática")
386
+ ):
387
+ """Retorna comparação de cenários com contexto climático opcional"""
294
388
  try:
389
+ # Obter contexto climático se coordenadas foram fornecidas
390
+ contexto_climatico = None
391
+ if lat is not None and lon is not None:
392
+ from core.climate_adapter import obter_contexto_climatico_por_coordenadas
393
+ contexto_climatico = obter_contexto_climatico_por_coordenadas(lat, lon, days)
394
+
395
+ # Gerar chave de cache considerando clima
396
+ cache_params = {"cenarios": True}
397
+ if lat is not None and lon is not None:
398
+ cache_params.update({"lat": lat, "lon": lon, "days": days})
399
+
400
+ cache_key = get_cache_key("cenarios", **cache_params)
401
+
295
402
  def montar_cenarios():
296
403
  culturas, talhoes, regras = get_dados()
297
404
 
298
405
  # Gera todos os cenários
299
406
  cenarios = gerar_cenarios(culturas, talhoes, regras)
300
407
 
301
- # Usa AG cacheado
302
- resultado_ag = get_ag_cacheado(objetivo='equilibrado', seed=42)
408
+ # Usar AG com clima se disponível
409
+ if contexto_climatico:
410
+ from core.climate_adapter import gerar_plano_com_clima
411
+ resultado_ag = gerar_plano_com_clima(
412
+ culturas, talhoes, regras,
413
+ objetivo='equilibrado', seed=42,
414
+ lat=lat, lon=lon, days=days
415
+ )
416
+ else:
417
+ resultado_ag = get_ag_cacheado(objetivo='equilibrado', seed=42)
303
418
 
304
419
  # Cria um mapa de talhões para facilitar o acesso
305
420
  talhoes_dict = {int(row['id']): row for _, row in talhoes.iterrows()}
@@ -332,7 +447,7 @@ def get_cenarios():
332
447
  ]
333
448
  }
334
449
 
335
- # Adiciona AG
450
+ # Adiciona AG com contexto climático
336
451
  cenarios_formatados['genetico'] = {
337
452
  'nome': 'Algoritmo Genético',
338
453
  'descricao': 'Solução otimizada automaticamente',
@@ -357,13 +472,28 @@ def get_cenarios():
357
472
  ]
358
473
  }
359
474
 
360
- return {
361
- "cenarios": cenarios_formatados
362
- }
475
+ # Adicionar informações de clima real se disponível
476
+ resultado_final = {"cenarios": cenarios_formatados}
477
+ if contexto_climatico:
478
+ resultado_final["clima_real"] = {
479
+ "ativo": True,
480
+ "source": contexto_climatico.get("fonte", "unknown"),
481
+ "temperatura_media": contexto_climatico.get("temperatura_media"),
482
+ "precipitacao_total": contexto_climatico.get("precipitacao_total"),
483
+ "risco_climatico_estimado": contexto_climatico.get("risco_climatico_estimado"),
484
+ "clima_observado": contexto_climatico.get("clima_observado"),
485
+ "agua_observada": contexto_climatico.get("agua_observada"),
486
+ "ajuste_risco": contexto_climatico.get("ajuste_risco", 0),
487
+ "fallback": contexto_climatico.get("fallback", False),
488
+ "error": contexto_climatico.get("error")
489
+ }
490
+ else:
491
+ resultado_final["clima_real"] = {"ativo": False}
492
+
493
+ return resultado_final
363
494
 
364
- # Usa cache para cenários
365
- key = get_cache_key("cenarios")
366
- resultado = get_or_compute_cache(key, montar_cenarios)
495
+ # Usa cache para cenários com contexto climático
496
+ resultado = get_or_compute_cache(cache_key, montar_cenarios)
367
497
 
368
498
  return converter_tipos_python(resultado)
369
499
  except Exception as e:
@@ -373,34 +503,54 @@ def get_cenarios():
373
503
 
374
504
  @app.post("/otimizar")
375
505
  def otimizar(request: OtimizarRequest):
376
- """Executa otimização com Algoritmo Genético"""
506
+ """Executa otimização com Algoritmo Genético e contexto climático opcional"""
377
507
  try:
378
508
  # Valida objetivo
379
509
  objetivos_validos = ['equilibrado', 'lucro', 'risco', 'sustentavel']
380
510
  if request.objetivo not in objetivos_validos:
381
511
  raise HTTPException(status_code=400, detail=f"Objetivo inválido. Use: {objetivos_validos}")
382
512
 
383
- # Usa AG cacheado se parâmetros forem padrão
384
- if (request.objetivo == "equilibrado" and
385
- request.seed == 42 and
386
- request.geracoes == 100 and
387
- request.populacao == 50):
388
- resultado = get_ag_cacheado(
513
+ # Obter contexto climático se coordenadas foram fornecidas
514
+ contexto_climatico = None
515
+ if request.lat is not None and request.lon is not None:
516
+ from core.climate_adapter import obter_contexto_climatico_por_coordenadas
517
+ contexto_climatico = obter_contexto_climatico_por_coordenadas(request.lat, request.lon, request.days)
518
+
519
+ # Usar AG com clima se disponível
520
+ if contexto_climatico:
521
+ from core.climate_adapter import gerar_plano_com_clima
522
+ resultado = gerar_plano_com_clima(
523
+ *get_dados(),
389
524
  objetivo=request.objetivo,
390
525
  seed=request.seed,
391
526
  geracoes=request.geracoes,
392
- populacao=request.populacao
393
- )
394
- else:
395
- # Executa AG sem cache para parâmetros customizados
396
- culturas, talhoes, regras = get_dados()
397
- resultado = gerar_plano_genetico(
398
- culturas, talhoes, regras,
399
- objetivo=request.objetivo,
400
- geracoes=request.geracoes,
401
527
  populacao=request.populacao,
402
- seed=request.seed
528
+ lat=request.lat,
529
+ lon=request.lon,
530
+ days=request.days
403
531
  )
532
+ else:
533
+ # Usa AG cacheado se parâmetros forem padrão e sem clima
534
+ if (request.objetivo == "equilibrado" and
535
+ request.seed == 42 and
536
+ request.geracoes == 100 and
537
+ request.populacao == 50):
538
+ resultado = get_ag_cacheado(
539
+ objetivo=request.objetivo,
540
+ seed=request.seed,
541
+ geracoes=request.geracoes,
542
+ populacao=request.populacao
543
+ )
544
+ else:
545
+ # Executa AG sem cache para parâmetros customizados
546
+ culturas, talhoes, regras = get_dados()
547
+ resultado = gerar_plano_genetico(
548
+ culturas, talhoes, regras,
549
+ objetivo=request.objetivo,
550
+ geracoes=request.geracoes,
551
+ populacao=request.populacao,
552
+ seed=request.seed
553
+ )
404
554
 
405
555
  # Converte tipos numpy para Python nativos
406
556
  resultado_convertido = converter_tipos_python(resultado)
@@ -472,7 +622,7 @@ def rodadas(request: RodadasRequest):
472
622
 
473
623
  @app.post("/relatorio")
474
624
  def relatorio(request: RelatorioRequest):
475
- """Gera relatório"""
625
+ """Gera relatório com contexto climático opcional"""
476
626
  try:
477
627
  culturas, talhoes, regras = get_dados()
478
628
 
@@ -486,7 +636,14 @@ def relatorio(request: RelatorioRequest):
486
636
  if request.formato not in formatos_validos:
487
637
  raise HTTPException(status_code=400, detail=f"Formato inválido. Use: {formatos_validos}")
488
638
 
489
- # Gera relatório
639
+ # Obter contexto climático se coordenadas foram fornecidas
640
+ contexto_climatico = None
641
+ if request.lat is not None and request.lon is not None:
642
+ from core.climate_adapter import obter_contexto_climatico_por_coordenadas
643
+ contexto_climatico = obter_contexto_climatico_por_coordenadas(request.lat, request.lon, request.days)
644
+
645
+ # Gera relatório (por enquanto sem integração climática no gerador)
646
+ # TODO: Atualizar report_generator para aceitar contexto climático
490
647
  caminho = gerar_relatorio_completo(
491
648
  culturas, talhoes, regras,
492
649
  objetivo=request.objetivo,
@@ -497,11 +654,48 @@ def relatorio(request: RelatorioRequest):
497
654
  with open(caminho, 'r', encoding='utf-8') as f:
498
655
  conteudo = f.read()
499
656
 
500
- return {
657
+ # Adiciona informações climáticas ao final se disponível
658
+ if contexto_climatico and not contexto_climatico.get("fallback", True):
659
+ clima_info = f"""
660
+
661
+ ## Dados Climáticos Reais
662
+
663
+ **Fonte:** {contexto_climatico.get('fonte', 'N/A')}
664
+ **Temperatura Média:** {contexto_climatico.get('temperatura_media', 'N/A')}°C
665
+ **Precipitação Total:** {contexto_climatico.get('precipitacao_total', 'N/A')}mm
666
+ **Risco Climático:** {contexto_climatico.get('risco_climatico_estimado', 'N/A')}
667
+ **Clima Observado:** {contexto_climatico.get('clima_observado', 'N/A')}
668
+ **Água Observada:** {contexto_climatico.get('agua_observada', 'N/A')}
669
+ **Ajuste de Risco:** {contexto_climatico.get('ajuste_risco', 0):+.1%}
670
+
671
+ *Dados climáticos integrados ao planejamento para maior precisão.*
672
+ """
673
+ conteudo += clima_info
674
+
675
+ resultado = {
501
676
  "caminho": caminho,
502
677
  "conteudo": conteudo,
503
678
  "formato": request.formato
504
679
  }
680
+
681
+ # Adicionar informações de clima real
682
+ if contexto_climatico:
683
+ resultado["clima_real"] = {
684
+ "ativo": True,
685
+ "source": contexto_climatico.get("fonte", "unknown"),
686
+ "temperatura_media": contexto_climatico.get("temperatura_media"),
687
+ "precipitacao_total": contexto_climatico.get("precipitacao_total"),
688
+ "risco_climatico_estimado": contexto_climatico.get("risco_climatico_estimado"),
689
+ "clima_observado": contexto_climatico.get("clima_observado"),
690
+ "agua_observada": contexto_climatico.get("agua_observada"),
691
+ "ajuste_risco": contexto_climatico.get("ajuste_risco", 0),
692
+ "fallback": contexto_climatico.get("fallback", False),
693
+ "error": contexto_climatico.get("error")
694
+ }
695
+ else:
696
+ resultado["clima_real"] = {"ativo": False}
697
+
698
+ return resultado
505
699
  except HTTPException:
506
700
  raise
507
701
  except Exception as e:
@@ -0,0 +1,142 @@
1
+ """
2
+ Adaptador para integrar dados climáticos reais no planejamento agrícola
3
+ """
4
+ from typing import Optional, Dict, Any
5
+ from providers.weather_provider import get_weather_summary
6
+ from providers.types import WeatherSummary
7
+
8
+ def classificar_clima_por_temperatura(temp_media: Optional[float]) -> Optional[str]:
9
+ """Classifica clima baseado na temperatura média"""
10
+ if temp_media is None:
11
+ return None
12
+
13
+ if temp_media >= 28:
14
+ return "quente"
15
+ if temp_media < 18:
16
+ return "frio"
17
+ return "ameno"
18
+
19
+ def classificar_agua_por_precipitacao(precipitacao_total: Optional[float]) -> Optional[str]:
20
+ """Classifica disponibilidade de água baseada na precipitação"""
21
+ if precipitacao_total is None:
22
+ return None
23
+
24
+ if precipitacao_total < 50:
25
+ return "baixa"
26
+ if precipitacao_total > 120:
27
+ return "alta"
28
+ return "media"
29
+
30
+ def calcular_ajuste_risco_climatico(risco_climatico: str) -> float:
31
+ """Calcula ajuste de risco baseado no risco climático estimado"""
32
+ ajustes = {
33
+ "alto": 0.15, # +15% risco
34
+ "medio": 0.05, # +5% risco
35
+ "baixo": -0.03, # -3% risco (leve benefício)
36
+ "indeterminado": 0
37
+ }
38
+ return ajustes.get(risco_climatico, 0)
39
+
40
+ def criar_contexto_climatico(weather_summary: WeatherSummary) -> Dict[str, Any]:
41
+ """Cria contexto climático para uso no planejamento"""
42
+
43
+ clima_observado = classificar_clima_por_temperatura(weather_summary.temperatura_media)
44
+ agua_observada = classificar_agua_por_precipitacao(weather_summary.precipitacao_total)
45
+ ajuste_risco = calcular_ajuste_risco_climatico(weather_summary.risco_climatico_estimado)
46
+
47
+ return {
48
+ "clima_observado": clima_observado,
49
+ "agua_observada": agua_observada,
50
+ "ajuste_risco": ajuste_risco,
51
+ "fonte": weather_summary.source,
52
+ "fallback": weather_summary.fallback,
53
+ "temperatura_media": weather_summary.temperatura_media,
54
+ "temperatura_maxima": weather_summary.temperatura_maxima,
55
+ "temperatura_minima": weather_summary.temperatura_minima,
56
+ "precipitacao_total": weather_summary.precipitacao_total,
57
+ "evapotranspiracao": weather_summary.evapotranspiracao,
58
+ "umidade_media": weather_summary.umidade_media,
59
+ "radiacao_solar": weather_summary.radiacao_solar,
60
+ "risco_climatico_estimado": weather_summary.risco_climatico_estimado,
61
+ "error": weather_summary.error
62
+ }
63
+
64
+ def aplicar_contexto_climatico_no_plano(resultado_ag: Dict[str, Any], contexto_climatico: Dict[str, Any]) -> Dict[str, Any]:
65
+ """Aplica ajustes climáticos no resultado do algoritmo genético"""
66
+
67
+ if not contexto_climatico or contexto_climatico.get("ajuste_risco", 0) == 0:
68
+ # Sem ajuste necessário
69
+ resultado_ag["ajuste_climatico_aplicado"] = False
70
+ resultado_ag["contexto_climatico"] = contexto_climatico
71
+ return resultado_ag
72
+
73
+ ajuste_risco = contexto_climatico["ajuste_risco"]
74
+
75
+ # Aplicar ajuste no plano otimizado
76
+ if "plano_otimizado" in resultado_ag:
77
+ for item in resultado_ag["plano_otimizado"]:
78
+ if "risco" in item:
79
+ risco_original = item["risco"]
80
+ # Aplicar ajuste mantendo risco entre 0.05 e 0.95
81
+ novo_risco = min(0.95, max(0.05, risco_original + ajuste_risco))
82
+ item["risco"] = round(novo_risco, 3)
83
+ item["risco_original"] = risco_original
84
+ item["ajuste_aplicado"] = ajuste_risco
85
+
86
+ # Recalcular métricas gerais se existirem
87
+ if "metricas" in resultado_ag and "risco_medio" in resultado_ag["metricas"]:
88
+ risco_original = resultado_ag["metricas"]["risco_medio"]
89
+ novo_risco = min(0.95, max(0.05, risco_original + ajuste_risco))
90
+ resultado_ag["metricas"]["risco_medio"] = round(novo_risco, 3)
91
+ resultado_ag["metricas"]["risco_medio_original"] = risco_original
92
+
93
+ # Marcar que ajuste foi aplicado
94
+ resultado_ag["ajuste_climatico_aplicado"] = True
95
+ resultado_ag["contexto_climatico"] = contexto_climatico
96
+
97
+ return resultado_ag
98
+
99
+ def obter_contexto_climatico_por_coordenadas(lat: float, lon: float, days: int = 30) -> Optional[Dict[str, Any]]:
100
+ """Obtém contexto climático para coordenadas específicas"""
101
+ try:
102
+ weather_summary = get_weather_summary(lat, lon, days)
103
+ return criar_contexto_climatico(weather_summary)
104
+ except Exception as e:
105
+ # Em caso de erro, retornar contexto vazio
106
+ return {
107
+ "clima_observado": None,
108
+ "agua_observada": None,
109
+ "ajuste_risco": 0,
110
+ "fonte": "erro",
111
+ "fallback": True,
112
+ "error": str(e),
113
+ "temperatura_media": None,
114
+ "precipitacao_total": None,
115
+ "risco_climatico_estimado": "indeterminado"
116
+ }
117
+
118
+ def gerar_plano_com_clima(culturas, talhoes, regras, objetivo="equilibrado", seed=42,
119
+ geracoes=100, populacao=50, lat=None, lon=None, days=30):
120
+ """
121
+ Wrapper para gerar plano genético com contexto climático opcional
122
+ """
123
+ from .planner import gerar_plano_genetico
124
+
125
+ # Gerar plano normalmente
126
+ resultado = gerar_plano_genetico(
127
+ culturas, talhoes, regras,
128
+ objetivo=objetivo, seed=seed,
129
+ geracoes=geracoes, populacao=populacao
130
+ )
131
+
132
+ # Se coordenadas foram fornecidas, aplicar contexto climático
133
+ if lat is not None and lon is not None:
134
+ contexto_climatico = obter_contexto_climatico_por_coordenadas(lat, lon, days)
135
+ if contexto_climatico:
136
+ resultado = aplicar_contexto_climatico_no_plano(resultado, contexto_climatico)
137
+ else:
138
+ # Marcar que não há clima real aplicado
139
+ resultado["ajuste_climatico_aplicado"] = False
140
+ resultado["contexto_climatico"] = None
141
+
142
+ return resultado
@@ -0,0 +1 @@
1
+ # Provedores de dados reais para AgroPlan AI
@@ -0,0 +1,49 @@
1
+ """
2
+ Cache simples em memória para chamadas de APIs externas
3
+ """
4
+ import time
5
+ from typing import Any, Optional
6
+
7
+ # Cache global em memória
8
+ _cache = {}
9
+
10
+ def get_cache(key: str) -> Optional[Any]:
11
+ """Recupera valor do cache se ainda válido"""
12
+ if key not in _cache:
13
+ return None
14
+
15
+ value, expiry = _cache[key]
16
+ if time.time() > expiry:
17
+ # Cache expirado, remove
18
+ del _cache[key]
19
+ return None
20
+
21
+ return value
22
+
23
+ def set_cache(key: str, value: Any, ttl_seconds: int = 3600) -> None:
24
+ """Armazena valor no cache com TTL"""
25
+ expiry = time.time() + ttl_seconds
26
+ _cache[key] = (value, expiry)
27
+
28
+ def clear_provider_cache() -> None:
29
+ """Limpa todo o cache de provedores"""
30
+ global _cache
31
+ _cache = {}
32
+
33
+ def get_cache_stats() -> dict:
34
+ """Retorna estatísticas do cache"""
35
+ current_time = time.time()
36
+ valid_items = 0
37
+ expired_items = 0
38
+
39
+ for key, (value, expiry) in _cache.items():
40
+ if current_time <= expiry:
41
+ valid_items += 1
42
+ else:
43
+ expired_items += 1
44
+
45
+ return {
46
+ "total_items": len(_cache),
47
+ "valid_items": valid_items,
48
+ "expired_items": expired_items
49
+ }
@@ -0,0 +1,40 @@
1
+ """
2
+ Tipos de dados para provedores externos
3
+ """
4
+ from typing import Optional
5
+ from dataclasses import dataclass
6
+
7
+ @dataclass
8
+ class WeatherSummary:
9
+ """Resumo de dados climáticos"""
10
+ source: str
11
+ latitude: float
12
+ longitude: float
13
+ temperatura_media: Optional[float] = None
14
+ temperatura_maxima: Optional[float] = None
15
+ temperatura_minima: Optional[float] = None
16
+ precipitacao_total: Optional[float] = None
17
+ evapotranspiracao: Optional[float] = None
18
+ umidade_media: Optional[float] = None
19
+ radiacao_solar: Optional[float] = None
20
+ risco_climatico_estimado: str = "indeterminado"
21
+ fallback: bool = False
22
+ error: Optional[str] = None
23
+
24
+ def to_dict(self) -> dict:
25
+ """Converte para dicionário para JSON"""
26
+ return {
27
+ "source": self.source,
28
+ "latitude": self.latitude,
29
+ "longitude": self.longitude,
30
+ "temperatura_media": self.temperatura_media,
31
+ "temperatura_maxima": self.temperatura_maxima,
32
+ "temperatura_minima": self.temperatura_minima,
33
+ "precipitacao_total": self.precipitacao_total,
34
+ "evapotranspiracao": self.evapotranspiracao,
35
+ "umidade_media": self.umidade_media,
36
+ "radiacao_solar": self.radiacao_solar,
37
+ "risco_climatico_estimado": self.risco_climatico_estimado,
38
+ "fallback": self.fallback,
39
+ "error": self.error
40
+ }
@@ -0,0 +1,161 @@
1
+ """
2
+ Provedor de dados climáticos usando Open-Meteo
3
+ """
4
+ import urllib.request
5
+ import urllib.parse
6
+ import json
7
+ from datetime import datetime, timedelta
8
+ from typing import Optional
9
+ from .types import WeatherSummary
10
+ from .cache import get_cache, set_cache
11
+
12
+ def estimar_risco_climatico(temp_media: Optional[float], precipitacao_total: Optional[float]) -> str:
13
+ """Estima risco climático baseado em temperatura e precipitação"""
14
+ if temp_media is None or precipitacao_total is None:
15
+ return "indeterminado"
16
+
17
+ # Heurística simples inicial
18
+ if precipitacao_total < 30:
19
+ return "alto"
20
+ if temp_media > 34:
21
+ return "alto"
22
+ if precipitacao_total < 70:
23
+ return "medio"
24
+ return "baixo"
25
+
26
+ def get_weather_summary(lat: float, lon: float, days: int = 30) -> WeatherSummary:
27
+ """
28
+ Obtém resumo climático usando Open-Meteo
29
+
30
+ Args:
31
+ lat: Latitude
32
+ lon: Longitude
33
+ days: Número de dias para análise (padrão 30)
34
+
35
+ Returns:
36
+ WeatherSummary com dados climáticos ou fallback
37
+ """
38
+
39
+ # Chave do cache
40
+ cache_key = f"weather_{lat}_{lon}_{days}"
41
+
42
+ # Verificar cache primeiro
43
+ cached = get_cache(cache_key)
44
+ if cached:
45
+ return WeatherSummary(**cached)
46
+
47
+ try:
48
+ # Calcular datas (últimos N dias)
49
+ end_date = datetime.now().date()
50
+ start_date = end_date - timedelta(days=days)
51
+
52
+ # Parâmetros da API Open-Meteo Archive
53
+ params = {
54
+ "latitude": str(lat),
55
+ "longitude": str(lon),
56
+ "start_date": start_date.strftime("%Y-%m-%d"),
57
+ "end_date": end_date.strftime("%Y-%m-%d"),
58
+ "daily": ",".join([
59
+ "temperature_2m_mean",
60
+ "temperature_2m_max",
61
+ "temperature_2m_min",
62
+ "precipitation_sum",
63
+ "et0_fao_evapotranspiration",
64
+ "relative_humidity_2m_mean",
65
+ "shortwave_radiation_sum"
66
+ ]),
67
+ "timezone": "America/Sao_Paulo"
68
+ }
69
+
70
+ # Construir URL
71
+ base_url = "https://archive-api.open-meteo.com/v1/archive"
72
+ query_string = urllib.parse.urlencode(params)
73
+ url = f"{base_url}?{query_string}"
74
+
75
+ # Fazer requisição
76
+ with urllib.request.urlopen(url, timeout=10) as response:
77
+ data = json.loads(response.read().decode())
78
+
79
+ daily = data.get("daily", {})
80
+
81
+ # Extrair dados
82
+ temps_mean = daily.get("temperature_2m_mean", [])
83
+ temps_max = daily.get("temperature_2m_max", [])
84
+ temps_min = daily.get("temperature_2m_min", [])
85
+ precipitation = daily.get("precipitation_sum", [])
86
+ evapotranspiration = daily.get("et0_fao_evapotranspiration", [])
87
+ humidity = daily.get("relative_humidity_2m_mean", [])
88
+ radiation = daily.get("shortwave_radiation_sum", [])
89
+
90
+ # Calcular médias (ignorando valores None)
91
+ def safe_mean(values):
92
+ valid_values = [v for v in values if v is not None]
93
+ return sum(valid_values) / len(valid_values) if valid_values else None
94
+
95
+ def safe_sum(values):
96
+ valid_values = [v for v in values if v is not None]
97
+ return sum(valid_values) if valid_values else None
98
+
99
+ temp_media = safe_mean(temps_mean)
100
+ temp_maxima = safe_mean(temps_max)
101
+ temp_minima = safe_mean(temps_min)
102
+ precip_total = safe_sum(precipitation)
103
+ evap_media = safe_mean(evapotranspiration)
104
+ umid_media = safe_mean(humidity)
105
+ rad_total = safe_sum(radiation)
106
+
107
+ # Estimar risco climático
108
+ risco = estimar_risco_climatico(temp_media, precip_total)
109
+
110
+ # Criar resultado
111
+ result = WeatherSummary(
112
+ source="open-meteo",
113
+ latitude=lat,
114
+ longitude=lon,
115
+ temperatura_media=round(temp_media, 1) if temp_media else None,
116
+ temperatura_maxima=round(temp_maxima, 1) if temp_maxima else None,
117
+ temperatura_minima=round(temp_minima, 1) if temp_minima else None,
118
+ precipitacao_total=round(precip_total, 1) if precip_total else None,
119
+ evapotranspiracao=round(evap_media, 2) if evap_media else None,
120
+ umidade_media=round(umid_media, 1) if umid_media else None,
121
+ radiacao_solar=round(rad_total, 1) if rad_total else None,
122
+ risco_climatico_estimado=risco,
123
+ fallback=False,
124
+ error=None
125
+ )
126
+
127
+ # Cachear por 1 hora (dados históricos mudam pouco)
128
+ set_cache(cache_key, result.to_dict(), ttl_seconds=3600)
129
+
130
+ return result
131
+
132
+ except Exception as e:
133
+ # Fallback para dados simulados
134
+ return _get_fallback_weather(lat, lon, str(e))
135
+
136
+ def _get_fallback_weather(lat: float, lon: float, error: str) -> WeatherSummary:
137
+ """Retorna dados climáticos simulados como fallback"""
138
+
139
+ # Dados simulados baseados na localização (heurística simples)
140
+ # Região Sudeste do Brasil como referência
141
+ temp_base = 22.0
142
+ if lat < -25: # Mais ao sul, mais frio
143
+ temp_base = 18.0
144
+ elif lat > -20: # Mais ao norte, mais quente
145
+ temp_base = 26.0
146
+
147
+ return WeatherSummary(
148
+ source="simulado",
149
+ latitude=lat,
150
+ longitude=lon,
151
+ temperatura_media=temp_base,
152
+ temperatura_maxima=temp_base + 8,
153
+ temperatura_minima=temp_base - 6,
154
+ precipitacao_total=120.0, # mm/mês típico
155
+ evapotranspiracao=4.5, # mm/dia típico
156
+ umidade_media=65.0, # % típico
157
+ radiacao_solar=180.0, # MJ/m² típico mensal
158
+ risco_climatico_estimado="medio",
159
+ fallback=True,
160
+ error=f"Open-Meteo indisponível: {error}"
161
+ )
package/dist/index.js CHANGED
@@ -779,7 +779,7 @@ var COMMANDS = {
779
779
  open: "Abre o AgroPlan AI no navegador"
780
780
  };
781
781
  function showHelp() {
782
- console.log("\uD83C\uDF31 AgroPlan AI - CLI Local v1.0.7");
782
+ console.log("\uD83C\uDF31 AgroPlan AI - CLI Local v1.0.8");
783
783
  console.log(` Launcher para modo local acelerado
784
784
  `);
785
785
  console.log("\uD83D\uDCCB Comandos dispon\xEDveis:");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agroplan-ai-cli",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "CLI global para AgroPlan AI - modo local acelerado",
5
5
  "type": "module",
6
6
  "bin": {