MMOON commited on
Commit
f618ea1
·
verified ·
1 Parent(s): c0fbdb9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +189 -280
app.py CHANGED
@@ -9,9 +9,9 @@ import requests
9
  import plotly.express as px
10
  import streamlit as st
11
  from tenacity import retry, stop_after_attempt, wait_exponential
12
- import time
13
  from collections import defaultdict
14
- import hashlib
15
 
16
  # Configuration Streamlit
17
  st.set_page_config(page_title="Pesticide Data Explorer - Optimized", page_icon="🌿", layout="wide")
@@ -34,7 +34,7 @@ class PesticideDataFetcher:
34
  HEADERS = {
35
  "Content-Type": "application/json",
36
  "Cache-Control": "no-cache",
37
- "User-Agent": "Mozilla/5.0"
38
  }
39
 
40
  def __init__(self):
@@ -49,39 +49,32 @@ class PesticideDataFetcher:
49
 
50
  try:
51
  self.api_calls += 1
52
- logger.info(f"Téléchargement depuis {endpoint} (appel API #{self.api_calls})")
53
 
54
- response = self.session.get(url, params=params, timeout=120) # Timeout plus long pour les gros fichiers
55
  response.raise_for_status()
56
 
57
  content_type = response.headers.get('Content-Type', '')
58
 
59
- # Si c'est du JSON
60
  if 'json' in content_type or params.get('format') == 'json':
61
  return response.json()
62
-
63
- # Si c'est du CSV
64
  elif 'csv' in content_type or params.get('format') == 'csv':
65
  return response.text
66
-
67
- # Si c'est un fichier ZIP (possible pour les gros datasets)
68
  elif 'zip' in content_type:
69
  with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
70
- # Prendre le premier fichier du ZIP
71
  filename = zf.namelist()[0]
72
  with zf.open(filename) as f:
73
  content = f.read().decode('utf-8')
74
  if filename.endswith('.json'):
75
  return json.loads(content)
76
- else:
77
  return content
78
-
79
  else:
80
- # Par défaut, retourner le contenu brut
81
  return response.text
82
 
83
  except requests.RequestException as e:
84
- logger.error(f"Erreur lors du téléchargement {endpoint}: {e}")
85
  if hasattr(e, 'response') and e.response is not None:
86
  logger.error(f"Status code: {e.response.status_code}")
87
  logger.error(f"Response: {e.response.text[:500]}...")
@@ -97,87 +90,75 @@ class PesticideDataFetcher:
97
  'api-version': 'v2.0'
98
  }
99
 
100
- # Première requête pour voir s'il y a pagination
101
- self.api_calls += 1
102
- response = self.session.get(url, params=params, timeout=30)
103
- response.raise_for_status()
104
- data = response.json()
105
-
106
- if 'value' in data:
107
- all_products.extend(data['value'])
108
 
109
- # Gérer la pagination avec nextLink si présent
110
- while 'nextLink' in data and self.api_calls < 10: # Limite de sécurité
111
- self.api_calls += 1
112
- response = self.session.get(data['nextLink'], timeout=30)
113
- response.raise_for_status()
114
- data = response.json()
115
- if 'value' in data:
116
- all_products.extend(data['value'])
117
- else:
118
- # Pas de structure 'value', c'est directement la liste
119
- all_products = data if isinstance(data, list) else [data]
120
-
121
- logger.info(f"Récupéré {len(all_products)} produits en {self.api_calls} appels")
 
 
122
  return all_products
123
 
124
  @st.cache_data(ttl=86400) # Cache de 24h pour les données bulk
125
  def download_all_data() -> Dict[str, Any]:
126
  """Télécharge toutes les données en utilisant les endpoints optimisés"""
127
-
128
  fetcher = PesticideDataFetcher()
129
  results = {}
130
 
131
  with st.spinner("Téléchargement des données complètes..."):
132
-
133
- # 1. Télécharger toutes les substances actives
134
  st.text("📥 Téléchargement des substances actives...")
135
  substances_data = fetcher.download_data(
136
  "/active_substances/download",
137
  {"format": "json", "api-version": "v2.0"}
138
  )
139
-
140
  if substances_data:
141
- # Convertir en dictionnaire pour accès rapide
142
- if isinstance(substances_data, dict) and 'value' in substances_data:
143
- substances_list = substances_data['value']
144
- else:
145
- substances_list = substances_data if isinstance(substances_data, list) else []
146
-
147
  results['substances'] = {
148
  item['substance_id']: item['substance_name']
149
- for item in substances_list
150
- if item.get('substance_id') and item.get('substance_name')
151
  }
152
  logger.info(f"✓ {len(results['substances'])} substances téléchargées")
153
-
154
- # 2. Télécharger toutes les LMR
155
- st.text("📥 Téléchargement de toutes les LMR (peut prendre quelques secondes)...")
 
 
156
  mrls_data = fetcher.download_data(
157
  "/pesticide_residues_mrls/download",
158
  {"format": "json", "language": "FR", "api-version": "v2.0"}
159
  )
160
-
161
  if mrls_data:
