Spaces:
Sleeping
Sleeping
| import logging | |
| import json | |
| import io | |
| import zipfile | |
| from datetime import datetime, timedelta | |
| from typing import Dict, List, Optional, Any, Tuple | |
| import pandas as pd | |
| import requests | |
| import plotly.express as px | |
| import streamlit as st | |
| from tenacity import retry, stop_after_attempt, wait_exponential | |
| import time | |
| from collections import defaultdict | |
| import hashlib | |
| # Configuration Streamlit | |
| st.set_page_config(page_title="Pesticide Data Explorer - Optimized", page_icon="🌿", layout="wide") | |
| # Configuration logging | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", | |
| handlers=[ | |
| logging.FileHandler("pesticide_app_optimized.log", encoding="utf-8"), | |
| logging.StreamHandler() | |
| ], | |
| ) | |
| logger = logging.getLogger(__name__) | |
| class PesticideDataFetcher: | |
| """Fetcher optimisé utilisant les endpoints de téléchargement bulk""" | |
| BASE_URL = "https://api.datalake.sante.service.ec.europa.eu/sante/pesticides" | |
| HEADERS = { | |
| "Content-Type": "application/json", | |
| "Cache-Control": "no-cache", | |
| "User-Agent": "Mozilla/5.0" | |
| } | |
| def __init__(self): | |
| self.session = requests.Session() | |
| self.session.headers.update(self.HEADERS) | |
| self.api_calls = 0 | |
| def download_data(self, endpoint: str, params: Dict) -> Optional[Any]: | |
| """Télécharge les données depuis un endpoint de download""" | |
| url = f"{self.BASE_URL}{endpoint}" | |
| try: | |
| self.api_calls += 1 | |
| logger.info(f"Téléchargement depuis {endpoint} (appel API #{self.api_calls})") | |
| response = self.session.get(url, params=params, timeout=120) # Timeout plus long pour les gros fichiers | |
| response.raise_for_status() | |
| content_type = response.headers.get('Content-Type', '') | |
| # Si c'est du JSON | |
| if 'json' in content_type or params.get('format') == 'json': | |
| return response.json() | |
| # Si c'est du CSV | |
| elif 'csv' in content_type or params.get('format') == 'csv': | |
| return response.text | |
| # Si c'est un fichier ZIP (possible pour les gros datasets) | |
| elif 'zip' in content_type: | |
| with zipfile.ZipFile(io.BytesIO(response.content)) as zf: | |
| # Prendre le premier fichier du ZIP | |
| filename = zf.namelist()[0] | |
| with zf.open(filename) as f: | |
| content = f.read().decode('utf-8') | |
| if filename.endswith('.json'): | |
| return json.loads(content) | |
| else: | |
| return content | |
| else: | |
| # Par défaut, retourner le contenu brut | |
| return response.text | |
| except requests.RequestException as e: | |
| logger.error(f"Erreur lors du téléchargement {endpoint}: {e}") | |
| if hasattr(e, 'response') and e.response is not None: | |
| logger.error(f"Status code: {e.response.status_code}") | |
| logger.error(f"Response: {e.response.text[:500]}...") | |
| return None | |
| def get_products_paginated(self, language: str = 'FR') -> List[Dict]: | |
| """Récupère tous les produits avec pagination (pas d'endpoint download)""" | |
| all_products = [] | |
| url = f"{self.BASE_URL}/pesticide_residues_products" | |
| params = { | |
| 'format': 'json', | |
| 'language': language, | |
| 'api-version': 'v2.0' | |
| } | |
| # Première requête pour voir s'il y a pagination | |
| self.api_calls += 1 | |
| response = self.session.get(url, params=params, timeout=30) | |
| response.raise_for_status() | |
| data = response.json() | |
| if 'value' in data: | |
| all_products.extend(data['value']) | |
| # Gérer la pagination avec nextLink si présent | |
| while 'nextLink' in data and self.api_calls < 10: # Limite de sécurité | |
| self.api_calls += 1 | |
| response = self.session.get(data['nextLink'], timeout=30) | |
| response.raise_for_status() | |
| data = response.json() | |
| if 'value' in data: | |
| all_products.extend(data['value']) | |
| else: | |
| # Pas de structure 'value', c'est directement la liste | |
| all_products = data if isinstance(data, list) else [data] | |
| logger.info(f"Récupéré {len(all_products)} produits en {self.api_calls} appels") | |
| return all_products | |
| # Cache de 24h pour les données bulk | |
| def download_all_data() -> Dict[str, Any]: | |
| """Télécharge toutes les données en utilisant les endpoints optimisés""" | |
| fetcher = PesticideDataFetcher() | |
| results = {} | |
| with st.spinner("Téléchargement des données complètes..."): | |
| # 1. Télécharger toutes les substances actives | |
| st.text("📥 Téléchargement des substances actives...") | |
| substances_data = fetcher.download_data( | |
| "/active_substances/download", | |
| {"format": "json", "api-version": "v2.0"} | |
| ) | |
| if substances_data: | |
| # Convertir en dictionnaire pour accès rapide | |
| if isinstance(substances_data, dict) and 'value' in substances_data: | |
| substances_list = substances_data['value'] | |
| else: | |
| substances_list = substances_data if isinstance(substances_data, list) else [] | |
| results['substances'] = { | |
| item['substance_id']: item['substance_name'] | |
| for item in substances_list | |
| if item.get('substance_id') and item.get('substance_name') | |
| } | |
| logger.info(f"✓ {len(results['substances'])} substances téléchargées") | |
| # 2. Télécharger toutes les LMR | |
| st.text("📥 Téléchargement de toutes les LMR (peut prendre quelques secondes)...") | |
| mrls_data = fetcher.download_data( | |
| "/pesticide_residues_mrls/download", | |
| {"format": "json", "language": "FR", "api-version": "v2.0"} | |
| ) | |
| if mrls_data: | |
| if isinstance(mrls_data, dict) and 'value' in mrls_data: | |
| results['mrls'] = mrls_data['value'] | |
| else: | |
| results['mrls'] = mrls_data if isinstance(mrls_data, list) else [] | |
| logger.info(f"✓ {len(results['mrls'])} LMR téléchargées") | |
| # 3. Récupérer tous les produits (avec pagination si nécessaire) | |
| st.text("📥 Récupération des produits...") | |
| products_list = fetcher.get_products_paginated(language='FR') | |
| results['products'] = products_list | |
| results['product_dict'] = { | |
| p['product_id']: p['product_name'] | |
| for p in products_list | |
| if p.get('product_id') and p.get('product_name') | |
| } | |
| logger.info(f"✓ {len(results['products'])} produits récupérés") | |
| # 4. Statistiques | |
| results['stats'] = { | |
| 'api_calls': fetcher.api_calls, | |
| 'substances_count': len(results.get('substances', {})), | |
| 'mrls_count': len(results.get('mrls', [])), | |
| 'products_count': len(results.get('products', [])), | |
| 'download_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S') | |
| } | |
| st.success(f"✅ Toutes les données téléchargées en seulement {fetcher.api_calls} appels API!") | |
| return results | |
| class PesticideInterface: | |
| def __init__(self): | |
| # Charger toutes les données une seule fois | |
| self.data = download_all_data() | |
| # Créer des index pour des recherches rapides | |
| self._create_indexes() | |
| def _create_indexes(self): | |
| """Crée des index pour optimiser les recherches""" | |
| # Index des LMR par product_id | |
| self.mrls_by_product = defaultdict(list) | |
| for mrl in self.data.get('mrls', []): | |
| if mrl.get('product_id'): | |
| self.mrls_by_product[mrl['product_id']].append(mrl) | |
| # Index des produits par nom | |
| self.product_choices = { | |
| p['product_name']: p['product_id'] | |
| for p in self.data.get('products', []) | |
| if p.get('product_name') and p.get('product_id') | |
| } | |
| logger.info(f"Index créés: {len(self.mrls_by_product)} produits avec LMR") | |
| def get_product_details(self, product_names: List[str], future_only: bool = False) -> pd.DataFrame: | |
| """Récupère les détails des produits depuis les données en cache""" | |
| # Convertir les noms en IDs | |
| product_ids = [self.product_choices[name] for name in product_names] | |
| # Récupérer les LMR depuis l'index | |
| all_mrls = [] | |
| for product_id in product_ids: | |
| mrls = self.mrls_by_product.get(product_id, []) | |
| all_mrls.extend(mrls) | |
| if not all_mrls: | |
| st.info("Aucune donnée de LMR trouvée pour les produits sélectionnés.") | |
| return pd.DataFrame() | |
| # Convertir en DataFrame | |
| df = pd.DataFrame(all_mrls) | |
| # Enrichir avec les données | |
| df["Substance"] = df["pesticide_residue_id"].map(self.data.get('substances', {})).fillna("Inconnu") | |
| df["Produit"] = df["product_id"].map(self.data.get('product_dict', {})).fillna("Inconnu") | |
| # Ajouter le lien vers le règlement | |
| if 'regulation_url' in df.columns and 'regulation_number' in df.columns: | |
| # Pour LinkColumn de Streamlit, on a besoin juste de l'URL | |
| df["Règlement"] = df["regulation_url"].fillna("") | |
| df["Règlement_Num"] = df["regulation_number"].fillna("N/A") | |
| elif 'regulation_number' in df.columns: | |
| df["Règlement_Num"] = df["regulation_number"].fillna("N/A") | |
| else: | |
| df["Règlement_Num"] = "N/A" | |
| # Conversion des dates | |
| df["Date d'application"] = pd.to_datetime(df.get("entry_into_force_date"), errors="coerce") | |
| # Filtrer pour les 6 prochains mois si demandé | |
| if future_only: | |
| now = datetime.now() | |
| future_date = now + timedelta(days=180) | |
| df = df[ | |
| (df["Date d'application"] > now) & | |
| (df["Date d'application"] <= future_date) | |
| ] | |
| if df.empty: | |
| st.info(f"🔍 Aucun changement de LMR prévu dans les 6 prochains mois.") | |
| return pd.DataFrame() | |
| # Préparer le DataFrame final | |
| if "mrl_value" in df.columns: | |
| df["Valeur LMR"] = pd.to_numeric(df["mrl_value"], errors='coerce') | |
| elif "Valeur LMR" not in df.columns: | |
| df["Valeur LMR"] = pd.NA | |
| # Sélection des colonnes finales | |
| columns_to_keep = ["Produit", "Substance", "Valeur LMR"] | |
| if "Date d'application" in df.columns: | |
| columns_to_keep.append("Date d'application") | |
| if "Règlement_Num" in df.columns: | |
| columns_to_keep.append("Règlement_Num") | |
| if "Règlement" in df.columns: | |
| columns_to_keep.append("Règlement") | |
| # Tri des données | |
| sort_columns = ["Produit"] | |
| if "Date d'application" in df.columns: | |
| sort_columns.append("Date d'application") | |
| sort_ascending = [True, False] | |
| else: | |
| sort_ascending = [True] | |
| df = df.sort_values(sort_columns, ascending=sort_ascending) | |
| return df | |
| def create_interface(self): | |
| st.title("🌿 EU Pesticides Database Explorer - Version Optimisée") | |
| # Afficher les statistiques | |
| col1, col2, col3, col4 = st.columns(4) | |
| with col1: | |
| st.metric("📦 Produits", f"{self.data['stats']['products_count']:,}") | |
| with col2: | |
| st.metric("🧪 Substances", f"{self.data['stats']['substances_count']:,}") | |
| with col3: | |
| st.metric("📊 LMR totales", f"{self.data['stats']['mrls_count']:,}") | |
| with col4: | |
| st.metric("🚀 Appels API", self.data['stats']['api_calls']) | |
| st.success(f"✨ Toutes les données ont été téléchargées en {self.data['stats']['api_calls']} appels API seulement!") | |
| st.markdown("---") | |
| # Interface de sélection | |
| col1, col2 = st.columns([3, 1]) | |
| with col1: | |
| # Recherche avec autocomplétion | |
| product_names = st.multiselect( | |
| "🔍 Sélectionnez un ou plusieurs produits", | |
| options=sorted(list(self.product_choices.keys())), | |
| help="Commencez à taper pour filtrer les produits" | |
| ) | |
| with col2: | |
| future_only = st.checkbox( | |
| "📅 6 prochains mois", | |
| value=False, | |
| help="Afficher uniquement les changements prévus" | |
| ) | |
| # Affichage des résultats | |
| if product_names: | |
| df = self.get_product_details(product_names, future_only) | |
| if not df.empty: | |
| st.markdown("### 📊 Résultats") | |
| # Statistiques rapides | |
| col1, col2, col3 = st.columns(3) | |
| with col1: | |
| st.metric("Entrées trouvées", len(df)) | |
| with col2: | |
| st.metric("Substances uniques", df["Substance"].nunique()) | |
| with col3: | |
| if "Valeur LMR" in df.columns: | |
| avg_mrl = df["Valeur LMR"].mean() | |
| st.metric("LMR moyenne", f"{avg_mrl:.3f} mg/kg" if not pd.isna(avg_mrl) else "N/A") | |
| # Options d'affichage | |
| with st.expander("⚙️ Options d'affichage"): | |
| show_zero = st.checkbox("Afficher les LMR à 0.01 mg/kg", value=True) | |
| # Colonnes disponibles pour le tri | |
| available_sort_columns = ["Produit", "Substance", "Valeur LMR"] | |
| if "Date d'application" in df.columns: | |
| available_sort_columns.append("Date d'application") | |
| if "Règlement_Num" in df.columns: | |
| available_sort_columns.append("N° Règlement") | |
| sort_by = st.selectbox("Trier par", available_sort_columns) | |
| sort_order = st.radio("Ordre", ["Croissant", "Décroissant"], horizontal=True) | |
| # Appliquer les filtres | |
| if not show_zero and "Valeur LMR" in df.columns: | |
| df = df[df["Valeur LMR"] != 0.01] | |
| # Mapper le nom d'affichage vers le nom réel de la colonne | |
| sort_column_mapping = { | |
| "N° Règlement": "Règlement_Num", | |
| "Produit": "Produit", | |
| "Substance": "Substance", | |
| "Valeur LMR": "Valeur LMR", | |
| "Date d'application": "Date d'application" | |
| } | |
| actual_sort_column = sort_column_mapping.get(sort_by, sort_by) | |
| if actual_sort_column in df.columns: | |
| df = df.sort_values(actual_sort_column, ascending=(sort_order == "Croissant")) | |
| # Afficher le tableau avec configuration des colonnes | |
| column_config = { | |
| "Valeur LMR": st.column_config.NumberColumn( | |
| "Valeur LMR (mg/kg)", | |
| format="%.3f", | |
| help="Limite Maximale de Résidus" | |
| ), | |
| "Date d'application": st.column_config.DateColumn( | |
| "Date d'application", | |
| format="DD/MM/YYYY" | |
| ) | |
| } | |
| # Garder seulement les colonnes nécessaires pour l'affichage | |
| display_columns = ["Produit", "Substance", "Valeur LMR"] | |
| if "Date d'application" in df.columns: | |
| display_columns.append("Date d'application") | |
| # Gérer l'affichage du règlement | |
| if "Règlement" in df.columns and df["Règlement"].notna().any(): | |
| display_columns.append("Règlement_Num") | |
| display_columns.append("Règlement") | |
| column_config["Règlement_Num"] = st.column_config.TextColumn( | |
| "N° Règlement", | |
| help="Numéro du règlement" | |
| ) | |
| column_config["Règlement"] = st.column_config.LinkColumn( | |
| "Lien", | |
| help="Cliquez pour voir le règlement officiel", | |
| display_text="📄 Voir" | |
| ) | |
| elif "Règlement_Num" in df.columns: | |
| display_columns.append("Règlement_Num") | |
| column_config["Règlement_Num"] = st.column_config.TextColumn( | |
| "Règlement", | |
| help="Numéro du règlement" | |
| ) | |
| df_display = df[display_columns] | |
| st.dataframe( | |
| df_display, | |
| use_container_width=True, | |
| hide_index=True, | |
| column_config=column_config | |
| ) | |
| # Visualisations | |
| if len(df) > 1: | |
| self.create_visualizations(df) | |
| # Export - exclure la colonne des liens URL | |
| export_columns = ["Produit", "Substance", "Valeur LMR"] | |
| if "Date d'application" in df.columns: | |
| export_columns.append("Date d'application") | |
| if "Règlement_Num" in df.columns: | |
| export_columns.append("Règlement_Num") | |
| # Renommer Règlement_Num en Règlement pour l'export | |
| df_export = df[export_columns].copy() | |
| if "Règlement_Num" in df_export.columns: | |
| df_export = df_export.rename(columns={"Règlement_Num": "Règlement"}) | |
| csv = df_export.to_csv(index=False) | |
| st.download_button( | |
| label="📥 Télécharger (CSV)", | |
| data=csv, | |
| file_name=f"pesticides_lmr_{datetime.now().strftime('%Y%m%d')}.csv", | |
| mime="text/csv" | |
| ) | |
| else: | |
| # Afficher quelques statistiques globales | |
| st.info("👆 Sélectionnez des produits pour voir leurs LMR") | |
| with st.expander("📊 Statistiques globales"): | |
| # Top 10 des produits avec le plus de LMR | |
| product_mrl_count = { | |
| pid: len(mrls) | |
| for pid, mrls in self.mrls_by_product.items() | |
| } | |
| top_products = sorted( | |
| product_mrl_count.items(), | |
| key=lambda x: x[1], | |
| reverse=True | |
| )[:10] | |
| if top_products: | |
| st.markdown("**Top 10 des produits avec le plus de LMR:**") | |
| for pid, count in top_products: | |
| product_name = self.data['product_dict'].get(pid, f"ID: {pid}") | |
| st.write(f"- {product_name}: {count} LMR") | |
| def create_visualizations(self, df: pd.DataFrame): | |
| """Crée des visualisations interactives""" | |
| tabs = st.tabs(["📈 Évolution temporelle", "📊 Distribution", "🏆 Top substances"]) | |
| with tabs[0]: | |
| if "Date d'application" in df.columns and df["Date d'application"].notna().any(): | |
| # Filtrer les données pour le graphique (enlever les NaN) | |
| df_plot = df[df["Date d'application"].notna() & df["Valeur LMR"].notna()].copy() | |
| if not df_plot.empty: | |
| fig = px.scatter( | |
| df_plot, | |
| x="Date d'application", | |
| y="Valeur LMR", | |
| color="Substance", | |
| hover_data=["Produit"], | |
| title="Évolution des LMR dans le temps", | |
| log_y=True | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| else: | |
| st.info("Pas de données valides pour le graphique temporel") | |
| else: | |
| st.info("Pas de données temporelles disponibles") | |
| with tabs[1]: | |
| if "Valeur LMR" in df.columns and df["Valeur LMR"].notna().any(): | |
| # Filtrer les valeurs valides pour l'histogramme | |
| df_hist = df[df["Valeur LMR"].notna()] | |
| # Histogramme des valeurs LMR | |
| fig = px.histogram( | |
| df_hist, | |
| x="Valeur LMR", | |
| nbins=50, | |
| title="Distribution des valeurs LMR", | |
| log_x=True, | |
| labels={"count": "Nombre d'occurrences"} | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| # Box plot par produit si plusieurs produits | |
| if df["Produit"].nunique() > 1 and len(df_hist) > 0: | |
| fig2 = px.box( | |
| df_hist, | |
| x="Produit", | |
| y="Valeur LMR", | |
| title="Distribution des LMR par produit", | |
| log_y=True | |
| ) | |
| st.plotly_chart(fig2, use_container_width=True) | |
| else: | |
| st.info("Pas de données de LMR valides pour les graphiques de distribution") | |
| with tabs[2]: | |
| if "Valeur LMR" in df.columns and df["Valeur LMR"].notna().any(): | |
| # Filtrer les valeurs valides | |
| df_valid = df[df["Valeur LMR"].notna()] | |
| # Top substances par valeur maximale | |
| top_substances = ( | |
| df_valid.groupby("Substance")["Valeur LMR"] | |
| .agg(['max', 'count', 'mean']) | |
| .sort_values('max', ascending=False) | |
| .head(15) | |
| ) | |
| if not top_substances.empty: | |
| fig = px.bar( | |
| x=top_substances['max'].values, | |
| y=top_substances.index, | |
| orientation='h', | |
| title="Top 15 des substances par LMR maximale", | |
| labels={'x': 'LMR maximale (mg/kg)', 'y': 'Substance'}, | |
| hover_data={ | |
| 'Occurrences': top_substances['count'].values, | |
| 'Moyenne': top_substances['mean'].round(3).values | |
| } | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| else: | |
| st.info("Pas assez de données pour afficher le top des substances") | |
| else: | |
| st.info("Pas de données de LMR valides pour le classement des substances") | |
| def main(): | |
| # Configuration de la sidebar | |
| with st.sidebar: | |
| st.markdown("## 🌿 EU Pesticides Explorer") | |
| st.markdown("### Version Ultra-Optimisée") | |
| st.markdown(""" | |
| Cette version utilise les **endpoints de téléchargement bulk** | |
| pour récupérer toutes les données en seulement **3-4 appels API** ! | |
| **Avantages :** | |
| - ✅ Pas de limitation à 100 appels | |
| - ✅ Accès à TOUTES les données | |
| - ✅ Performance maximale | |
| - ✅ Cache de 24h | |
| **Données disponibles :** | |
| - Tous les produits alimentaires | |
| - Toutes les substances actives | |
| - Toutes les LMR (>100,000 entrées) | |
| """) | |
| if st.button("🔄 Forcer le rechargement des données"): | |
| st.cache_data.clear() | |
| st.rerun() | |
| st.markdown("---") | |
| # Afficher l'heure du dernier téléchargement | |
| data = download_all_data() | |
| if 'download_time' in data.get('stats', {}): | |
| st.caption(f"Dernière mise à jour : {data['stats']['download_time']}") | |
| # Interface principale | |
| interface = PesticideInterface() | |
| interface.create_interface() | |
| if __name__ == "__main__": | |
| main() |