Spaces:
Sleeping
Sleeping
| """ | |
| app.py - Application Streamlit pour la détermination de l'impact carbone | |
| des matières premières pour aliments composés. | |
| Lancement : streamlit run app.py | |
| """ | |
| import re | |
| import sys | |
| import os | |
| import streamlit as st | |
| import pandas as pd | |
| # Ajouter le dossier src/ au path pour les imports | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) | |
| from flowchart_engine import evaluate_carbon_impact, CarbonResult | |
| import llm_service | |
| import config | |
| import proxy_log | |
| # ============================================================================ | |
| # Fonctions utilitaires | |
| # ============================================================================ | |
| def get_country_list() -> list[str]: | |
| """Renvoie la liste des pays connus (clés du mapping FR→ISO).""" | |
| return sorted([p.title() for p in config.PAYS_FR_TO_ISO.keys()]) | |
| def _format_action_line(line: str) -> str: | |
| """Convertit les impacts affichés en kg CO2 eq / t produit.""" | |
| m = re.search(r"=\s*([0-9]+(?:\.[0-9]+)?)\s*kg\s*CO2\s*eq\s*/\s*t", line) | |
| if m: | |
| val = float(m.group(1)) | |
| return re.sub( | |
| r"=\s*[0-9]+(?:\.[0-9]+)?\s*kg\s*CO2\s*eq\s*/\s*t", | |
| f"= {val:.2f} kg CO2 eq / t", line, | |
| ) | |
| m = re.search(r"=\s*([0-9]+(?:\.[0-9]+)?)\s*kg\s*CO2\s*eq\s*/\s*kg", line) | |
| if m: | |
| val = float(m.group(1)) * 1000.0 | |
| return re.sub( | |
| r"=\s*[0-9]+(?:\.[0-9]+)?\s*kg\s*CO2\s*eq\s*/\s*kg", | |
| f"= {val:.2f} kg CO2 eq / t", line, | |
| ) | |
| return line | |
| def _get_transport_surcharge( | |
| pays_production: str | None = None, | |
| pays_transformation: str | None = None, | |
| type_mp: str = "vegetal_animal", | |
| source_db: str | None = None, | |
| classification: str = "brut", | |
| ) -> tuple[float, str]: | |
| """Retourne (forfait_kg_co2_t, zone) selon nouvelles règles de transport. | |
| Nouvelles règles : | |
| - MP Végétales & Soja : | |
| * ECOALIM → 10 kg CO2 eq / t | |
| * GFLI : | |
| - Transformée → référence pays_transformation | |
| - Brute → référence pays_production | |
| - Minéraux : référence pays_transformation (ECOALIM & GFLI) | |
| Calcul par pays : | |
| - France → +10 kg CO2 eq / t | |
| - Europe (hors FR) → +100 kg CO2 eq / t | |
| - Autre / inconnu → +300 kg CO2 eq / t | |
| """ | |
| # Cas ECOALIM pour végétal/soja : forfait fixe | |
| if source_db == "ECOALIM" and type_mp in ("vegetal_animal", "soja"): | |
| return 10.0, "ECOALIM (forfait fixe)" | |
| # Déterminer le pays de référence selon le type de MP et la classification | |
| pays_ref = None | |
| if type_mp == "mineral": | |
| # Minéraux : toujours utiliser pays_transformation | |
| pays_ref = pays_transformation or pays_production | |
| elif type_mp in ("vegetal_animal", "soja"): | |
| # Végétal/Soja GFLI : classification détermine le pays | |
| if classification == "transforme": | |
| pays_ref = pays_transformation or pays_production | |
| else: # brut | |
| pays_ref = pays_production or pays_transformation | |
| else: | |
| pays_ref = pays_production | |
| # Si pas de pays de référence, utiliser la valeur "autre" | |
| if not pays_ref or not pays_ref.strip(): | |
| return config.TRANSPORT_SURCHARGE["autre"], "Autre (inconnu)" | |
| pays_lower = pays_ref.strip().lower() | |
| # France | |
| if pays_lower == "france": | |
| return config.TRANSPORT_SURCHARGE["france"], "France" | |
| # Europe (via le set de noms FR ou via code ISO) | |
| iso = config.PAYS_FR_TO_ISO.get(pays_lower, pays_ref.upper()) | |
| if pays_lower in config.EUROPEAN_COUNTRIES_FR or iso in config.EUROPEAN_COUNTRIES_ISO: | |
| return config.TRANSPORT_SURCHARGE["europe"], "Europe" | |
| return config.TRANSPORT_SURCHARGE["autre"], "Autre" | |
| def _display_4_alternatives( | |
| alts: dict, | |
| title: str = "🎯 4 Alternatives proposées", | |
| selection_key: str = "alt_selection", | |
| ) -> dict | None: | |
| """Affiche les 4 colonnes d'alternatives avec possibilité de sélection. | |
| Retourne l'alternative sélectionnée si l'utilisateur valide. | |
| """ | |
| st.subheader(title) | |
| st.info( | |
| "Quand aucune matière exacte n'est trouvée, voici 4 propositions. " | |
| "Sélectionnez celle que vous préférez puis validez." | |
| ) | |
| col1, col2, col3, col4 = st.columns(4) | |
| labels = [ | |
| ("itinerary", "### 🔄 Itinéraire", col1), | |
| ("locality", "### 📍 Localité", col2), | |
| ("form", "### 🌱 Forme structurelle", col3), | |
| ("combined", "### ✨ Compromis", col4), | |
| ] | |
| radio_options: list[str] = [] | |
| radio_map: dict[str, dict] = {} | |
| for key, header, col in labels: | |
| with col: | |
| alt = alts.get(key) | |
| st.markdown(header) | |
| if alt: | |
| st.markdown(f"**{alt['name']}**") | |
| st.metric("Impact", f"{alt['impact']:.2f}") | |
| st.caption(f"kg CO2 eq/t | Source: {alt['source']}") | |
| with st.expander("Raison"): | |
| st.markdown(alt["reasoning"]) | |
| label = f"{alt['name']} ({alt['impact']:.2f} kg CO2 eq/t — {alt['source']})" | |
| radio_options.append(label) | |
| radio_map[label] = alt | |
| else: | |
| st.caption("Non disponible") | |
| selected = None | |
| if radio_options: | |
| chosen = st.radio( | |
| "Sélectionnez l'alternative à utiliser :", | |
| options=radio_options, | |
| index=0, | |
| key=f"radio_{selection_key}", | |
| horizontal=True, | |
| ) | |
| if st.button("✅ Valider cette alternative", key=f"btn_validate_{selection_key}"): | |
| selected = radio_map.get(chosen) | |
| if selected: | |
| st.session_state[f"selected_{selection_key}"] = selected | |
| st.success( | |
| f"✅ Alternative sélectionnée : **{selected['name']}** — " | |
| f"{selected['impact']:.2f} kg CO2 eq/t" | |
| ) | |
| st.divider() | |
| return st.session_state.get(f"selected_{selection_key}") or selected | |
| # ============================================================================ | |
| # Configuration de la page | |
| # ============================================================================ | |
| st.set_page_config( | |
| page_title="POC GAIA - Impact Carbone MP", | |
| page_icon="🌿", | |
| layout="wide", | |
| ) | |
| st.title("🌿 POC GAIA — Impact Carbone des Matières Premières") | |
| st.markdown(""" | |
| **Logigramme d'aide pour faciliter l'application du Guide de calcul de l'impact carbone | |
| des aliments composés** *(GT Carbone)* | |
| """) | |
| st.divider() | |
| # ============================================================================ | |
| # ONGLETS PRINCIPAUX | |
| # ============================================================================ | |
| tab_formulation, tab_single, tab_stats = st.tabs( | |
| ["📊 Calcul par liste de MP", "🔍 Calcul unitaire", "📈 Statistiques proxies"] | |
| ) | |
| # ============================================================================ | |
| # TAB 1 : FORMULATION PRODUIT | |
| # ============================================================================ | |
| with tab_formulation: | |
| st.subheader("📊 Calcul d'impact par liste de matières premières") | |
| st.markdown(""" | |
| Remplissez le tableau ci-dessous avec les matières premières. | |
| - **Code MP** : code interne de la matière première | |
| - **Matière première** : nom usuel | |
| - **Type MP** : Végétale/Animale, Soja ou Minérale (détermine le logigramme) | |
| - **Pays production** / **Pays transformation** : provenance (laisser vide si inconnu) | |
| - **% Appro origine** : pourcentage d'approvisionnement depuis cette origine spécifique (doit totaliser 100% par Code MP) | |
| """) | |
| # --- Initialiser le DataFrame éditable dans session_state --- | |
| _EMPTY_ROW = { | |
| "Type MP": "vegetal_animal", | |
| "Code MP": "", | |
| "Matière première": "", | |
| "Pays production": "", | |
| "Pays transformation": "", | |
| "% Appro origine": 100.0, | |
| "Extrusion": False, | |
| } | |
| if "formulation_df" not in st.session_state: | |
| st.session_state["formulation_df"] = pd.DataFrame([_EMPTY_ROW.copy() for _ in range(5)]) | |
| # --- Boutons d'action --- | |
| col_add, col_del, col_import, col_clear, _ = st.columns([1, 1, 1, 1, 2]) | |
| with col_add: | |
| if st.button("➕ Ajouter une ligne", key="btn_add_row"): | |
| st.session_state["formulation_df"] = pd.concat( | |
| [st.session_state["formulation_df"], pd.DataFrame([_EMPTY_ROW.copy()])], | |
| ignore_index=True, | |
| ) | |
| st.rerun() | |
| with col_del: | |
| if st.button("➖ Retirer dernière ligne", key="btn_del_row"): | |
| if len(st.session_state["formulation_df"]) > 1: | |
| st.session_state["formulation_df"] = ( | |
| st.session_state["formulation_df"].iloc[:-1].reset_index(drop=True) | |
| ) | |
| st.rerun() | |
| else: | |
| st.warning("⚠️ Le tableau doit contenir au moins une ligne.") | |
| with col_import: | |
| _import_file = st.file_uploader( | |
| "📥 Importer tableau Excel", | |
| type=["xlsx", "xls"], | |
| key="btn_import_formulation", | |
| label_visibility="collapsed", | |
| ) | |
| if _import_file is not None: | |
| try: | |
| _df_imp = pd.read_excel(_import_file) | |
| # Mapper les colonnes connues | |
| _col_map = {} | |
| for _c in _df_imp.columns: | |
| _cl = str(_c).lower() | |
| if "code" in _cl: | |
| _col_map["Code MP"] = _c | |
| elif "matière" in _cl or "matiere" in _cl or ("mp" in _cl and "code" not in _cl): | |
| _col_map["Matière première"] = _c | |
| elif "type" in _cl: | |
| _col_map["Type MP"] = _c | |
| elif "production" in _cl or ("pays" in _cl and "transf" not in _cl): | |
| _col_map["Pays production"] = _c | |
| elif "transf" in _cl: | |
| _col_map["Pays transformation"] = _c | |
| elif "extru" in _cl: | |
| _col_map["Extrusion"] = _c | |
| elif "appro" in _cl or "%" in _cl: | |
| _col_map["% Appro origine"] = _c | |
| _new_df = pd.DataFrame([_EMPTY_ROW.copy() for _ in range(len(_df_imp))]) | |
| for target, src in _col_map.items(): | |
| _new_df[target] = _df_imp[src].astype(str).fillna("") | |
| if "% Appro origine" in _col_map: | |
| _new_df["% Appro origine"] = pd.to_numeric( | |
| _df_imp[_col_map["% Appro origine"]], errors="coerce" | |
| ).fillna(100.0) | |
| if "Extrusion" in _col_map: | |
| _new_df["Extrusion"] = _df_imp[_col_map["Extrusion"]].astype(bool) | |
| st.session_state["formulation_df"] = _new_df | |
| st.session_state.pop("formulation_results", None) | |
| st.success(f"✅ {len(_new_df)} lignes importées.") | |
| st.rerun() | |
| except Exception as _e: | |
| st.error(f"Erreur d'import : {_e}") | |
| with col_clear: | |
| if st.button("🗑️ Réinitialiser", key="btn_clear_form"): | |
| st.session_state.pop("formulation_df", None) | |
| st.session_state.pop("formulation_results", None) | |
| st.rerun() | |
| # --- Tableau éditable --- | |
| edited_df = st.data_editor( | |
| st.session_state["formulation_df"], | |
| num_rows="dynamic", | |
| width='stretch', | |
| key="formulation_editor", | |
| column_config={ | |
| "Type MP": st.column_config.SelectboxColumn( | |
| "Type MP", | |
| options=["vegetal_animal", "soja", "mineral"], | |
| width="small", | |
| help="Végétale/Animale (hors soja), Dérivé du soja, Minérale/Micro-ingrédient/Additif", | |
| ), | |
| "Code MP": st.column_config.TextColumn("Code MP", width="small"), | |
| "Matière première": st.column_config.TextColumn("Matière première", width="medium"), | |
| "Pays production": st.column_config.TextColumn("Pays production", width="medium"), | |
| "Pays transformation": st.column_config.TextColumn("Pays transformation", width="medium"), | |
| "% Appro origine": st.column_config.NumberColumn("% Appro origine", min_value=0, max_value=100, step=0.1, format="%.1f"), | |
| "Extrusion": st.column_config.CheckboxColumn("Extrusion", help="Cocher si la MP subit une extrusion (+56,77 kg CO2 eq/t)", width="small"), | |
| }, | |
| ) | |
| # Synchroniser les éditions - faire une copie explicite pour éviter les bugs | |
| if not edited_df.equals(st.session_state["formulation_df"]): | |
| st.session_state["formulation_df"] = edited_df.copy() | |
| # Réinitialiser les résultats si le tableau a changé | |
| st.session_state.pop("formulation_results", None) | |
| if st.button("🚀 Calculer l'impact de la formulation", type="primary", width='stretch', key="btn_calc_formulation"): | |
| # Filtrer les lignes valides | |
| rows_to_eval = edited_df[edited_df["Matière première"].astype(str).str.strip() != ""].copy() | |
| if len(rows_to_eval) == 0: | |
| st.error("❌ Aucune matière première renseignée.") | |
| else: | |
| results_list = [] | |
| progress = st.progress(0) | |
| total = len(rows_to_eval) | |
| for idx, (_, row) in enumerate(rows_to_eval.iterrows()): | |
| code_mp = str(row["Code MP"]).strip() | |
| mp_name = str(row["Matière première"]).strip() | |
| type_mp_val = str(row.get("Type MP", "vegetal_animal")).strip() or "vegetal_animal" | |
| pays_p = str(row["Pays production"]).strip() if str(row["Pays production"]).strip() else None | |
| pays_t = str(row["Pays transformation"]).strip() if str(row["Pays transformation"]).strip() else None | |
| pct_appro = float(row["% Appro origine"]) if row["% Appro origine"] else 100.0 | |
| # Nettoyage | |
| if pays_p and pays_p.lower() in ("", "nan", "none"): | |
| pays_p = None | |
| if pays_t and pays_t.lower() in ("", "nan", "none"): | |
| pays_t = None | |
| with st.spinner(f"Évaluation de {mp_name} ({pays_p or '?'})…"): | |
| res = evaluate_carbon_impact(mp_name, pays_p, pays_t, type_mp=type_mp_val) | |
| # Calcul impact en kg CO2 eq / t | |
| if res.impact_kg_co2_eq is not None: | |
| if "tonne" in (res.unite_source or ""): | |
| impact_kg_t = res.impact_kg_co2_eq | |
| else: | |
| impact_kg_t = res.impact_kg_co2_eq * 1000.0 | |
| else: | |
| impact_kg_t = None | |
| # Forfait transport - nouvelle logique (dépend du type MP, classification, BD) | |
| transport_val, transport_zone = _get_transport_surcharge( | |
| pays_production=pays_p, | |
| pays_transformation=pays_t, | |
| type_mp=type_mp_val, | |
| source_db=res.source_db or None, | |
| classification=res.classification or "brut", | |
| ) | |
| # Forfait extrusion | |
| is_extrusion = bool(row.get("Extrusion", False)) | |
| extrusion_val = config.FORFAIT_EXTRUSION if is_extrusion else 0.0 | |
| # Impact total = impact unitaire + transport + extrusion | |
| if impact_kg_t is not None: | |
| impact_avec_transport = impact_kg_t + transport_val + extrusion_val | |
| else: | |
| impact_avec_transport = None | |
| # Impact pondéré = (impact + transport + extrusion) × (% appro / 100) | |
| if impact_avec_transport is not None: | |
| impact_pondere = impact_avec_transport * (pct_appro / 100.0) | |
| else: | |
| impact_pondere = None | |
| # --- Enregistrer le proxy choisi (formulation) --- | |
| if res.intrant_utilise: | |
| proxy_log.log_selection( | |
| matiere_recherchee=mp_name, | |
| proxy_choisi=res.intrant_utilise, | |
| scenario=res.node_resultat or "inconnu", | |
| impact_kg_co2_t=impact_kg_t, | |
| source_db=res.source_db or "", | |
| match_exact=res.match_exact, | |
| pays_production=pays_p or "", | |
| pays_transformation=pays_t or "", | |
| type_mp=type_mp_val, | |
| ) | |
| results_list.append({ | |
| "Type MP": type_mp_val, | |
| "Code MP": code_mp, | |
| "Matière première": mp_name, | |
| "Pays production": pays_p or "", | |
| "Pays transformation": pays_t or "", | |
| "% Appro origine": pct_appro, | |
| "Impact unitaire (kg CO2 eq/t)": round(impact_kg_t, 2) if impact_kg_t else None, | |
| "Zone transport": transport_zone, | |
| "Forfait transport (kg CO2 eq/t)": transport_val, | |
| "Forfait extrusion (kg CO2 eq/t)": extrusion_val if is_extrusion else 0.0, | |
| "Impact total (kg CO2 eq/t)": round(impact_avec_transport, 2) if impact_avec_transport else None, | |
| "Impact pondéré (kg CO2 eq/t)": round(impact_pondere, 2) if impact_pondere else None, | |
| "Proxy utilisé": res.intrant_utilise or "", | |
| "Source": res.source_db or "", | |
| "Scénario (node)": res.node_resultat or "", | |
| "Match exact": "✅" if res.match_exact else "⚠️", | |
| "Extrusion": "✅" if is_extrusion else "", | |
| "Erreur": res.erreur or "", | |
| }) | |
| progress.progress((idx + 1) / total) | |
| st.session_state["formulation_results"] = results_list | |
| # --- Affichage des résultats --- | |
| if "formulation_results" in st.session_state: | |
| results_list = st.session_state["formulation_results"] | |
| df_results = pd.DataFrame(results_list) | |
| st.divider() | |
| st.subheader("📊 Résultats de la formulation") | |
| # Tableau coloré | |
| st.dataframe( | |
| df_results, | |
| width='stretch', | |
| column_config={ | |
| "Impact unitaire (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.2f"), | |
| "Forfait transport (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.0f"), | |
| "Forfait extrusion (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.2f"), | |
| "Impact total (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.2f"), | |
| "Impact pondéré (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.2f"), | |
| "% Appro origine": st.column_config.NumberColumn(format="%.1f"), | |
| }, | |
| ) | |
| # --- Bouton pour proposer des scénarios sur les lignes en erreur --- | |
| df_errors = df_results[df_results["Erreur"].astype(str).str.strip() != ""] | |
| if len(df_errors) > 0: | |
| st.warning( | |
| f"⚠️ **{len(df_errors)} ligne(s)** n'ont pas pu être évaluées. " | |
| "Vous pouvez lancer une recherche d'alternatives pour ces matières." | |
| ) | |
| if st.button( | |
| "🔍 Proposer des scénarios pour les lignes en erreur", | |
| key="btn_scenario_errors", | |
| type="primary", | |
| ): | |
| for _idx, _err_row in df_errors.iterrows(): | |
| _mp_err = _err_row["Matière première"] | |
| _pays_hint = _err_row.get("Pays production", "") or _err_row.get("Pays transformation", "") | |
| st.markdown(f"#### 🔎 Alternatives pour **{_mp_err}**") | |
| with st.spinner(f"Recherche d'alternatives pour {_mp_err}…"): | |
| _alts = llm_service.find_alternative_materials( | |
| _mp_err, | |
| db_name="GFLI", | |
| country_hint=_pays_hint if _pays_hint else None, | |
| ) | |
| if _alts: | |
| _sel = _display_4_alternatives( | |
| _alts, | |
| title=f"🎯 Alternatives pour {_mp_err}", | |
| selection_key=f"form_err_{_idx}", | |
| ) | |
| if _sel: | |
| _sel_impact = _sel["impact"] | |
| _sel_name = _sel["name"] | |
| _sel_source = _sel["source"] | |
| _sel_reasoning = _sel.get("reasoning", "") | |
| # Mettre à jour TOUS les résultats qui correspondent à cette matière | |
| for _r in st.session_state["formulation_results"]: | |
| if _r["Matière première"] == _mp_err and _r.get("Erreur"): | |
| _r["Impact unitaire (kg CO2 eq/t)"] = round(_sel_impact, 2) | |
| # Déterminer la classification : brut si pas de transfo, sinon transformé | |
| _classification = "transforme" if _r.get("Pays transformation") else "brut" | |
| transport_v, _ = _get_transport_surcharge( | |
| pays_production=_r.get("Pays production"), | |
| pays_transformation=_r.get("Pays transformation"), | |
| type_mp=_r.get("Type MP", "vegetal_animal"), | |
| source_db=_r.get("Source"), | |
| classification=_classification, | |
| ) | |
| extr_v = config.FORFAIT_EXTRUSION if _r.get("Extrusion") == "✅" else 0.0 | |
| _r["Impact total (kg CO2 eq/t)"] = round(_sel_impact + transport_v + extr_v, 2) | |
| pct = _r.get("% Appro origine", 100.0) | |
| _r["Impact pondéré (kg CO2 eq/t)"] = round( | |
| (_sel_impact + transport_v + extr_v) * pct / 100.0, 2 | |
| ) | |
| _r["Proxy utilisé"] = _sel_name | |
| _r["Source"] = _sel_source | |
| _r["Match exact"] = "⚠️" | |
| _r["Erreur"] = "" | |
| st.rerun() | |
| else: | |
| st.info(f"Aucune alternative trouvée pour {_mp_err}.") | |
| # --- Résumé par Code MP --- | |
| st.subheader("📋 Résumé par matière première") | |
| df_res = df_results.copy() | |
| summary_rows = [] | |
| for code_mp, group in df_res.groupby("Code MP", sort=False): | |
| mp_name = group["Matière première"].iloc[0] | |
| # Somme des impacts pondérés de toutes les origines pour ce code | |
| total_impact_pondere = group["Impact pondéré (kg CO2 eq/t)"].sum() | |
| # Impact moyen pondéré par appro | |
| impacts_unitaires = group["Impact unitaire (kg CO2 eq/t)"].dropna() | |
| appro_vals = group.loc[impacts_unitaires.index, "% Appro origine"] | |
| if len(impacts_unitaires) > 0 and appro_vals.sum() > 0: | |
| impact_moyen = (impacts_unitaires * appro_vals).sum() / appro_vals.sum() | |
| else: | |
| impact_moyen = None | |
| summary_rows.append({ | |
| "Code MP": code_mp, | |
| "Matière première": mp_name, | |
| "Nb origines": len(group), | |
| "Impact moyen pondéré appro (kg CO2 eq/t)": round(impact_moyen, 2) if impact_moyen else None, | |
| "Contribution (kg CO2 eq/t produit)": round(total_impact_pondere, 2) if total_impact_pondere else None, | |
| }) | |
| df_summary = pd.DataFrame(summary_rows) | |
| st.dataframe(df_summary, width='stretch') | |
| # --- Erreurs & Téléchargement --- | |
| nb_errors = df_results["Erreur"].astype(str).str.strip().ne("").sum() | |
| st.divider() | |
| col_errors, col_download = st.columns(2) | |
| with col_errors: | |
| if nb_errors > 0: | |
| st.warning(f"⚠️ {nb_errors} ligne(s) en erreur (sans impact calculé)") | |
| else: | |
| st.success("✅ Toutes les lignes ont un impact calculé") | |
| with col_download: | |
| csv = df_results.to_csv(index=False).encode("utf-8") | |
| st.download_button( | |
| label="📥 Télécharger les résultats (CSV)", | |
| data=csv, | |
| file_name="formulation_impact_carbone.csv", | |
| mime="text/csv", | |
| key="btn_download_formulation", | |
| ) | |
| # ============================================================================ | |
| # TAB 2 : RECHERCHE UNITAIRE | |
| # ============================================================================ | |
| with tab_single: | |
| st.markdown( | |
| "Renseignez les informations sur votre matière première ci-dessous, " | |
| "puis lancez l'évaluation." | |
| ) | |
| col_form, col_info = st.columns([2, 1]) | |
| with col_form: | |
| st.subheader("📝 Formulaire de saisie") | |
| # --- Type de matière première --- | |
| type_mp = st.radio( | |
| "Type de matière première", | |
| options=[ | |
| "🌾 Végétale / Animale (hors soja)", | |
| "🫘 Dérivé du soja", | |
| "🧪 Minérale / Micro-ingrédient / Additif", | |
| ], | |
| horizontal=True, | |
| key="input_type_mp", | |
| help="Choisissez le type de MP pour appliquer le logigramme adapté.", | |
| ) | |
| # Mapper vers le code interne | |
| TYPE_MAP = { | |
| "🌾 Végétale / Animale (hors soja)": "vegetal_animal", | |
| "🫘 Dérivé du soja": "soja", | |
| "🧪 Minérale / Micro-ingrédient / Additif": "mineral", | |
| } | |
| type_mp_code = TYPE_MAP[type_mp] | |
| matiere = st.text_input( | |
| "Nom de la matière première", | |
| key="input_matiere", | |
| placeholder="Ex : BLE, T.TNSL DEC., ORGE, T. COLZA, LUZERNE, T. SOJA, CARBONATE DE CALCIUM…", | |
| help="Entrez le nom usuel de la matière première.", | |
| ) | |
| provenance_connue = st.radio( | |
| "Connaissez-vous la provenance de la matière première ?", | |
| options=["Oui", "Non"], | |
| horizontal=True, | |
| ) | |
| pays_production = None | |
| pays_transformation = None | |
| if provenance_connue == "Oui": | |
| if type_mp_code == "mineral": | |
| # Pour les minéraux, on demande uniquement le pays de transformation | |
| pays_transformation = st.text_input( | |
| "Pays de transformation", | |
| key="input_pays_transfo", | |
| placeholder="Ex : France, Allemagne, Pays-Bas…", | |
| help="Pays où la transformation du minéral a eu lieu.", | |
| ) | |
| else: | |
| pays_production = st.text_input( | |
| "Pays de production primaire / origine de la graine", | |
| key="input_pays_prod", | |
| placeholder="Ex : France, Brésil, Ukraine…", | |
| help="Pays de production de la MP brute (ou pays d'origine de la graine pour le soja).", | |
| ) | |
| pays_transformation = st.text_input( | |
| "Pays de transformation (laisser vide si MP brute)", | |
| key="input_pays_transfo", | |
| placeholder="Ex : France, Allemagne… (vide si non transformée)", | |
| help="Pays où la transformation a eu lieu. Laissez vide si la matière est brute.", | |
| ) | |
| st.markdown("---") | |
| extrusion_single = st.checkbox( | |
| "🔧 Forfait extrusion (+56,77 kg CO2 eq/t)", | |
| key="input_extrusion", | |
| help="Cocher si la matière première subit une extrusion.", | |
| ) | |
| run_button = st.button( | |
| "🚀 Évaluer l'impact carbone", type="primary", width='stretch' | |
| ) | |
| with col_info: | |
| st.subheader("ℹ️ Règles générales") | |
| st.info(""" | |
| - Le choix des valeurs par défaut relève de la responsabilité des entreprises. | |
| - Il faut attribuer un facteur d'émission à **tous** les intrants. | |
| - Si provenance inconnue → valeur la **plus défavorable**. | |
| - Si provenance connue mais donnée inexistante → donnée générique la **plus pertinente**. | |
| - Bases : **GFLI** et **ECOALIM** en priorité. | |
| """) | |
| # Afficher le logigramme actif | |
| if type_mp_code == "vegetal_animal": | |
| st.success("📋 **Logigramme actif** : MP Végétale / Animale (hors soja)") | |
| st.warning( | |
| "⚠️ **Hors produits dérivés du soja** — " | |
| "Pour les dérivés de soja, sélectionnez le type correspondant." | |
| ) | |
| elif type_mp_code == "soja": | |
| st.success("📋 **Logigramme actif** : MP Dérivée du soja") | |
| st.markdown( | |
| "Ce logigramme est spécifique aux **graines de soja** et à leurs " | |
| "**produits dérivés** (tourteaux, huiles, lécithines…)." | |
| ) | |
| else: | |
| st.success("📋 **Logigramme actif** : MP Minérale / Micro-ingrédient / Additif") | |
| st.markdown( | |
| "Ce logigramme couvre les **minéraux**, **micro-ingrédients**, " | |
| "**vitamines** et **additifs**." | |
| ) | |
| # ======================================================================== | |
| # Exécution et stockage des résultats | |
| # ======================================================================== | |
| if run_button: | |
| if not matiere: | |
| st.error("❌ Veuillez saisir un nom de matière première.") | |
| st.stop() | |
| pays_prod = ( | |
| pays_production.strip() | |
| if pays_production and pays_production.strip() | |
| else None | |
| ) | |
| pays_transfo = ( | |
| pays_transformation.strip() | |
| if pays_transformation and pays_transformation.strip() | |
| else None | |
| ) | |
| with st.spinner( | |
| "🔄 Évaluation en cours… (classification LLM + recherche dans les bases de données)" | |
| ): | |
| result: CarbonResult = evaluate_carbon_impact( | |
| matiere_premiere=matiere.strip(), | |
| pays_production=pays_prod, | |
| pays_transformation=pays_transfo, | |
| type_mp=type_mp_code, | |
| ) | |
| st.session_state["last_result"] = result | |
| st.session_state["last_matiere"] = matiere.strip() | |
| st.session_state.pop("searched_alternatives", None) | |
| # --- Enregistrer le proxy choisi --- | |
| if result.intrant_utilise: | |
| _impact_for_log = None | |
| if result.impact_kg_co2_eq is not None: | |
| _impact_for_log = ( | |
| result.impact_kg_co2_eq | |
| if "tonne" in (result.unite_source or "") | |
| else result.impact_kg_co2_eq * 1000.0 | |
| ) | |
| proxy_log.log_selection( | |
| matiere_recherchee=matiere.strip(), | |
| proxy_choisi=result.intrant_utilise, | |
| scenario=result.node_resultat or "inconnu", | |
| impact_kg_co2_t=_impact_for_log, | |
| source_db=result.source_db or "", | |
| match_exact=result.match_exact, | |
| pays_production=result.pays_production or "", | |
| pays_transformation=result.pays_transformation or "", | |
| type_mp=type_mp_code, | |
| ) | |
| # ======================================================================== | |
| # Affichage des résultats (depuis session_state — persiste entre reruns) | |
| # ======================================================================== | |
| if "last_result" in st.session_state: | |
| result = st.session_state["last_result"] | |
| st.divider() | |
| # Section 0b : 4 alternatives (fallback) | |
| if result.alternatives_combined or result.alternatives_itinerary: | |
| _fallback_sel = _display_4_alternatives( | |
| { | |
| "itinerary": result.alternatives_itinerary, | |
| "locality": result.alternatives_locality, | |
| "form": result.alternatives_form, | |
| "combined": result.alternatives_combined, | |
| }, | |
| title="🎯 4 Alternatives proposées (absence de correspondance)", | |
| selection_key="single_fallback", | |
| ) | |
| if _fallback_sel: | |
| result.impact_kg_co2_eq = _fallback_sel["impact"] | |
| result.impact_tonne_co2_eq = _fallback_sel["impact"] / 1000.0 | |
| result.unite_source = "kg CO2 eq / tonne de produit" | |
| result.source_db = _fallback_sel["source"] | |
| result.intrant_utilise = _fallback_sel["name"] | |
| result.match_exact = False | |
| result.justification_alternative = _fallback_sel.get("reasoning", "") | |
| result.erreur = None | |
| st.session_state["last_result"] = result | |
| # Main layout: Results and Candidates side-by-side | |
| if result.erreur: | |
| st.error(f"❌ {result.erreur}") | |
| else: | |
| # Create two columns for side-by-side display | |
| col_result, col_candidates = st.columns([1.2, 1]) | |
| # Calculer l'impact pour l'affichage | |
| if result.impact_kg_co2_eq is not None: | |
| if "tonne" in (result.unite_source or ""): | |
| impact_kg_t = result.impact_kg_co2_eq | |
| else: | |
| impact_kg_t = result.impact_kg_co2_eq * 1000.0 | |
| # --- Forfait transport --- | |
| transport_val, transport_zone = _get_transport_surcharge( | |
| pays_production=result.pays_production, | |
| pays_transformation=result.pays_transformation, | |
| type_mp=type_mp_code, | |
| source_db=result.source_db, | |
| classification=result.classification, | |
| ) | |
| # --- Forfait extrusion --- | |
| extrusion_val = config.FORFAIT_EXTRUSION if extrusion_single else 0.0 | |
| impact_total = impact_kg_t + transport_val + extrusion_val | |
| # ======================================================================== | |
| # LEFT COLUMN: Résultat de l'impact carbone | |
| # ======================================================================== | |
| with col_result: | |
| with st.container(): | |
| st.markdown("### 📊 Résultat de l'impact carbone") | |
| st.metric( | |
| label="🌍 Impact carbone Total (MP + Transport)", | |
| value=f"{impact_total:.2f}", | |
| delta="kg CO2 eq / t produit", | |
| ) | |
| # Display Source and Intrant utilisé right next to Impact metric | |
| st.markdown(f"<p style='font-size: 18px; margin-top: 8px; margin-bottom: 4px;'><strong>Source:</strong> {result.source_db}</p>", unsafe_allow_html=True) | |
| st.markdown(f"<p style='font-size: 18px; margin-bottom: 12px;'><strong>Intrant utilisé:</strong> {result.intrant_utilise}</p>", unsafe_allow_html=True) | |
| # Match exact and Classification | |
| st.markdown(f"**Match exact:** {'✅ Oui' if result.match_exact else '⚠️ Non'}") | |
| st.markdown(f"**Classification:** {'🏭 Transformé' if result.classification == 'transforme' else '🌾 Brut'}") | |
| # Pays information if available | |
| if result.pays_production: | |
| st.markdown(f"**Pays production:** {result.pays_production}") | |
| if result.pays_transformation: | |
| st.markdown(f"**Pays transformation:** {result.pays_transformation}") | |
| with st.expander("Détails techniques"): | |
| st.markdown(f"**Node résultat :** `{result.node_resultat}`") | |
| st.markdown("---") | |
| # Affichage des impacts | |
| st.markdown("#### 🎯 Impacts calculés") | |
| # Impact de base | |
| st.metric( | |
| label="Impact carbone (MP)", | |
| value=f"{impact_kg_t:.2f} kg CO2 eq / t", | |
| ) | |
| # Forfaits additionnels en colonnes | |
| forfait_cols = st.columns(2) | |
| with forfait_cols[0]: | |
| st.metric( | |
| label=f"🚚 Transport ({transport_zone})", | |
| value=f"+{transport_val:.0f} kg CO2 eq / t", | |
| ) | |
| with forfait_cols[1]: | |
| if extrusion_single: | |
| st.metric( | |
| label="🔧 Extrusion", | |
| value=f"+{extrusion_val:.2f} kg CO2 eq / t", | |
| ) | |
| # ======================================================================== | |
| # RIGHT COLUMN: Produits candidats | |
| # ======================================================================== | |
| with col_candidates: | |
| with st.container(): | |
| st.markdown("### 📋 Autres Alternatives") | |
| if result.candidats_alternatifs: | |
| if not result.match_exact: | |
| st.warning("⚠️ Pas de correspondance exacte. Sélectionnez une alternative ci-dessous.") | |
| else: | |
| st.info("ℹ️ Autres produits disponibles pour votre recherche.") | |
| # Affichage sous forme de tableau amélioré | |
| candidates_data = [] | |
| candidates_map = {} | |
| for idx, cand in enumerate(result.candidats_alternatifs): | |
| nom = cand.get("nom", "") | |
| impact = cand.get("impact", 0) | |
| unite = str(cand.get("unite", "")) | |
| source = cand.get("source", "") | |
| source_upper = source.upper() | |
| is_gfli = "GFLI" in source_upper | |
| if "tonne" in unite or is_gfli: | |
| impact_kg_t = impact | |
| else: | |
| impact_kg_t = impact * 1000.0 | |
| candidates_data.append({ | |
| "Intrant": nom, | |
| "Impact (kg CO2 eq/t)": round(impact_kg_t, 2), | |
| "Base": source if source else "—" | |
| }) | |
| candidates_map[idx] = { | |
| "nom": nom, | |
| "impact": impact_kg_t, | |
| "source": source, | |
| "unite": unite | |
| } | |
| df_candidates = pd.DataFrame(candidates_data) | |
| # Sélection d'une ligne dans le dataframe | |
| st.markdown("**Cliquez sur une ligne pour sélectionner :**") | |
| event = st.dataframe( | |
| df_candidates, | |
| use_container_width=True, | |
| hide_index=True, | |
| on_select="rerun", | |
| selection_mode="single-row", | |
| key="candidate_dataframe", | |
| column_config={ | |
| "Intrant": st.column_config.TextColumn("Intrant", width="large"), | |
| "Impact (kg CO2 eq/t)": st.column_config.NumberColumn( | |
| "Impact (kg CO2 eq/t)", | |
| format="%.2f", | |
| width="medium" | |
| ), | |
| "Base": st.column_config.TextColumn("Base", width="small"), | |
| } | |
| ) | |
| # Récupérer la sélection | |
| selected_rows = event.selection.rows if hasattr(event, 'selection') else [] | |
| if selected_rows: | |
| selected_idx = selected_rows[0] | |
| selected_cand = candidates_map[selected_idx] | |
| st.markdown("---") | |
| st.info(f"**Sélectionné :** {selected_cand['nom']} — {selected_cand['impact']:.2f} kg CO2 eq/t") | |
| if st.button("✅ Valider cette alternative", key="apply_candidate", type="primary", use_container_width=True): | |
| result.impact_kg_co2_eq = selected_cand["impact"] | |
| result.impact_tonne_co2_eq = selected_cand["impact"] / 1000.0 | |
| result.unite_source = "kg CO2 eq / tonne de produit" | |
| result.source_db = selected_cand["source"] | |
| result.intrant_utilise = selected_cand["nom"] | |
| result.match_exact = False | |
| st.session_state["last_result"] = result | |
| st.success(f"✅ Alternative appliquée : **{selected_cand['nom']}** ({selected_cand['impact']:.2f} kg CO2 eq/t)") | |
| st.rerun() | |
| else: | |
| st.caption("👆 Sélectionnez une ligne dans le tableau ci-dessus") | |
| else: | |
| st.info("Aucun produit candidat disponible.") | |
| # Section 3 : Bouton "Chercher une alternative" si match non exact | |
| if not result.match_exact and result.impact_kg_co2_eq is not None: | |
| st.divider() | |
| st.info("💡 La correspondance n'est pas exacte. Vous pouvez chercher d'autres alternatives.") | |
| _c1, _c2, _c3 = st.columns([1, 2, 1]) | |
| with _c2: | |
| if st.button( | |
| "🔍 Chercher une alternative plus proche", | |
| width='stretch', | |
| key="btn_find_alternative", | |
| ): | |
| matiere_search = st.session_state.get("last_matiere", "") | |
| with st.spinner("Recherche des 4 alternatives en cours..."): | |
| db_name = "GFLI" if "GFLI" in (result.source_db or "") else "ECOALIM" | |
| country_hint = result.pays_production or result.pays_transformation | |
| alternatives = llm_service.find_alternative_materials( | |
| matiere_search, | |
| db_name=db_name, | |
| country_hint=country_hint, | |
| ) | |
| if alternatives: | |
| st.session_state["searched_alternatives"] = { | |
| "itinerary": alternatives.get("itinerary"), | |
| "locality": alternatives.get("locality"), | |
| "form": alternatives.get("form"), | |
| "combined": alternatives.get("combined"), | |
| } | |
| st.rerun() | |
| else: | |
| st.error("❌ Pas d'alternatives trouvées.") | |
| # Afficher les alternatives trouvées via le bouton | |
| if "searched_alternatives" in st.session_state: | |
| _searched_sel = _display_4_alternatives( | |
| st.session_state["searched_alternatives"], | |
| title="🎯 Alternatives recherchées", | |
| selection_key="single_searched", | |
| ) | |
| if _searched_sel: | |
| result.impact_kg_co2_eq = _searched_sel["impact"] | |
| result.impact_tonne_co2_eq = _searched_sel["impact"] / 1000.0 | |
| result.unite_source = "kg CO2 eq / tonne de produit" | |
| result.source_db = _searched_sel["source"] | |
| result.intrant_utilise = _searched_sel["name"] | |
| result.match_exact = False | |
| result.justification_alternative = _searched_sel.get("reasoning", "") | |
| result.erreur = None | |
| st.session_state["last_result"] = result | |
| # Section 4 : Parcours du logigramme | |
| st.divider() | |
| st.subheader("🔀 Parcours du logigramme") | |
| for i, step in enumerate(result.parcours): | |
| with st.expander(f"Étape {i+1} — {step.node_id}", expanded=(i == 0)): | |
| if step.question: | |
| st.markdown(f"**Question :** {step.question}") | |
| if step.answer: | |
| st.markdown(f"**Réponse :** {step.answer}") | |
| if step.action: | |
| st.markdown(f"**Action :** {step.action}") | |
| # Section 5 : Actions appliquées | |
| st.subheader("🔍 Détail des recherches effectuées") | |
| for action in result.actions_appliquees: | |
| line = _format_action_line(action) | |
| if line.startswith(" →"): | |
| st.success(line) | |
| else: | |
| st.markdown(f"- {line}") | |
| # Section 6 : Justification si valeur alternative | |
| if result.justification_alternative: | |
| st.subheader("💡 Justification du choix de valeur") | |
| st.info(result.justification_alternative) | |
| # Section 7 : Classification détaillée | |
| with st.expander("📋 Détail de la classification brut/transformé"): | |
| st.markdown(f"**Classification :** {result.classification}") | |
| st.markdown(f"**Justification :** {result.classification_justification}") | |
| # ======================================================================== | |
| # Mode batch (import fichier) | |
| # ======================================================================== | |
| st.divider() | |
| st.subheader("📁 Évaluation par lot (import fichier)") | |
| st.markdown( | |
| "Importez un fichier Excel avec les colonnes : " | |
| "`Matière première`, `Pays de la production primaire de la MP brute`, " | |
| "`Pays de la transformation`" | |
| ) | |
| uploaded_file = st.file_uploader("Choisir un fichier Excel", type=["xlsx", "xls"]) | |
| if uploaded_file is not None: | |
| try: | |
| df_input = pd.read_excel(uploaded_file) | |
| st.dataframe(df_input, width='stretch') | |
| col_mapping = {} | |
| for col in df_input.columns: | |
| col_lower = str(col).lower() | |
| if "matière" in col_lower or "matiere" in col_lower or "mp" in col_lower: | |
| col_mapping["matiere"] = col | |
| elif ("production" in col_lower or "pays" in col_lower) and "transf" not in col_lower: | |
| if "matiere" not in col_lower: | |
| col_mapping["pays_prod"] = col | |
| elif "transf" in col_lower: | |
| col_mapping["pays_transfo"] = col | |
| if "matiere" not in col_mapping: | |
| st.warning("⚠️ Colonne 'Matière première' non détectée. Vérifiez les noms de colonnes.") | |
| else: | |
| if st.button("🚀 Lancer l'évaluation par lot", type="primary"): | |
| results_list = [] | |
| progress_bar = st.progress(0) | |
| for idx, row in df_input.iterrows(): | |
| mp = str(row.get(col_mapping["matiere"], "")).strip() | |
| if not mp or mp == "nan": | |
| continue | |
| pays_p = str(row.get(col_mapping.get("pays_prod", ""), "")).strip() | |
| pays_t = str(row.get(col_mapping.get("pays_transfo", ""), "")).strip() | |
| pays_p = pays_p if pays_p and pays_p != "nan" else None | |
| pays_t = pays_t if pays_t and pays_t != "nan" else None | |
| with st.spinner(f"Évaluation de {mp}..."): | |
| res = evaluate_carbon_impact(mp, pays_p, pays_t) | |
| # --- Enregistrer le proxy choisi (batch) --- | |
| if res.intrant_utilise: | |
| _batch_impact = None | |
| if res.impact_kg_co2_eq is not None: | |
| _batch_impact = ( | |
| res.impact_kg_co2_eq | |
| if "tonne" in (res.unite_source or "") | |
| else res.impact_kg_co2_eq * 1000.0 | |
| ) | |
| proxy_log.log_selection( | |
| matiere_recherchee=mp, | |
| proxy_choisi=res.intrant_utilise, | |
| scenario=res.node_resultat or "inconnu", | |
| impact_kg_co2_t=_batch_impact, | |
| source_db=res.source_db or "", | |
| match_exact=res.match_exact, | |
| pays_production=pays_p or "", | |
| pays_transformation=pays_t or "", | |
| ) | |
| results_list.append({ | |
| "Matière première": mp, | |
| "Pays production": pays_p or "", | |
| "Pays transformation": pays_t or "", | |
| "Classification": res.classification, | |
| "Impact (kg CO2 eq / t)": ( | |
| res.impact_kg_co2_eq | |
| if res.impact_kg_co2_eq is None | |
| else ( | |
| res.impact_kg_co2_eq | |
| if "tonne" in (res.unite_source or "") | |
| else res.impact_kg_co2_eq * 1000.0 | |
| ) | |
| ), | |
| "Unité": "kg CO2 eq / t produit", | |
| "Source": res.source_db, | |
| "Intrant utilisé": res.intrant_utilise, | |
| "Match exact": "Oui" if res.match_exact else "Non", | |
| "Node résultat": res.node_resultat, | |
| "Justification": res.justification_alternative or "", | |
| "Erreur": res.erreur or "", | |
| }) | |
| progress_bar.progress((idx + 1) / len(df_input)) | |
| if results_list: | |
| df_results = pd.DataFrame(results_list) | |
| st.subheader("📊 Résultats du lot") | |
| st.dataframe(df_results, width='stretch') | |
| csv = df_results.to_csv(index=False).encode("utf-8") | |
| st.download_button( | |
| label="📥 Télécharger les résultats (CSV)", | |
| data=csv, | |
| file_name="resultats_impact_carbone.csv", | |
| mime="text/csv", | |
| ) | |
| except Exception as e: | |
| st.error(f"Erreur lors de la lecture du fichier : {e}") | |
| # ============================================================================ | |
| # TAB 3 : STATISTIQUES PROXIES | |
| # ============================================================================ | |
| with tab_stats: | |
| st.subheader("📈 Statistiques des proxies sélectionnés") | |
| st.markdown( | |
| "Ce tableau recense les **intrants (proxies)** choisis lors des évaluations, " | |
| "avec le nombre de fois qu'ils ont été sélectionnés." | |
| ) | |
| # --- Filtre temporel --- | |
| col_filter, _ = st.columns([1, 3]) | |
| with col_filter: | |
| period = st.selectbox( | |
| "Période", | |
| options=["Tout", "7 derniers jours", "30 derniers jours", "90 derniers jours"], | |
| index=0, | |
| key="stats_period", | |
| ) | |
| days_map = {"Tout": None, "7 derniers jours": 7, "30 derniers jours": 30, "90 derniers jours": 90} | |
| selected_days = days_map[period] | |
| # --- Top proxies --- | |
| st.markdown("### 🏆 Top proxies les plus choisis") | |
| df_top_proxies = proxy_log.top_proxies(n=30, days=selected_days) | |
| if df_top_proxies.empty: | |
| st.info("Aucune sélection enregistrée pour le moment.") | |
| else: | |
| st.dataframe( | |
| df_top_proxies, | |
| width='stretch', | |
| column_config={ | |
| "proxy_choisi": st.column_config.TextColumn("Intrant / Proxy", width="large"), | |
| "nb_selections": st.column_config.NumberColumn("Nb sélections", format="%d"), | |
| "dernière_utilisation": st.column_config.DatetimeColumn( | |
| "Dernière utilisation", format="DD/MM/YYYY HH:mm" | |
| ), | |
| }, | |
| ) | |
| st.divider() | |
| # --- Top scénarios --- | |
| st.markdown("### 🔀 Top scénarios (nodes résultat)") | |
| df_top_scenarios = proxy_log.top_scenarios(n=20, days=selected_days) | |
| if df_top_scenarios.empty: | |
| st.info("Aucune sélection enregistrée pour le moment.") | |
| else: | |
| st.dataframe( | |
| df_top_scenarios, | |
| width='stretch', | |
| column_config={ | |
| "scenario": st.column_config.TextColumn("Scénario (node)", width="medium"), | |
| "nb_selections": st.column_config.NumberColumn("Nb sélections", format="%d"), | |
| "dernière_utilisation": st.column_config.DatetimeColumn( | |
| "Dernière utilisation", format="DD/MM/YYYY HH:mm" | |
| ), | |
| }, | |
| ) | |
| st.divider() | |
| # --- Journal complet --- | |
| with st.expander("📋 Journal complet des sélections", expanded=False): | |
| df_full = proxy_log.load_log() | |
| if df_full.empty: | |
| st.info("Aucune sélection enregistrée.") | |
| else: | |
| st.dataframe(df_full.sort_values("timestamp", ascending=False), width='stretch') | |
| csv_log = df_full.to_csv(index=False).encode("utf-8") | |
| st.download_button( | |
| label="📥 Télécharger le journal (CSV)", | |
| data=csv_log, | |
| file_name="proxy_selections_log.csv", | |
| mime="text/csv", | |
| key="btn_download_proxy_log", | |
| ) | |
| # ============================================================================ | |
| # Footer | |
| # ============================================================================ | |
| st.divider() | |
| st.caption( | |
| "POC GAIA — Outil d'aide à la détermination de l'impact carbone des matières premières " | |
| "pour aliments composés. Basé sur le Guide GT Carbone, les bases ECOALIM v9 et GFLI 2.0. " | |
| "Classification brut/transformé assistée par Mistral AI et le Catalogue UE des Matières Premières." | |
| ) | |