162
- if isinstance(mrls_data, dict) and 'value' in mrls_data:
163
- results['mrls'] = mrls_data['value']
164
- else:
165
- results['mrls'] = mrls_data if isinstance(mrls_data, list) else []
166
- logger.info(f" {len(results['mrls'])} LMR téléchargées")
167
-
168
- # 3. Récupérer tous les produits (avec pagination si nécessaire)
169
  st.text("📥 Récupération des produits...")
170
  products_list = fetcher.get_products_paginated(language='FR')
171
-
172
  results['products'] = products_list
173
  results['product_dict'] = {
174
  p['product_id']: p['product_name']
175
- for p in products_list
176
- if p.get('product_id') and p.get('product_name')
177
  }
178
  logger.info(f"✓ {len(results['products'])} produits récupérés")
179
 
180
- # 4. Statistiques
181
  results['stats'] = {
182
  'api_calls': fetcher.api_calls,
183
  'substances_count': len(results.get('substances', {})),
@@ -185,359 +166,287 @@ def download_all_data() -> Dict[str, Any]:
185
  'products_count': len(results.get('products', [])),
186
  'download_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
187
  }
188
-
189
- st.success(f"✅ Toutes les données téléchargées en seulement {fetcher.api_calls} appels API!")
190
-
191
  return results
192
 
193
  class PesticideInterface:
194
  def __init__(self):
195
- # Charger toutes les données une seule fois
196
  self.data = download_all_data()
197
-
198
- # Créer des index pour des recherches rapides
199
  self._create_indexes()
200
 
201
  def _create_indexes(self):
202
- """Crée des index pour optimiser les recherches"""
203
- # Index des LMR par product_id
204
  self.mrls_by_product = defaultdict(list)
205
  for mrl in self.data.get('mrls', []):
206
  if mrl.get('product_id'):
207
  self.mrls_by_product[mrl['product_id']].append(mrl)
208
 
209
- # Index des produits par nom
210
  self.product_choices = {
211
  p['product_name']: p['product_id']
212
- for p in self.data.get('products', [])
213
- if p.get('product_name') and p.get('product_id')
214
  }
215
-
216
- logger.info(f"Index créés: {len(self.mrls_by_product)} produits avec LMR")
217
 
218
  def get_product_details(self, product_names: List[str], future_only: bool = False) -> pd.DataFrame:
219
- """Récupère les détails des produits depuis les données en cache"""
220
-
221
- # Convertir les noms en IDs
222
- product_ids = [self.product_choices[name] for name in product_names]
223
 
224
- # Récupérer les LMR depuis l'index
225
  all_mrls = []
226
  for product_id in product_ids:
227
- mrls = self.mrls_by_product.get(product_id, [])
228
- all_mrls.extend(mrls)
229
 
230
  if not all_mrls:
231
- st.info("Aucune donnée de LMR trouvée pour les produits sélectionnés.")
232
  return pd.DataFrame()
233
 
234
- # Convertir en DataFrame
235
  df = pd.DataFrame(all_mrls)
236
 
237
- # Enrichir avec les données
238
  df["Substance"] = df["pesticide_residue_id"].map(self.data.get('substances', {})).fillna("Inconnu")
239
  df["Produit"] = df["product_id"].map(self.data.get('product_dict', {})).fillna("Inconnu")
240
 
241
- # Ajouter le lien vers le règlement
242
- if 'regulation_url' in df.columns:
243
- df["Règlement"] = df.apply(
244
- lambda x: f'<a href="{x["regulation_url"]}" target="_blank">{x.get("regulation_number", "N/A")}</a>'
245
- if pd.notna(x.get("regulation_url")) else x.get("regulation_number", "N/A"),
246
- axis=1
247
- )
248
- else:
249
- df["Règlement"] = df.get("regulation_number", "N/A")
250
 
251
- # Conversion des dates
252
  df["Date d'application"] = pd.to_datetime(df.get("entry_into_force_date"), errors="coerce")
253
 
254
- # Filtrer pour les 6 prochains mois si demandé
255
  if future_only:
256
- now = datetime.now()
257
  future_date = now + timedelta(days=180)
 
 
 
 
258
  df = df[
 
259
  (df["Date d'application"] > now) &
260
  (df["Date d'application"] <= future_date)
261
  ]
262
-
263
  if df.empty:
264
- st.info(f"🔍 Aucun changement de LMR prévu dans les 6 prochains mois.")
265
  return pd.DataFrame()
266
 
267
- # Préparer le DataFrame final
268
- # C'est ici que les NaNs peuvent apparaître si mrl_value n'est pas numérique
269
  df["Valeur LMR"] = pd.to_numeric(df.get("mrl_value"), errors='coerce')
270
 
271
- # Sélection des colonnes finales
272
- columns_to_keep = ["Produit", "Substance", "Valeur LMR"]
273
- if "Date d'application" in df.columns:
274
- columns_to_keep.append("Date d'application")
275
- if "Règlement" in df.columns:
276
- columns_to_keep.append("Règlement")
277
 
278
- # S'assurer que toutes les colonnes à garder existent avant de les sélectionner
279
- # Utilisez .copy() pour éviter SettingWithCopyWarning
280
- df = df[[col for col in columns_to_keep if col in df.columns]].copy()
281
 
282
- df = df.sort_values(
283
- ["Produit", "Date d'application"] if "Date d'application" in columns_to_keep else ["Produit"],
284
- ascending=[True, False] if "Date d'application" in columns_to_keep else [True]
285
- )
 
 
 
286
 
287
  return df
288
 
289
  def create_interface(self):
290
  st.title("🌿 EU Pesticides Database Explorer - Version Optimisée")
291
 
292
- # Afficher les statistiques
293
  col1, col2, col3, col4 = st.columns(4)
294
  with col1:
295
- st.metric("📦 Produits", f"{self.data['stats']['products_count']:,}")
296
  with col2:
297
- st.metric("🧪 Substances", f"{self.data['stats']['substances_count']:,}")
298
  with col3:
299
- st.metric("📊 LMR totales", f"{self.data['stats']['mrls_count']:,}")
300
  with col4:
301
- st.metric("🚀 Appels API", self.data['stats']['api_calls'])
302
-
303
- st.success(f"✨ Toutes les données ont été téléchargées en {self.data['stats']['api_calls']} appels API seulement!")
304
 
 
305
  st.markdown("---")
306
 
307
- # Interface de sélection
308
- col1, col2 = st.columns([3, 1])
309
- with col1:
310
- # Recherche avec autocomplétion
311
  product_names = st.multiselect(
312
  "🔍 Sélectionnez un ou plusieurs produits",
313
  options=sorted(list(self.product_choices.keys())),
314
  help="Commencez à taper pour filtrer les produits"
315
  )
316
-
317
- with col2:
318
  future_only = st.checkbox(
319
- "📅 6 prochains mois",
320
  value=False,
321
- help="Afficher uniquement les changements prévus"
322
  )
323
 
324
- # Affichage des résultats
325
  if product_names:
326
- df = self.get_product_details(product_names, future_only)
327
 
328
- if not df.empty:
 
 
 
 
 
329
  st.markdown("### 📊 Résultats")
330
-
331
- # Statistiques rapides
332
- # Filtrer les NaNs pour les calculs de statistiques
333
- df_numeric_mrl = df[df["Valeur LMR"].notna()]
334
 
335
- col1, col2, col3 = st.columns(3)
336
- with col1:
337
- st.metric("Entrées trouvées", len(df))
338
- with col2:
339
  st.metric("Substances uniques", df_numeric_mrl["Substance"].nunique() if not df_numeric_mrl.empty else 0)
340
- with col3:
341
- if "Valeur LMR" in df_numeric_mrl.columns:
342
- avg_mrl = df_numeric_mrl["Valeur LMR"].mean()
343
- st.metric("LMR moyenne", f"{avg_mrl:.3f} mg/kg" if not pd.isna(avg_mrl) else "N/A")
344
 
345
- # Options d'affichage
346
  with st.expander("⚙️ Options d'affichage"):
347
- show_zero = st.checkbox("Afficher les LMR à 0.01 mg/kg", value=True)
348
- sort_by = st.selectbox(
349
- "Trier par",
350
- ["Produit", "Substance", "Valeur LMR", "Date d'application"]
351
- if "Date d'application" in df.columns else ["Produit", "Substance", "Valeur LMR"]
352
- )
353
- sort_order = st.radio("Ordre", ["Croissant", "Décroissant"], horizontal=True)
354
-
355
- # Appliquer les filtres
356
- if not show_zero and "Valeur LMR" in df.columns:
357
- df = df[df["Valeur LMR"] != 0.01]
 
 
358
 
359
- if sort_by in df.columns:
360
- df = df.sort_values(sort_by, ascending=(sort_order == "Croissant"))
361
 
362
- # Afficher le tableau
363
  st.dataframe(
364
- df,
365
  use_container_width=True,
366
  hide_index=True,
367
  column_config={
368
  "Valeur LMR": st.column_config.NumberColumn(
369
- "Valeur LMR (mg/kg)",
370
- format="%.3f",
371
- help="Limite Maximale de Résidus"
372
  ),
373
  "Date d'application": st.column_config.DateColumn(
374
- "Date d'application",
375
- format="DD/MM/YYYY"
376
  ),
377
- "Règlement": st.column_config.LinkColumn(
378
- "Règlement",
379
- help="Cliquez pour voir le règlement officiel"
380
- )
381
  }
382
  )
383
 
384
- # Visualisations
385
- # Condition pour s'assurer qu'il y a assez de données pour visualiser
386
- if len(df) > 1:
387
- self.create_visualizations(df)
388
- else:
389
- st.info("Sélectionnez plus de données (au moins 2 entrées) pour générer des visualisations.")
390
 
391
- # Export
392
- csv = df.to_csv(index=False)
393
  st.download_button(
394
- label="📥 Télécharger (CSV)",
395
  data=csv,
396
- file_name=f"pesticides_lmr_{datetime.now().strftime('%Y%m%d')}.csv",
397
  mime="text/csv"
398
  )
399
  else:
400
- # Afficher quelques statistiques globales
401
- st.info("👆 Sélectionnez des produits pour voir leurs LMR")
402
-
403
- with st.expander("📊 Statistiques globales"):
404
- # Top 10 des produits avec le plus de LMR
405
- product_mrl_count = {
406
- pid: len(mrls)
407
- for pid, mrls in self.mrls_by_product.items()
408
  }
409
- top_products = sorted(
410
- product_mrl_count.items(),
411
- key=lambda x: x[1],
412
- reverse=True
413
- )[:10]
414
 
415
- if top_products:
416
- st.markdown("**Top 10 des produits avec le plus de LMR:**")
417
- for pid, count in top_products:
418
  product_name = self.data['product_dict'].get(pid, f"ID: {pid}")
419
- st.write(f"- {product_name}: {count} LMR")
420
 
421
  def create_visualizations(self, df: pd.DataFrame):
422
- """Crée des visualisations interactives"""
423
-
424
- tabs = st.tabs(["📈 Évolution temporelle", "📊 Distribution", "🏆 Top substances"])
425
-
426
- # Crée un DataFrame filtré pour les visualisations,
427
- # en s'assurant que 'Valeur LMR' n'est pas NaN.
428
- # Cela résout le problème de l'erreur 'size' et d'autres opérations numériques.
429
  plot_df = df[df["Valeur LMR"].notna()].copy()
430
 
431
  if plot_df.empty:
432
- st.info("Pas de données valides pour la visualisation après filtrage (valeurs LMR manquantes ou non numériques).")
433
  return
434
 
435
  with tabs[0]:
436
- # Filtrer davantage pour les données temporelles si nécessaire
437
  if "Date d'application" in plot_df.columns and plot_df["Date d'application"].notna().any():
438
  temp_plot_df = plot_df[plot_df["Date d'application"].notna()]
439
  if not temp_plot_df.empty:
440
  fig = px.scatter(
441
- temp_plot_df,
442
- x="Date d'application",
443
- y="Valeur LMR",
444
- color="Substance",
445
- size="Valeur LMR", # 'Valeur LMR' est maintenant garantie sans NaNs
446
- hover_data=["Produit", "Valeur LMR", "Substance", "Règlement"], # Plus de détails au survol
447
- title="Évolution des LMR dans le temps",
448
- log_y=True
449
  )
450
  st.plotly_chart(fig, use_container_width=True)
451
  else:
452
- st.info("Pas de données temporelles valides disponibles pour cette visualisation après filtrage.")
453
  else:
454
- st.info("Pas de données temporelles disponibles pour cette visualisation.")
455
 
456
  with tabs[1]:
457
- # Histogramme des valeurs LMR
458
- fig = px.histogram(
459
- plot_df, # Utiliser le plot_df déjà filtré
460
- x="Valeur LMR",
461
- nbins=50,
462
- title="Distribution des valeurs LMR",
463
- log_x=True,
464
- labels={"count": "Nombre d'occurrences"}
465
  )
466
- st.plotly_chart(fig, use_container_width=True)
467
 
468
- # Box plot par produit si plusieurs produits
469
  if plot_df["Produit"].nunique() > 1:
470
- fig2 = px.box(
471
- plot_df, # Utiliser le plot_df déjà filtré
472
- x="Produit",
473
- y="Valeur LMR",
474
- title="Distribution des LMR par produit",
475
- log_y=True
476
  )
477
- st.plotly_chart(fig2, use_container_width=True)
478
 
479
  with tabs[2]:
480
- # Top substances par valeur maximale
481
- # La colonne 'Valeur LMR' est déjà sans NaN dans plot_df
482
- top_substances = (
483
- plot_df.groupby("Substance")["Valeur LMR"]
484
- .agg(['max', 'count', 'mean'])
485
- .sort_values('max', ascending=False)
486
- .head(15)
487
- )
488
-
489
- if not top_substances.empty:
490
- fig = px.bar(
491
- x=top_substances['max'].values,
492
- y=top_substances.index,
493
- orientation='h',
494
- title="Top 15 des substances par LMR maximale",
495
- labels={'x': 'LMR maximale (mg/kg)', 'y': 'Substance'},
496
- hover_data={
497
- 'Occurrences': top_substances['count'].values,
498
- 'Moyenne': top_substances['mean'].round(3).values
499
- }
500
  )
501
- st.plotly_chart(fig, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
502
  else:
503
- st.info("Pas assez de données pour afficher le Top substances.")
 
504
 
505
  def main():
506
- # Configuration de la sidebar
507
  with st.sidebar:
508
  st.markdown("## 🌿 EU Pesticides Explorer")
509
  st.markdown("### Version Ultra-Optimisée")
510
-
511
  st.markdown("""
512
- Cette version utilise les **endpoints de téléchargement bulk**
513
- pour récupérer toutes les données en seulement **3-4 appels API** !
514
-
515
- **Avantages :**
516
- - ✅ Pas de limitation à 100 appels
517
- - ✅ Accès à TOUTES les données
518
- - ✅ Performance maximale
519
- - ✅ Cache de 24h
520
-
521
- **Données disponibles :**
522
- - Tous les produits alimentaires
523
- - Toutes les substances actives
524
- - Toutes les LMR (>100,000 entrées)
525
  """)
526
-
527
- if st.button("🔄 Forcer le rechargement des données"):
528
  st.cache_data.clear()
529
  st.rerun()
530
 
531
  st.markdown("---")
532
-
533
- # Afficher l'heure du dernier téléchargement
534
- data = download_all_data()
535
- if 'download_time' in data.get('stats', {}):
536
- st.caption(f"Dernière mise à jour : {data['stats']['download_time']}")
 
 
 
 
 
 
537
 
538
- # Interface principale
539
- interface = PesticideInterface()
540
- interface.create_interface()
 
 
 
 
541
 
542
  if __name__ == "__main__":
543
  main()
 
9
  import plotly.express as px
10
  import streamlit as st
11
  from tenacity import retry, stop_after_attempt, wait_exponential
12
+ # import time # Non utilisé directement, peut être enlevé si non requis par une dépendance cachée
13
  from collections import defaultdict
14
+ # import hashlib # Non utilisé directement
15
 
16
  # Configuration Streamlit
17
  st.set_page_config(page_title="Pesticide Data Explorer - Optimized", page_icon="🌿", layout="wide")
 
34
  HEADERS = {
35
  "Content-Type": "application/json",
36
  "Cache-Control": "no-cache",
37
+ "User-Agent": "Mozilla/5.0" # Bon réflexe d'avoir un User-Agent
38
  }
39
 
40
  def __init__(self):
 
49
 
50
  try:
51
  self.api_calls += 1
52
+ logger.info(f"Téléchargement depuis {url} avec params {params} (appel API #{self.api_calls})")
53
 
54
+ response = self.session.get(url, params=params, timeout=120)
55
  response.raise_for_status()
56
 
57
  content_type = response.headers.get('Content-Type', '')
58
 
 
59
  if 'json' in content_type or params.get('format') == 'json':
60
  return response.json()
 
 
61
  elif 'csv' in content_type or params.get('format') == 'csv':
62
  return response.text
 
 
63
  elif 'zip' in content_type:
64
  with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
 
65
  filename = zf.namelist()[0]
66
  with zf.open(filename) as f:
67
  content = f.read().decode('utf-8')
68
  if filename.endswith('.json'):
69
  return json.loads(content)
70
+ else: # Supposons CSV si ce n'est pas JSON dans un ZIP
71
  return content
 
72
  else:
73
+ logger.warning(f"Type de contenu non géré explicitement: {content_type}. Retour du texte brut.")
74
  return response.text
75
 
76
  except requests.RequestException as e:
77
+ logger.error(f"Erreur lors du téléchargement {url}: {e}")
78
  if hasattr(e, 'response') and e.response is not None:
79
  logger.error(f"Status code: {e.response.status_code}")
80
  logger.error(f"Response: {e.response.text[:500]}...")
 
90
  'api-version': 'v2.0'
91
  }
92
 
93
+ current_url = url # Pour gérer le nextLink
94
+ page_count = 0
95
+
96
+ while current_url and self.api_calls < 20 : # Limite de sécurité augmentée légèrement si besoin
97
+ self.api_calls += 1
98
+ page_count += 1
99
+ logger.info(f"Récupération produits - Page {page_count} depuis {current_url} (appel API global #{self.api_calls})")
 
100
 
101
+ # Utiliser params uniquement pour la première requête
102
+ current_params = params if page_count == 1 else {'api-version': 'v2.0', 'language': language}
103
+
104
+ response = self.session.get(current_url, params=current_params if page_count == 1 else None, timeout=30) # Params seulement pour la 1ère
105
+ response.raise_for_status()
106
+ data = response.json()
107
+
108
+ if 'value' in data:
109
+ all_products.extend(data['value'])
110
+ current_url = data.get('nextLink') # Mise à jour de l'URL pour la prochaine itération
111
+ else: # Cas la réponse n'a pas de 'value' (par ex. si la première page est la seule)
112
+ all_products = data if isinstance(data, list) else [data]
113
+ current_url = None # Pas de pagination
114
+
115
+ logger.info(f"Récupéré {len(all_products)} produits en {page_count} appels paginés (total API: {self.api_calls})")
116
  return all_products
117
 
118
  @st.cache_data(ttl=86400) # Cache de 24h pour les données bulk
119
  def download_all_data() -> Dict[str, Any]:
120
  """Télécharge toutes les données en utilisant les endpoints optimisés"""
 
121
  fetcher = PesticideDataFetcher()
122
  results = {}
123
 
124
  with st.spinner("Téléchargement des données complètes..."):
 
 
125
  st.text("📥 Téléchargement des substances actives...")
126
  substances_data = fetcher.download_data(
127
  "/active_substances/download",
128
  {"format": "json", "api-version": "v2.0"}
129
  )
 
130
  if substances_data:
131
+ substances_list = substances_data.get('value', []) if isinstance(substances_data, dict) else (substances_data if isinstance(substances_data, list) else [])
 
 
 
 
 
132
  results['substances'] = {
133
  item['substance_id']: item['substance_name']
134
+ for item in substances_list if item.get('substance_id') and item.get('substance_name')
 
135
  }
136
  logger.info(f"✓ {len(results['substances'])} substances téléchargées")
137
+ else:
138
+ results['substances'] = {}
139
+ logger.warning("Aucune donnée de substance active n'a été téléchargée.")
140
+
141
+ st.text("📥 Téléchargement de tous les enregistrements LMR...")
142
  mrls_data = fetcher.download_data(
143
  "/pesticide_residues_mrls/download",
144
  {"format": "json", "language": "FR", "api-version": "v2.0"}
145
  )
 
146
  if mrls_data:
147
+ results['mrls'] = mrls_data.get('value', []) if isinstance(mrls_data, dict) else (mrls_data if isinstance(mrls_data, list) else [])
148
+ logger.info(f"✓ {len(results['mrls'])} enregistrements LMR téléchargés")
149
+ else:
150
+ results['mrls'] = []
151
+ logger.warning("Aucune donnée LMR n'a été téléchargée.")
152
+
 
153
  st.text("📥 Récupération des produits...")
154
  products_list = fetcher.get_products_paginated(language='FR')
 
155
  results['products'] = products_list
156
  results['product_dict'] = {
157
  p['product_id']: p['product_name']
158
+ for p in products_list if p.get('product_id') and p.get('product_name')
 
159
  }
160
  logger.info(f"✓ {len(results['products'])} produits récupérés")
161
 
 
162
  results['stats'] = {
163
  'api_calls': fetcher.api_calls,
164
  'substances_count': len(results.get('substances', {})),
 
166
  'products_count': len(results.get('products', [])),
167
  'download_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
168
  }
169
+ st.success(f"✅ Toutes les données téléchargées en {fetcher.api_calls} appels API!")
 
 
170
  return results
171
 
172
  class PesticideInterface:
173
  def __init__(self):
 
174
  self.data = download_all_data()
 
 
175
  self._create_indexes()
176
 
177
  def _create_indexes(self):
 
 
178
  self.mrls_by_product = defaultdict(list)
179
  for mrl in self.data.get('mrls', []):
180
  if mrl.get('product_id'):
181
  self.mrls_by_product[mrl['product_id']].append(mrl)
182
 
 
183
  self.product_choices = {
184
  p['product_name']: p['product_id']
185
+ for p in self.data.get('products', []) if p.get('product_name') and p.get('product_id')
 
186
  }
187
+ logger.info(f"Index créés: {len(self.mrls_by_product)} produits avec des LMR indexées.")
 
188
 
189
  def get_product_details(self, product_names: List[str], future_only: bool = False) -> pd.DataFrame:
190
+ product_ids = [self.product_choices[name] for name in product_names if name in self.product_choices]
 
 
 
191
 
 
192
  all_mrls = []
193
  for product_id in product_ids:
194
+ all_mrls.extend(self.mrls_by_product.get(product_id, []))
 
195
 
196
  if not all_mrls:
197
+ # st.info("Aucune donnée de LMR trouvée pour les produits sélectionnés.") # Déplacé à create_interface
198
  return pd.DataFrame()
199
 
 
200
  df = pd.DataFrame(all_mrls)
201
 
 
202
  df["Substance"] = df["pesticide_residue_id"].map(self.data.get('substances', {})).fillna("Inconnu")
203
  df["Produit"] = df["product_id"].map(self.data.get('product_dict', {})).fillna("Inconnu")
204
 
205
+ # Création du lien Markdown pour le règlement
206
+ def create_regulation_link(row):
207
+ url = row.get("regulation_url")
208
+ number = row.get("regulation_number", "N/A")
209
+ if pd.notna(url) and str(url).strip(): # S'assurer que l'URL est valide
210
+ return f"[{number}]({url})"
211
+ return number
212
+
213
+ df["Lien Règlement"] = df.apply(create_regulation_link, axis=1)
214
 
 
215
  df["Date d'application"] = pd.to_datetime(df.get("entry_into_force_date"), errors="coerce")
216
 
 
217
  if future_only:
218
+ now = pd.Timestamp.now(tz='UTC') # Utiliser un timestamp avec fuseau horaire pour la comparaison
219
  future_date = now + timedelta(days=180)
220
+ # S'assurer que "Date d'application" est tz-aware ou tz-naive comme `now`
221
+ if df["Date d'application"].dt.tz is None:
222
+ df["Date d'application"] = df["Date d'application"].dt.tz_localize('UTC')
223
+
224
  df = df[
225
+ (df["Date d'application"].notna()) &
226
  (df["Date d'application"] > now) &
227
  (df["Date d'application"] <= future_date)
228
  ]
 
229
  if df.empty:
230
+ # st.info(f"🔍 Aucun changement de LMR prévu dans les 6 prochains mois.") # Déplacé
231
  return pd.DataFrame()
232
 
 
 
233
  df["Valeur LMR"] = pd.to_numeric(df.get("mrl_value"), errors='coerce')
234
 
235
+ columns_to_keep = ["Produit", "Substance", "Valeur LMR", "Date d'application", "Lien Règlement"]
 
 
 
 
 
236
 
237
+ # S'assurer que les colonnes existent avant la sélection pour éviter les KeyErrors
238
+ final_columns = [col for col in columns_to_keep if col in df.columns]
239
+ df = df[final_columns].copy()
240
 
241
+ sort_columns = ["Produit"]
242
+ ascending_order = [True]
243
+ if "Date d'application" in df.columns:
244
+ sort_columns.append("Date d'application")
245
+ ascending_order.append(False) # Plus récent en premier
246
+
247
+ df = df.sort_values(by=sort_columns, ascending=ascending_order)
248
 
249
  return df
250
 
251
  def create_interface(self):
252
  st.title("🌿 EU Pesticides Database Explorer - Version Optimisée")
253
 
254
+ stats = self.data.get('stats', {})
255
  col1, col2, col3, col4 = st.columns(4)
256
  with col1:
257
+ st.metric("📦 Produits", f"{stats.get('products_count', 0):,}")
258
  with col2:
259
+ st.metric("🧪 Substances", f"{stats.get('substances_count', 0):,}")
260
  with col3:
261
+ st.metric("📊 Enregistrements LMR", f"{stats.get('mrls_count', 0):,}") # Nom modifié
262
  with col4:
263
+ st.metric("🚀 Appels API", stats.get('api_calls', 0))
 
 
264
 
265
+ st.success(f"✨ Données téléchargées ({stats.get('download_time', 'N/A')}) en {stats.get('api_calls',0)} appels API.")
266
  st.markdown("---")
267
 
268
+ col1_select, col2_select = st.columns([3, 1])
269
+ with col1_select:
 
 
270
  product_names = st.multiselect(
271
  "🔍 Sélectionnez un ou plusieurs produits",
272
  options=sorted(list(self.product_choices.keys())),
273
  help="Commencez à taper pour filtrer les produits"
274
  )
275
+ with col2_select:
 
276
  future_only = st.checkbox(
277
+ "📅 Changements prévus (6 prochains mois)",
278
  value=False,
279
+ help="Afficher uniquement les changements de LMR prévus dans les 6 prochains mois"
280
  )
281
 
 
282
  if product_names:
283
+ df_results = self.get_product_details(product_names, future_only)
284
 
285
+ if df_results.empty:
286
+ if future_only:
287
+ st.info(f"🔍 Aucun changement de LMR prévu dans les 6 prochains mois pour les produits sélectionnés.")
288
+ else:
289
+ st.info("Aucune donnée de LMR trouvée pour les produits sélectionnés avec les filtres actuels.")
290
+ else:
291
  st.markdown("### 📊 Résultats")
292
+ df_numeric_mrl = df_results[df_results["Valeur LMR"].notna()]
 
 
 
293
 
294
+ res_col1, res_col2 = st.columns(2) # Suppression de la LMR moyenne
295
+ with res_col1:
296
+ st.metric("Lignes trouvées", len(df_results))
297
+ with res_col2:
298
  st.metric("Substances uniques", df_numeric_mrl["Substance"].nunique() if not df_numeric_mrl.empty else 0)
 
 
 
 
299
 
 
300
  with st.expander("⚙️ Options d'affichage"):
301
+ show_low_mrl = st.checkbox("Inclure LMR < 0.01 mg/kg", value=True) # LMR à 0.01 (souvent limite de détection)
302
+
303
+ sortable_cols = [col for col in ["Produit", "Substance", "Valeur LMR", "Date d'application"] if col in df_results.columns]
304
+ if sortable_cols:
305
+ sort_by = st.selectbox("Trier par", sortable_cols)
306
+ sort_order = st.radio("Ordre", ["Croissant", "Décroissant"], horizontal=True, index=1 if sort_by == "Date d'application" else 0) # Desc pour date
307
+ else:
308
+ sort_by = None
309
+
310
+ if not show_low_mrl and "Valeur LMR" in df_results.columns:
311
+ df_results_filtered = df_results[df_results["Valeur LMR"] >= 0.01].copy()
312
+ else:
313
+ df_results_filtered = df_results.copy()
314
 
315
+ if sort_by and sort_by in df_results_filtered.columns:
316
+ df_results_filtered = df_results_filtered.sort_values(sort_by, ascending=(sort_order == "Croissant"))
317
 
 
318
  st.dataframe(
319
+ df_results_filtered,
320
  use_container_width=True,
321
  hide_index=True,
322
  column_config={
323
  "Valeur LMR": st.column_config.NumberColumn(
324
+ "Valeur LMR (mg/kg)", format="%.3f", help="Limite Maximale de Résidus"
 
 
325
  ),
326
  "Date d'application": st.column_config.DateColumn(
327
+ "Date d'application", format="DD/MM/YYYY"
 
328
  ),
329
+ # "Lien Règlement" sera rendu par défaut car c'est du Markdown. Aucune config spécifique LinkColumn nécessaire.
 
 
 
330
  }
331
  )
332
 
333
+ if len(df_results_filtered) > 1:
334
+ self.create_visualizations(df_results_filtered)
 
 
 
 
335
 
336
+ csv = df_results_filtered.to_csv(index=False).encode('utf-8')
 
337
  st.download_button(
338
+ label="📥 Télécharger les résultats (CSV)",
339
  data=csv,
340
+ file_name=f"pesticides_lmr_{'_'.join(product_names)}_{datetime.now().strftime('%Y%m%d')}.csv",
341
  mime="text/csv"
342
  )
343
  else:
344
+ st.info("👆 Sélectionnez des produits pour afficher leurs Limites Maximales de Résidus (LMR).")
345
+ with st.expander("📊 Statistiques globales sur les données disponibles"):
346
+ product_mrl_counts = {
347
+ pid: len(mrls) for pid, mrls in self.mrls_by_product.items()
 
 
 
 
348
  }
349
+ top_products_ids = sorted(product_mrl_counts.items(), key=lambda x: x[1], reverse=True)[:10]
 
 
 
 
350
 
351
+ if top_products_ids:
352
+ st.markdown("**Top 10 des produits par nombre d'enregistrements LMR :**")
353
+ for pid, count in top_products_ids:
354
  product_name = self.data['product_dict'].get(pid, f"ID: {pid}")
355
+ st.write(f"- {product_name}: {count} enregistrements")
356
 
357
  def create_visualizations(self, df: pd.DataFrame):
358
+ tabs = st.tabs(["📈 Évolution temporelle des LMR", "📊 Distribution des LMR", "🏆 Top substances"])
 
 
 
 
 
 
359
  plot_df = df[df["Valeur LMR"].notna()].copy()
360
 
361
  if plot_df.empty:
362
+ st.info("Pas de données numériques de LMR valides pour la visualisation.")
363
  return
364
 
365
  with tabs[0]:
 
366
  if "Date d'application" in plot_df.columns and plot_df["Date d'application"].notna().any():
367
  temp_plot_df = plot_df[plot_df["Date d'application"].notna()]
368
  if not temp_plot_df.empty:
369
  fig = px.scatter(
370
+ temp_plot_df, x="Date d'application", y="Valeur LMR",
371
+ color="Substance", size="Valeur LMR",
372
+ hover_data=["Produit", "Valeur LMR", "Substance", "Lien Règlement"],
373
+ title="Évolution des LMR dans le temps (échelle log.)", log_y=True
 
 
 
 
374
  )
375
  st.plotly_chart(fig, use_container_width=True)
376
  else:
377
+ st.info("Pas de données temporelles valides après filtrage.")
378
  else:
379
+ st.info("Données de date d'application non disponibles pour cette visualisation.")
380
 
381
  with tabs[1]:
382
+ fig_hist = px.histogram(
383
+ plot_df, x="Valeur LMR", nbins=50, title="Distribution des valeurs LMR (échelle log.)",
384
+ log_x=True, labels={"count": "Nombre d'occurrences"}
 
 
 
 
 
385
  )
386
+ st.plotly_chart(fig_hist, use_container_width=True)
387
 
 
388
  if plot_df["Produit"].nunique() > 1:
389
+ fig_box = px.box(
390
+ plot_df, x="Produit", y="Valeur LMR",
391
+ title="Distribution des LMR par produit (échelle log.)", log_y=True
 
 
 
392
  )
393
+ st.plotly_chart(fig_box, use_container_width=True)
394
 
395
  with tabs[2]:
396
+ if not plot_df.empty:
397
+ top_substances = (
398
+ plot_df.groupby("Substance")["Valeur LMR"]
399
+ .agg(['max', 'count', 'mean'])
400
+ .sort_values('max', ascending=False).head(15).reset_index()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  )
402
+ if not top_substances.empty:
403
+ fig_bar = px.bar(
404
+ top_substances, y="Substance", x='max', orientation='h',
405
+ title="Top 15 substances par LMR max.",
406
+ labels={'max': 'LMR maximale (mg/kg)', 'Substance': 'Substance'},
407
+ hover_data={'count': True, 'mean': ':.3f'}
408
+ )
409
+ fig_bar.update_layout(yaxis={'categoryorder':'total ascending'})
410
+ st.plotly_chart(fig_bar, use_container_width=True)
411
+ else:
412
+ st.info("Pas assez de données pour le classement des substances.")
413
  else:
414
+ st.info("Pas de données pour le classement des substances.")
415
+
416
 
417
  def main():
 
418
  with st.sidebar:
419
  st.markdown("## 🌿 EU Pesticides Explorer")
420
  st.markdown("### Version Ultra-Optimisée")
 
421
  st.markdown("""
422
+ Utilise les **endpoints de téléchargement bulk** pour une récupération rapide des données.
423
+ - Accès à toutes les données LMR, substances et produits.
424
+ - Cache de 24h pour optimiser les chargements ultérieurs.
 
 
 
 
 
 
 
 
 
 
425
  """)
426
+ if st.button("🔄 Forcer le rechargement des données", key="force_reload"):
 
427
  st.cache_data.clear()
428
  st.rerun()
429
 
430
  st.markdown("---")
431
+ # Essayer d'accéder aux stats pour afficher l'heure sans tout retélécharger si déjà en cache
432
+ # Cela nécessite que download_all_data soit appelé au moins une fois.
433
+ # Pour éviter un appel prématuré, on peut le mettre dans l'interface principale.
434
+ # Ou, si on veut l'heure ici, il faut appeler download_all_data ici.
435
+ # Le plus simple est de laisser PesticideInterface gérer le premier appel.
436
+ # On pourrait passer une référence aux stats ici après l'init de PesticideInterface
437
+ # if 'interface' in st.session_state:
438
+ # st.caption(f"Dernière màj: {st.session_state.interface.data['stats']['download_time']}")
439
+
440
+ if 'interface' not in st.session_state:
441
+ st.session_state.interface = PesticideInterface()
442
 
443
+ st.session_state.interface.create_interface()
444
+
445
+ # Affichage de l'heure de màj dans la sidebar après initialisation
446
+ # Cela sera exécuté à chaque rerun, donc l'heure sera toujours à jour si les données sont rechargées.
447
+ if hasattr(st.session_state.interface, 'data') and 'stats' in st.session_state.interface.data:
448
+ st.sidebar.caption(f"Données chargées à: {st.session_state.interface.data['stats']['download_time']}")
449
+
450
 
451
  if __name__ == "__main__":
452
  main()