Spaces:
Sleeping
Sleeping
Commit ·
13bb974
1
Parent(s): f6b9880
Modifs J2 (#9)
Browse files- modifications fichiers (181215fa357447fd447554c01d6383df7397a221)
- update layout (058b0ec65a4d420604eab0a217a02bf37f6fa7b0)
- src/app.py +270 -31
- src/config.py +3 -2
- src/logigramme.json +8 -11
- src/proxy_log.py +150 -0
src/app.py
CHANGED
|
@@ -5,13 +5,19 @@ des matières premières pour aliments composés.
|
|
| 5 |
Lancement : streamlit run app.py
|
| 6 |
"""
|
| 7 |
import re
|
|
|
|
|
|
|
| 8 |
import streamlit as st
|
| 9 |
import pandas as pd
|
| 10 |
|
|
|
|
|
|
|
|
|
|
| 11 |
from flowchart_engine import evaluate_carbon_impact, CarbonResult
|
| 12 |
import llm_service
|
| 13 |
import data_loader
|
| 14 |
import config
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
# ============================================================================
|
|
@@ -120,16 +126,18 @@ st.divider()
|
|
| 120 |
# ============================================================================
|
| 121 |
# ONGLETS PRINCIPAUX
|
| 122 |
# ============================================================================
|
| 123 |
-
tab_formulation, tab_single = st.tabs(
|
|
|
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
# ============================================================================
|
| 127 |
# TAB 1 : FORMULATION PRODUIT
|
| 128 |
# ============================================================================
|
| 129 |
with tab_formulation:
|
| 130 |
-
st.subheader("📊
|
| 131 |
st.markdown("""
|
| 132 |
-
Remplissez le tableau ci-dessous avec les matières premières
|
| 133 |
- **Code MP** : code interne de la matière première
|
| 134 |
- **Matière première** : nom usuel
|
| 135 |
- **Type MP** : Végétale/Animale, Soja ou Minérale (détermine le logigramme)
|
|
@@ -138,32 +146,79 @@ with tab_formulation:
|
|
| 138 |
""")
|
| 139 |
|
| 140 |
# --- Initialiser le DataFrame éditable dans session_state ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
if "formulation_df" not in st.session_state:
|
| 142 |
-
st.session_state["formulation_df"] = pd.DataFrame(
|
| 143 |
-
"Code MP": ["", "", "", "", ""],
|
| 144 |
-
"Matière première": ["", "", "", "", ""],
|
| 145 |
-
"Type MP": ["vegetal_animal", "vegetal_animal", "vegetal_animal", "vegetal_animal", "vegetal_animal"],
|
| 146 |
-
"Pays production": ["", "", "", "", ""],
|
| 147 |
-
"Pays transformation": ["", "", "", "", ""],
|
| 148 |
-
"% Appro origine": [100.0, 100.0, 100.0, 100.0, 100.0],
|
| 149 |
-
})
|
| 150 |
|
| 151 |
# --- Boutons d'action ---
|
| 152 |
-
col_add, col_clear, _ = st.columns([1, 1,
|
| 153 |
with col_add:
|
| 154 |
if st.button("➕ Ajouter une ligne", key="btn_add_row"):
|
| 155 |
-
new_row = pd.DataFrame({
|
| 156 |
-
"Code MP": [""],
|
| 157 |
-
"Matière première": [""],
|
| 158 |
-
"Type MP": ["vegetal_animal"],
|
| 159 |
-
"Pays production": [""],
|
| 160 |
-
"Pays transformation": [""],
|
| 161 |
-
"% Appro origine": [100.0],
|
| 162 |
-
})
|
| 163 |
st.session_state["formulation_df"] = pd.concat(
|
| 164 |
-
[st.session_state["formulation_df"],
|
|
|
|
| 165 |
)
|
| 166 |
st.rerun()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
with col_clear:
|
| 168 |
if st.button("🗑️ Réinitialiser", key="btn_clear_form"):
|
| 169 |
st.session_state.pop("formulation_df", None)
|
|
@@ -187,14 +242,13 @@ with tab_formulation:
|
|
| 187 |
),
|
| 188 |
"Pays production": st.column_config.TextColumn("Pays production", width="medium"),
|
| 189 |
"Pays transformation": st.column_config.TextColumn("Pays transformation", width="medium"),
|
|
|
|
| 190 |
"% Appro origine": st.column_config.NumberColumn("% Appro origine", min_value=0, max_value=100, step=0.1, format="%.1f"),
|
| 191 |
},
|
| 192 |
)
|
| 193 |
# Synchroniser les éditions
|
| 194 |
st.session_state["formulation_df"] = edited_df
|
| 195 |
|
| 196 |
-
# --- Bouton calcul ---
|
| 197 |
-
st.markdown("---")
|
| 198 |
if st.button("🚀 Calculer l'impact de la formulation", type="primary", use_container_width=True, key="btn_calc_formulation"):
|
| 199 |
# Filtrer les lignes valides
|
| 200 |
rows_to_eval = edited_df[edited_df["Matière première"].astype(str).str.strip() != ""].copy()
|
|
@@ -235,18 +289,36 @@ with tab_formulation:
|
|
| 235 |
# Forfait transport
|
| 236 |
transport_val, transport_zone = _get_transport_surcharge(pays_p)
|
| 237 |
|
| 238 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
if impact_kg_t is not None:
|
| 240 |
-
impact_avec_transport = impact_kg_t + transport_val
|
| 241 |
else:
|
| 242 |
impact_avec_transport = None
|
| 243 |
|
| 244 |
-
# Impact pondéré = (impact + transport) × (% appro / 100)
|
| 245 |
if impact_avec_transport is not None:
|
| 246 |
impact_pondere = impact_avec_transport * (pct_appro / 100.0)
|
| 247 |
else:
|
| 248 |
impact_pondere = None
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
results_list.append({
|
| 251 |
"Code MP": code_mp,
|
| 252 |
"Matière première": mp_name,
|
|
@@ -257,10 +329,13 @@ with tab_formulation:
|
|
| 257 |
"Impact unitaire (kg CO2 eq/t)": round(impact_kg_t, 2) if impact_kg_t else None,
|
| 258 |
"Zone transport": transport_zone,
|
| 259 |
"Forfait transport (kg CO2 eq/t)": transport_val,
|
| 260 |
-
"
|
|
|
|
|
|
|
| 261 |
"Impact pondéré (kg CO2 eq/t)": round(impact_pondere, 2) if impact_pondere else None,
|
| 262 |
-
"
|
| 263 |
"Source": res.source_db or "",
|
|
|
|
| 264 |
"Match exact": "✅" if res.match_exact else "⚠️",
|
| 265 |
"Erreur": res.erreur or "",
|
| 266 |
})
|
|
@@ -284,12 +359,40 @@ with tab_formulation:
|
|
| 284 |
column_config={
|
| 285 |
"Impact unitaire (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.2f"),
|
| 286 |
"Forfait transport (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.0f"),
|
| 287 |
-
"
|
|
|
|
| 288 |
"Impact pondéré (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.2f"),
|
| 289 |
"% Appro origine": st.column_config.NumberColumn(format="%.1f"),
|
| 290 |
},
|
| 291 |
)
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
# --- Résumé par Code MP ---
|
| 294 |
st.subheader("📋 Résumé par matière première")
|
| 295 |
|
|
@@ -417,6 +520,11 @@ with tab_single:
|
|
| 417 |
)
|
| 418 |
|
| 419 |
st.markdown("---")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
run_button = st.button(
|
| 421 |
"🚀 Évaluer l'impact carbone", type="primary", use_container_width=True
|
| 422 |
)
|
|
@@ -484,6 +592,27 @@ with tab_single:
|
|
| 484 |
st.session_state["last_matiere"] = matiere.strip()
|
| 485 |
st.session_state.pop("searched_alternatives", None)
|
| 486 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
# ========================================================================
|
| 488 |
# Affichage des résultats (depuis session_state — persiste entre reruns)
|
| 489 |
# ========================================================================
|
|
@@ -567,15 +696,25 @@ with tab_single:
|
|
| 567 |
transport_val, transport_zone = _get_transport_surcharge(
|
| 568 |
result.pays_production
|
| 569 |
)
|
| 570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
|
| 572 |
st.metric(
|
| 573 |
label=f"🚚 Forfait transport ({transport_zone})",
|
| 574 |
value=f"+{transport_val:.0f}",
|
| 575 |
delta="kg CO2 eq / t",
|
| 576 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
st.metric(
|
| 578 |
-
label="🌍 Impact TOTAL (MP + transport)",
|
| 579 |
value=f"{impact_total:.2f}",
|
| 580 |
delta="kg CO2 eq / t produit",
|
| 581 |
)
|
|
@@ -720,6 +859,26 @@ with tab_single:
|
|
| 720 |
with st.spinner(f"Évaluation de {mp}..."):
|
| 721 |
res = evaluate_carbon_impact(mp, pays_p, pays_t)
|
| 722 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
results_list.append({
|
| 724 |
"Matière première": mp,
|
| 725 |
"Pays production": pays_p or "",
|
|
@@ -762,6 +921,86 @@ with tab_single:
|
|
| 762 |
st.error(f"Erreur lors de la lecture du fichier : {e}")
|
| 763 |
|
| 764 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
# ============================================================================
|
| 766 |
# Footer
|
| 767 |
# ============================================================================
|
|
|
|
| 5 |
Lancement : streamlit run app.py
|
| 6 |
"""
|
| 7 |
import re
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
import streamlit as st
|
| 11 |
import pandas as pd
|
| 12 |
|
| 13 |
+
# Ajouter le dossier src/ au path pour les imports
|
| 14 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
|
| 15 |
+
|
| 16 |
from flowchart_engine import evaluate_carbon_impact, CarbonResult
|
| 17 |
import llm_service
|
| 18 |
import data_loader
|
| 19 |
import config
|
| 20 |
+
import proxy_log
|
| 21 |
|
| 22 |
|
| 23 |
# ============================================================================
|
|
|
|
| 126 |
# ============================================================================
|
| 127 |
# ONGLETS PRINCIPAUX
|
| 128 |
# ============================================================================
|
| 129 |
+
tab_formulation, tab_single, tab_stats = st.tabs(
|
| 130 |
+
["📊 Calcul par liste de MP", "🔍 Calcul unitaire", "📈 Statistiques proxies"]
|
| 131 |
+
)
|
| 132 |
|
| 133 |
|
| 134 |
# ============================================================================
|
| 135 |
# TAB 1 : FORMULATION PRODUIT
|
| 136 |
# ============================================================================
|
| 137 |
with tab_formulation:
|
| 138 |
+
st.subheader("📊 Calcul d'impact par liste de matières premières")
|
| 139 |
st.markdown("""
|
| 140 |
+
Remplissez le tableau ci-dessous avec les matières premières.
|
| 141 |
- **Code MP** : code interne de la matière première
|
| 142 |
- **Matière première** : nom usuel
|
| 143 |
- **Type MP** : Végétale/Animale, Soja ou Minérale (détermine le logigramme)
|
|
|
|
| 146 |
""")
|
| 147 |
|
| 148 |
# --- Initialiser le DataFrame éditable dans session_state ---
|
| 149 |
+
_EMPTY_ROW = {
|
| 150 |
+
"Code MP": "",
|
| 151 |
+
"Matière première": "",
|
| 152 |
+
"Type MP": "vegetal_animal",
|
| 153 |
+
"Pays production": "",
|
| 154 |
+
"Pays transformation": "",
|
| 155 |
+
"Extrusion": False,
|
| 156 |
+
"% Appro origine": 100.0,
|
| 157 |
+
}
|
| 158 |
if "formulation_df" not in st.session_state:
|
| 159 |
+
st.session_state["formulation_df"] = pd.DataFrame([_EMPTY_ROW.copy() for _ in range(5)])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
# --- Boutons d'action ---
|
| 162 |
+
col_add, col_del, col_import, col_clear, _ = st.columns([1, 1, 1, 1, 2])
|
| 163 |
with col_add:
|
| 164 |
if st.button("➕ Ajouter une ligne", key="btn_add_row"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
st.session_state["formulation_df"] = pd.concat(
|
| 166 |
+
[st.session_state["formulation_df"], pd.DataFrame([_EMPTY_ROW.copy()])],
|
| 167 |
+
ignore_index=True,
|
| 168 |
)
|
| 169 |
st.rerun()
|
| 170 |
+
with col_del:
|
| 171 |
+
if st.button("➖ Retirer dernière ligne", key="btn_del_row"):
|
| 172 |
+
if len(st.session_state["formulation_df"]) > 1:
|
| 173 |
+
st.session_state["formulation_df"] = (
|
| 174 |
+
st.session_state["formulation_df"].iloc[:-1].reset_index(drop=True)
|
| 175 |
+
)
|
| 176 |
+
st.rerun()
|
| 177 |
+
else:
|
| 178 |
+
st.warning("⚠️ Le tableau doit contenir au moins une ligne.")
|
| 179 |
+
with col_import:
|
| 180 |
+
_import_file = st.file_uploader(
|
| 181 |
+
"📥 Importer tableau Excel",
|
| 182 |
+
type=["xlsx", "xls"],
|
| 183 |
+
key="btn_import_formulation",
|
| 184 |
+
label_visibility="collapsed",
|
| 185 |
+
)
|
| 186 |
+
if _import_file is not None:
|
| 187 |
+
try:
|
| 188 |
+
_df_imp = pd.read_excel(_import_file)
|
| 189 |
+
# Mapper les colonnes connues
|
| 190 |
+
_col_map = {}
|
| 191 |
+
for _c in _df_imp.columns:
|
| 192 |
+
_cl = str(_c).lower()
|
| 193 |
+
if "code" in _cl:
|
| 194 |
+
_col_map["Code MP"] = _c
|
| 195 |
+
elif "matière" in _cl or "matiere" in _cl or ("mp" in _cl and "code" not in _cl):
|
| 196 |
+
_col_map["Matière première"] = _c
|
| 197 |
+
elif "type" in _cl:
|
| 198 |
+
_col_map["Type MP"] = _c
|
| 199 |
+
elif "production" in _cl or ("pays" in _cl and "transf" not in _cl):
|
| 200 |
+
_col_map["Pays production"] = _c
|
| 201 |
+
elif "transf" in _cl:
|
| 202 |
+
_col_map["Pays transformation"] = _c
|
| 203 |
+
elif "extru" in _cl:
|
| 204 |
+
_col_map["Extrusion"] = _c
|
| 205 |
+
elif "appro" in _cl or "%" in _cl:
|
| 206 |
+
_col_map["% Appro origine"] = _c
|
| 207 |
+
_new_df = pd.DataFrame([_EMPTY_ROW.copy() for _ in range(len(_df_imp))])
|
| 208 |
+
for target, src in _col_map.items():
|
| 209 |
+
_new_df[target] = _df_imp[src].astype(str).fillna("")
|
| 210 |
+
if "% Appro origine" in _col_map:
|
| 211 |
+
_new_df["% Appro origine"] = pd.to_numeric(
|
| 212 |
+
_df_imp[_col_map["% Appro origine"]], errors="coerce"
|
| 213 |
+
).fillna(100.0)
|
| 214 |
+
if "Extrusion" in _col_map:
|
| 215 |
+
_new_df["Extrusion"] = _df_imp[_col_map["Extrusion"]].astype(bool)
|
| 216 |
+
st.session_state["formulation_df"] = _new_df
|
| 217 |
+
st.session_state.pop("formulation_results", None)
|
| 218 |
+
st.success(f"✅ {len(_new_df)} lignes importées.")
|
| 219 |
+
st.rerun()
|
| 220 |
+
except Exception as _e:
|
| 221 |
+
st.error(f"Erreur d'import : {_e}")
|
| 222 |
with col_clear:
|
| 223 |
if st.button("🗑️ Réinitialiser", key="btn_clear_form"):
|
| 224 |
st.session_state.pop("formulation_df", None)
|
|
|
|
| 242 |
),
|
| 243 |
"Pays production": st.column_config.TextColumn("Pays production", width="medium"),
|
| 244 |
"Pays transformation": st.column_config.TextColumn("Pays transformation", width="medium"),
|
| 245 |
+
"Extrusion": st.column_config.CheckboxColumn("Extrusion", help="Cocher si la MP subit une extrusion (+56,77 kg CO2 eq/t)", width="small"),
|
| 246 |
"% Appro origine": st.column_config.NumberColumn("% Appro origine", min_value=0, max_value=100, step=0.1, format="%.1f"),
|
| 247 |
},
|
| 248 |
)
|
| 249 |
# Synchroniser les éditions
|
| 250 |
st.session_state["formulation_df"] = edited_df
|
| 251 |
|
|
|
|
|
|
|
| 252 |
if st.button("🚀 Calculer l'impact de la formulation", type="primary", use_container_width=True, key="btn_calc_formulation"):
|
| 253 |
# Filtrer les lignes valides
|
| 254 |
rows_to_eval = edited_df[edited_df["Matière première"].astype(str).str.strip() != ""].copy()
|
|
|
|
| 289 |
# Forfait transport
|
| 290 |
transport_val, transport_zone = _get_transport_surcharge(pays_p)
|
| 291 |
|
| 292 |
+
# Forfait extrusion
|
| 293 |
+
is_extrusion = bool(row.get("Extrusion", False))
|
| 294 |
+
extrusion_val = config.FORFAIT_EXTRUSION if is_extrusion else 0.0
|
| 295 |
+
|
| 296 |
+
# Impact total = impact unitaire + transport + extrusion
|
| 297 |
if impact_kg_t is not None:
|
| 298 |
+
impact_avec_transport = impact_kg_t + transport_val + extrusion_val
|
| 299 |
else:
|
| 300 |
impact_avec_transport = None
|
| 301 |
|
| 302 |
+
# Impact pondéré = (impact + transport + extrusion) × (% appro / 100)
|
| 303 |
if impact_avec_transport is not None:
|
| 304 |
impact_pondere = impact_avec_transport * (pct_appro / 100.0)
|
| 305 |
else:
|
| 306 |
impact_pondere = None
|
| 307 |
|
| 308 |
+
# --- Enregistrer le proxy choisi (formulation) ---
|
| 309 |
+
if res.intrant_utilise:
|
| 310 |
+
proxy_log.log_selection(
|
| 311 |
+
matiere_recherchee=mp_name,
|
| 312 |
+
proxy_choisi=res.intrant_utilise,
|
| 313 |
+
scenario=res.node_resultat or "inconnu",
|
| 314 |
+
impact_kg_co2_t=impact_kg_t,
|
| 315 |
+
source_db=res.source_db or "",
|
| 316 |
+
match_exact=res.match_exact,
|
| 317 |
+
pays_production=pays_p or "",
|
| 318 |
+
pays_transformation=pays_t or "",
|
| 319 |
+
type_mp=type_mp_val,
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
results_list.append({
|
| 323 |
"Code MP": code_mp,
|
| 324 |
"Matière première": mp_name,
|
|
|
|
| 329 |
"Impact unitaire (kg CO2 eq/t)": round(impact_kg_t, 2) if impact_kg_t else None,
|
| 330 |
"Zone transport": transport_zone,
|
| 331 |
"Forfait transport (kg CO2 eq/t)": transport_val,
|
| 332 |
+
"Extrusion": "✅" if is_extrusion else "",
|
| 333 |
+
"Forfait extrusion (kg CO2 eq/t)": extrusion_val if is_extrusion else 0.0,
|
| 334 |
+
"Impact total (kg CO2 eq/t)": round(impact_avec_transport, 2) if impact_avec_transport else None,
|
| 335 |
"Impact pondéré (kg CO2 eq/t)": round(impact_pondere, 2) if impact_pondere else None,
|
| 336 |
+
"Proxy utilisé": res.intrant_utilise or "",
|
| 337 |
"Source": res.source_db or "",
|
| 338 |
+
"Scénario (node)": res.node_resultat or "",
|
| 339 |
"Match exact": "✅" if res.match_exact else "⚠️",
|
| 340 |
"Erreur": res.erreur or "",
|
| 341 |
})
|
|
|
|
| 359 |
column_config={
|
| 360 |
"Impact unitaire (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.2f"),
|
| 361 |
"Forfait transport (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.0f"),
|
| 362 |
+
"Forfait extrusion (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.2f"),
|
| 363 |
+
"Impact total (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.2f"),
|
| 364 |
"Impact pondéré (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.2f"),
|
| 365 |
"% Appro origine": st.column_config.NumberColumn(format="%.1f"),
|
| 366 |
},
|
| 367 |
)
|
| 368 |
|
| 369 |
+
# --- Bouton pour proposer des scénarios sur les lignes en erreur ---
|
| 370 |
+
df_errors = df_results[df_results["Erreur"].astype(str).str.strip() != ""]
|
| 371 |
+
if len(df_errors) > 0:
|
| 372 |
+
st.warning(
|
| 373 |
+
f"⚠️ **{len(df_errors)} ligne(s)** n'ont pas pu être évaluées. "
|
| 374 |
+
"Vous pouvez lancer une recherche d'alternatives pour ces matières."
|
| 375 |
+
)
|
| 376 |
+
if st.button(
|
| 377 |
+
"🔍 Proposer des scénarios pour les lignes en erreur",
|
| 378 |
+
key="btn_scenario_errors",
|
| 379 |
+
type="primary",
|
| 380 |
+
):
|
| 381 |
+
for _idx, _err_row in df_errors.iterrows():
|
| 382 |
+
_mp_err = _err_row["Matière première"]
|
| 383 |
+
_pays_hint = _err_row.get("Pays production", "") or _err_row.get("Pays transformation", "")
|
| 384 |
+
st.markdown(f"#### 🔎 Alternatives pour **{_mp_err}**")
|
| 385 |
+
with st.spinner(f"Recherche d'alternatives pour {_mp_err}…"):
|
| 386 |
+
_alts = llm_service.find_alternative_materials(
|
| 387 |
+
_mp_err,
|
| 388 |
+
db_name="GFLI",
|
| 389 |
+
country_hint=_pays_hint if _pays_hint else None,
|
| 390 |
+
)
|
| 391 |
+
if _alts:
|
| 392 |
+
_display_4_alternatives(_alts, title=f"🎯 Alternatives pour {_mp_err}")
|
| 393 |
+
else:
|
| 394 |
+
st.info(f"Aucune alternative trouvée pour {_mp_err}.")
|
| 395 |
+
|
| 396 |
# --- Résumé par Code MP ---
|
| 397 |
st.subheader("📋 Résumé par matière première")
|
| 398 |
|
|
|
|
| 520 |
)
|
| 521 |
|
| 522 |
st.markdown("---")
|
| 523 |
+
extrusion_single = st.checkbox(
|
| 524 |
+
"🔧 Forfait extrusion (+56,77 kg CO2 eq/t)",
|
| 525 |
+
key="input_extrusion",
|
| 526 |
+
help="Cocher si la matière première subit une extrusion.",
|
| 527 |
+
)
|
| 528 |
run_button = st.button(
|
| 529 |
"🚀 Évaluer l'impact carbone", type="primary", use_container_width=True
|
| 530 |
)
|
|
|
|
| 592 |
st.session_state["last_matiere"] = matiere.strip()
|
| 593 |
st.session_state.pop("searched_alternatives", None)
|
| 594 |
|
| 595 |
+
# --- Enregistrer le proxy choisi ---
|
| 596 |
+
if result.intrant_utilise:
|
| 597 |
+
_impact_for_log = None
|
| 598 |
+
if result.impact_kg_co2_eq is not None:
|
| 599 |
+
_impact_for_log = (
|
| 600 |
+
result.impact_kg_co2_eq
|
| 601 |
+
if "tonne" in (result.unite_source or "")
|
| 602 |
+
else result.impact_kg_co2_eq * 1000.0
|
| 603 |
+
)
|
| 604 |
+
proxy_log.log_selection(
|
| 605 |
+
matiere_recherchee=matiere.strip(),
|
| 606 |
+
proxy_choisi=result.intrant_utilise,
|
| 607 |
+
scenario=result.node_resultat or "inconnu",
|
| 608 |
+
impact_kg_co2_t=_impact_for_log,
|
| 609 |
+
source_db=result.source_db or "",
|
| 610 |
+
match_exact=result.match_exact,
|
| 611 |
+
pays_production=result.pays_production or "",
|
| 612 |
+
pays_transformation=result.pays_transformation or "",
|
| 613 |
+
type_mp=type_mp_code,
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
# ========================================================================
|
| 617 |
# Affichage des résultats (depuis session_state — persiste entre reruns)
|
| 618 |
# ========================================================================
|
|
|
|
| 696 |
transport_val, transport_zone = _get_transport_surcharge(
|
| 697 |
result.pays_production
|
| 698 |
)
|
| 699 |
+
|
| 700 |
+
# --- Forfait extrusion ---
|
| 701 |
+
extrusion_val = config.FORFAIT_EXTRUSION if extrusion_single else 0.0
|
| 702 |
+
|
| 703 |
+
impact_total = impact_kg_t + transport_val + extrusion_val
|
| 704 |
|
| 705 |
st.metric(
|
| 706 |
label=f"🚚 Forfait transport ({transport_zone})",
|
| 707 |
value=f"+{transport_val:.0f}",
|
| 708 |
delta="kg CO2 eq / t",
|
| 709 |
)
|
| 710 |
+
if extrusion_single:
|
| 711 |
+
st.metric(
|
| 712 |
+
label="🔧 Forfait extrusion",
|
| 713 |
+
value=f"+{extrusion_val:.2f}",
|
| 714 |
+
delta="kg CO2 eq / t",
|
| 715 |
+
)
|
| 716 |
st.metric(
|
| 717 |
+
label="🌍 Impact TOTAL (MP + transport" + (" + extrusion)" if extrusion_single else ")"),
|
| 718 |
value=f"{impact_total:.2f}",
|
| 719 |
delta="kg CO2 eq / t produit",
|
| 720 |
)
|
|
|
|
| 859 |
with st.spinner(f"Évaluation de {mp}..."):
|
| 860 |
res = evaluate_carbon_impact(mp, pays_p, pays_t)
|
| 861 |
|
| 862 |
+
# --- Enregistrer le proxy choisi (batch) ---
|
| 863 |
+
if res.intrant_utilise:
|
| 864 |
+
_batch_impact = None
|
| 865 |
+
if res.impact_kg_co2_eq is not None:
|
| 866 |
+
_batch_impact = (
|
| 867 |
+
res.impact_kg_co2_eq
|
| 868 |
+
if "tonne" in (res.unite_source or "")
|
| 869 |
+
else res.impact_kg_co2_eq * 1000.0
|
| 870 |
+
)
|
| 871 |
+
proxy_log.log_selection(
|
| 872 |
+
matiere_recherchee=mp,
|
| 873 |
+
proxy_choisi=res.intrant_utilise,
|
| 874 |
+
scenario=res.node_resultat or "inconnu",
|
| 875 |
+
impact_kg_co2_t=_batch_impact,
|
| 876 |
+
source_db=res.source_db or "",
|
| 877 |
+
match_exact=res.match_exact,
|
| 878 |
+
pays_production=pays_p or "",
|
| 879 |
+
pays_transformation=pays_t or "",
|
| 880 |
+
)
|
| 881 |
+
|
| 882 |
results_list.append({
|
| 883 |
"Matière première": mp,
|
| 884 |
"Pays production": pays_p or "",
|
|
|
|
| 921 |
st.error(f"Erreur lors de la lecture du fichier : {e}")
|
| 922 |
|
| 923 |
|
| 924 |
+
# ============================================================================
|
| 925 |
+
# TAB 3 : STATISTIQUES PROXIES
|
| 926 |
+
# ============================================================================
|
| 927 |
+
with tab_stats:
|
| 928 |
+
st.subheader("📈 Statistiques des proxies sélectionnés")
|
| 929 |
+
st.markdown(
|
| 930 |
+
"Ce tableau recense les **intrants (proxies)** choisis lors des évaluations, "
|
| 931 |
+
"avec le nombre de fois qu'ils ont été sélectionnés."
|
| 932 |
+
)
|
| 933 |
+
|
| 934 |
+
# --- Filtre temporel ---
|
| 935 |
+
col_filter, _ = st.columns([1, 3])
|
| 936 |
+
with col_filter:
|
| 937 |
+
period = st.selectbox(
|
| 938 |
+
"Période",
|
| 939 |
+
options=["Tout", "7 derniers jours", "30 derniers jours", "90 derniers jours"],
|
| 940 |
+
index=0,
|
| 941 |
+
key="stats_period",
|
| 942 |
+
)
|
| 943 |
+
days_map = {"Tout": None, "7 derniers jours": 7, "30 derniers jours": 30, "90 derniers jours": 90}
|
| 944 |
+
selected_days = days_map[period]
|
| 945 |
+
|
| 946 |
+
# --- Top proxies ---
|
| 947 |
+
st.markdown("### 🏆 Top proxies les plus choisis")
|
| 948 |
+
df_top_proxies = proxy_log.top_proxies(n=30, days=selected_days)
|
| 949 |
+
if df_top_proxies.empty:
|
| 950 |
+
st.info("Aucune sélection enregistrée pour le moment.")
|
| 951 |
+
else:
|
| 952 |
+
st.dataframe(
|
| 953 |
+
df_top_proxies,
|
| 954 |
+
use_container_width=True,
|
| 955 |
+
column_config={
|
| 956 |
+
"proxy_choisi": st.column_config.TextColumn("Intrant / Proxy", width="large"),
|
| 957 |
+
"nb_selections": st.column_config.NumberColumn("Nb sélections", format="%d"),
|
| 958 |
+
"dernière_utilisation": st.column_config.DatetimeColumn(
|
| 959 |
+
"Dernière utilisation", format="DD/MM/YYYY HH:mm"
|
| 960 |
+
),
|
| 961 |
+
},
|
| 962 |
+
)
|
| 963 |
+
|
| 964 |
+
st.divider()
|
| 965 |
+
|
| 966 |
+
# --- Top scénarios ---
|
| 967 |
+
st.markdown("### 🔀 Top scénarios (nodes résultat)")
|
| 968 |
+
df_top_scenarios = proxy_log.top_scenarios(n=20, days=selected_days)
|
| 969 |
+
if df_top_scenarios.empty:
|
| 970 |
+
st.info("Aucune sélection enregistrée pour le moment.")
|
| 971 |
+
else:
|
| 972 |
+
st.dataframe(
|
| 973 |
+
df_top_scenarios,
|
| 974 |
+
use_container_width=True,
|
| 975 |
+
column_config={
|
| 976 |
+
"scenario": st.column_config.TextColumn("Scénario (node)", width="medium"),
|
| 977 |
+
"nb_selections": st.column_config.NumberColumn("Nb sélections", format="%d"),
|
| 978 |
+
"dernière_utilisation": st.column_config.DatetimeColumn(
|
| 979 |
+
"Dernière utilisation", format="DD/MM/YYYY HH:mm"
|
| 980 |
+
),
|
| 981 |
+
},
|
| 982 |
+
)
|
| 983 |
+
|
| 984 |
+
st.divider()
|
| 985 |
+
|
| 986 |
+
# --- Journal complet ---
|
| 987 |
+
with st.expander("📋 Journal complet des sélections", expanded=False):
|
| 988 |
+
df_full = proxy_log.load_log()
|
| 989 |
+
if df_full.empty:
|
| 990 |
+
st.info("Aucune sélection enregistrée.")
|
| 991 |
+
else:
|
| 992 |
+
st.dataframe(df_full.sort_values("timestamp", ascending=False), use_container_width=True)
|
| 993 |
+
|
| 994 |
+
csv_log = df_full.to_csv(index=False).encode("utf-8")
|
| 995 |
+
st.download_button(
|
| 996 |
+
label="📥 Télécharger le journal (CSV)",
|
| 997 |
+
data=csv_log,
|
| 998 |
+
file_name="proxy_selections_log.csv",
|
| 999 |
+
mime="text/csv",
|
| 1000 |
+
key="btn_download_proxy_log",
|
| 1001 |
+
)
|
| 1002 |
+
|
| 1003 |
+
|
| 1004 |
# ============================================================================
|
| 1005 |
# Footer
|
| 1006 |
# ============================================================================
|
src/config.py
CHANGED
|
@@ -5,8 +5,6 @@ import os
|
|
| 5 |
from dotenv import load_dotenv
|
| 6 |
|
| 7 |
load_dotenv()
|
| 8 |
-
|
| 9 |
-
|
| 10 |
# ---------------------------------------------------------------------------
|
| 11 |
# Clé API Mistral
|
| 12 |
# ---------------------------------------------------------------------------
|
|
@@ -145,6 +143,9 @@ TRANSPORT_SURCHARGE = {
|
|
| 145 |
"autre": 300,
|
| 146 |
}
|
| 147 |
|
|
|
|
|
|
|
|
|
|
| 148 |
# Modèle Mistral à utiliser
|
| 149 |
MISTRAL_MODEL = "mistral-small-latest"
|
| 150 |
MISTRAL_MODEL_POWERFUL = "mistral-large-latest" # Pour analyses complexes (alternatives, tri)
|
|
|
|
| 5 |
from dotenv import load_dotenv
|
| 6 |
|
| 7 |
load_dotenv()
|
|
|
|
|
|
|
| 8 |
# ---------------------------------------------------------------------------
|
| 9 |
# Clé API Mistral
|
| 10 |
# ---------------------------------------------------------------------------
|
|
|
|
| 143 |
"autre": 300,
|
| 144 |
}
|
| 145 |
|
| 146 |
+
# Forfait extrusion (kg CO2 eq / t) — appliqué aux produits extrudés
|
| 147 |
+
FORFAIT_EXTRUSION = 56.77
|
| 148 |
+
|
| 149 |
# Modèle Mistral à utiliser
|
| 150 |
MISTRAL_MODEL = "mistral-small-latest"
|
| 151 |
MISTRAL_MODEL_POWERFUL = "mistral-large-latest" # Pour analyses complexes (alternatives, tri)
|
src/logigramme.json
CHANGED
|
@@ -134,9 +134,8 @@
|
|
| 134 |
"type": "resultat",
|
| 135 |
"actions_priorisees": [
|
| 136 |
"1. Je prends la valeur correspondant à cet intrant transformé dans ECOALIM",
|
| 137 |
-
|
| 138 |
-
"3. Si
|
| 139 |
-
"4. Si cela n'est pas possible, je prends la valeur d'un intrant qui a le process le plus proche dans ECOALIM"
|
| 140 |
]
|
| 141 |
},
|
| 142 |
{
|
|
@@ -144,10 +143,9 @@
|
|
| 144 |
"type": "resultat",
|
| 145 |
"actions_priorisees": [
|
| 146 |
"1. Je prends la valeur France indiquée pour l'intrant dans le GFLI",
|
| 147 |
-
"2. Si la valeur n'existe pas, je prends la valeur GFLI du Mix Européen (RER)",
|
| 148 |
-
"3. Si
|
| 149 |
-
"4. Si
|
| 150 |
-
"5. Si la valeur n'existe pas, je prends la valeur d'un intrant qui a la pratique culturale la plus proche dans le GFLI"
|
| 151 |
]
|
| 152 |
},
|
| 153 |
{
|
|
@@ -155,10 +153,9 @@
|
|
| 155 |
"type": "resultat",
|
| 156 |
"actions_priorisees": [
|
| 157 |
"1. Je prends la valeur GFLI du pays correspondant",
|
| 158 |
-
"2. Si la valeur n'existe pas, je prends la valeur GFLI du Mix Européen (RER) si l'intrant provient d'Europe et la valeur du Mix Monde (GLO) si l'intrant vient d'un autre continent",
|
| 159 |
-
"3. Si
|
| 160 |
-
"4. Si
|
| 161 |
-
"5. Si la valeur n'existe pas, je prends la valeur d'un intrant qui a la pratique culturale la plus proche dans le GFLI"
|
| 162 |
]
|
| 163 |
}
|
| 164 |
]
|
|
|
|
| 134 |
"type": "resultat",
|
| 135 |
"actions_priorisees": [
|
| 136 |
"1. Je prends la valeur correspondant à cet intrant transformé dans ECOALIM",
|
| 137 |
+
"2. Si la valeur n'existe pas : A/ Si je connais de manière fiable l'impact du process de transformation, je pars de la valeur pour l'intrant brut dans ECOALIM et j'ajoute l'impact du process. B/ Si je ne connais pas de manière fiable l'impact du process, j'utilise la valeur GFLI si elle existe",
|
| 138 |
+
"3. Si cela n'est pas possible, je prends la valeur d'un intrant qui a le process le plus proche dans ECOALIM"
|
|
|
|
| 139 |
]
|
| 140 |
},
|
| 141 |
{
|
|
|
|
| 143 |
"type": "resultat",
|
| 144 |
"actions_priorisees": [
|
| 145 |
"1. Je prends la valeur France indiquée pour l'intrant dans le GFLI",
|
| 146 |
+
"2. Si la valeur n'existe pas : A/ Si je connais de manière fiable l'impact du process, je pars de la valeur pour l'intrant brut dans le GFLI et j'ajoute l'impact du process. B/ Si je ne connais pas de manière fiable l'impact du process, je prends la valeur GFLI du Mix Européen (RER)",
|
| 147 |
+
"3. Si cela n'est pas possible, je prends la valeur pour l'intrant correspondant dans ECOALIM",
|
| 148 |
+
"4. Si la valeur n'existe pas, je prends la valeur d'un intrant qui a la pratique culturale la plus proche dans le GFLI"
|
|
|
|
| 149 |
]
|
| 150 |
},
|
| 151 |
{
|
|
|
|
| 153 |
"type": "resultat",
|
| 154 |
"actions_priorisees": [
|
| 155 |
"1. Je prends la valeur GFLI du pays correspondant",
|
| 156 |
+
"2. Si la valeur n'existe pas : A/ Si je connais de manière fiable l'impact du process, je pars de la valeur pour la MP brute dans le GFLI et j'ajoute l'impact du process. B/ Si je ne connais pas de manière fiable l'impact du process, je prends la valeur GFLI du Mix Européen (RER) si l'intrant provient d'Europe et la valeur du Mix Monde (GLO) si l'intrant vient d'un autre continent",
|
| 157 |
+
"3. Si cela n'est pas possible, je prends la valeur pour l'intrant correspondant dans ECOALIM",
|
| 158 |
+
"4. Si la valeur n'existe pas, je prends la valeur d'un intrant qui a la pratique culturale la plus proche dans le GFLI"
|
|
|
|
| 159 |
]
|
| 160 |
}
|
| 161 |
]
|
src/proxy_log.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
proxy_log.py – Persistence layer for proxy/scenario selections.
|
| 3 |
+
|
| 4 |
+
Each time the engine returns a result (single search or formulation row),
|
| 5 |
+
the chosen proxy is logged. The CSV file lives next to app.py so it
|
| 6 |
+
survives Streamlit restarts.
|
| 7 |
+
|
| 8 |
+
Colonnes du CSV :
|
| 9 |
+
timestamp, matiere_recherchee, proxy_choisi, scenario, impact_kg_co2_t,
|
| 10 |
+
source_db, match_exact, pays_production, pays_transformation, type_mp
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import csv
|
| 15 |
+
import os
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from collections import Counter
|
| 19 |
+
|
| 20 |
+
import pandas as pd
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Fichier de stockage
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
_LOG_DIR = Path(__file__).parent / "data"
|
| 26 |
+
_LOG_FILE = _LOG_DIR / "proxy_selections.csv"
|
| 27 |
+
|
| 28 |
+
_FIELDNAMES = [
|
| 29 |
+
"timestamp",
|
| 30 |
+
"matiere_recherchee",
|
| 31 |
+
"proxy_choisi",
|
| 32 |
+
"scenario",
|
| 33 |
+
"impact_kg_co2_t",
|
| 34 |
+
"source_db",
|
| 35 |
+
"match_exact",
|
| 36 |
+
"pays_production",
|
| 37 |
+
"pays_transformation",
|
| 38 |
+
"type_mp",
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _ensure_file() -> None:
|
| 43 |
+
"""Crée le répertoire et le fichier CSV avec en-tête s'ils n'existent pas."""
|
| 44 |
+
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
if not _LOG_FILE.exists():
|
| 46 |
+
with open(_LOG_FILE, "w", newline="", encoding="utf-8") as f:
|
| 47 |
+
writer = csv.DictWriter(f, fieldnames=_FIELDNAMES)
|
| 48 |
+
writer.writeheader()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
# Écriture
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
|
| 55 |
+
def log_selection(
|
| 56 |
+
matiere_recherchee: str,
|
| 57 |
+
proxy_choisi: str,
|
| 58 |
+
scenario: str,
|
| 59 |
+
impact_kg_co2_t: float | None = None,
|
| 60 |
+
source_db: str = "",
|
| 61 |
+
match_exact: bool = True,
|
| 62 |
+
pays_production: str = "",
|
| 63 |
+
pays_transformation: str = "",
|
| 64 |
+
type_mp: str = "vegetal_animal",
|
| 65 |
+
) -> None:
|
| 66 |
+
"""Enregistre une sélection de proxy dans le fichier CSV."""
|
| 67 |
+
_ensure_file()
|
| 68 |
+
row = {
|
| 69 |
+
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
| 70 |
+
"matiere_recherchee": matiere_recherchee,
|
| 71 |
+
"proxy_choisi": proxy_choisi,
|
| 72 |
+
"scenario": scenario,
|
| 73 |
+
"impact_kg_co2_t": round(impact_kg_co2_t, 2) if impact_kg_co2_t is not None else "",
|
| 74 |
+
"source_db": source_db,
|
| 75 |
+
"match_exact": "Oui" if match_exact else "Non",
|
| 76 |
+
"pays_production": pays_production or "",
|
| 77 |
+
"pays_transformation": pays_transformation or "",
|
| 78 |
+
"type_mp": type_mp,
|
| 79 |
+
}
|
| 80 |
+
with open(_LOG_FILE, "a", newline="", encoding="utf-8") as f:
|
| 81 |
+
writer = csv.DictWriter(f, fieldnames=_FIELDNAMES)
|
| 82 |
+
writer.writerow(row)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Lecture / statistiques
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
def load_log() -> pd.DataFrame:
|
| 90 |
+
"""Charge le journal complet sous forme de DataFrame."""
|
| 91 |
+
_ensure_file()
|
| 92 |
+
df = pd.read_csv(_LOG_FILE, encoding="utf-8")
|
| 93 |
+
if "timestamp" in df.columns:
|
| 94 |
+
df["timestamp"] = pd.to_datetime(df["timestamp"], errors="coerce")
|
| 95 |
+
return df
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def top_proxies(n: int = 20, days: int | None = None) -> pd.DataFrame:
|
| 99 |
+
"""Renvoie les *n* proxies les plus choisis (optionnel : sur les *days* derniers jours).
|
| 100 |
+
|
| 101 |
+
Colonnes retournées : proxy_choisi, nb_selections, dernière_utilisation
|
| 102 |
+
"""
|
| 103 |
+
df = load_log()
|
| 104 |
+
if df.empty:
|
| 105 |
+
return pd.DataFrame(columns=["proxy_choisi", "nb_selections", "dernière_utilisation"])
|
| 106 |
+
|
| 107 |
+
if days is not None and "timestamp" in df.columns:
|
| 108 |
+
cutoff = pd.Timestamp.now() - pd.Timedelta(days=days)
|
| 109 |
+
df = df[df["timestamp"] >= cutoff]
|
| 110 |
+
|
| 111 |
+
if df.empty:
|
| 112 |
+
return pd.DataFrame(columns=["proxy_choisi", "nb_selections", "dernière_utilisation"])
|
| 113 |
+
|
| 114 |
+
stats = (
|
| 115 |
+
df.groupby("proxy_choisi", sort=False)
|
| 116 |
+
.agg(
|
| 117 |
+
nb_selections=("proxy_choisi", "size"),
|
| 118 |
+
dernière_utilisation=("timestamp", "max"),
|
| 119 |
+
)
|
| 120 |
+
.reset_index()
|
| 121 |
+
.sort_values("nb_selections", ascending=False)
|
| 122 |
+
.head(n)
|
| 123 |
+
)
|
| 124 |
+
return stats
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def top_scenarios(n: int = 20, days: int | None = None) -> pd.DataFrame:
|
| 128 |
+
"""Renvoie les *n* scénarios les plus fréquents."""
|
| 129 |
+
df = load_log()
|
| 130 |
+
if df.empty:
|
| 131 |
+
return pd.DataFrame(columns=["scenario", "nb_selections", "dernière_utilisation"])
|
| 132 |
+
|
| 133 |
+
if days is not None and "timestamp" in df.columns:
|
| 134 |
+
cutoff = pd.Timestamp.now() - pd.Timedelta(days=days)
|
| 135 |
+
df = df[df["timestamp"] >= cutoff]
|
| 136 |
+
|
| 137 |
+
if df.empty:
|
| 138 |
+
return pd.DataFrame(columns=["scenario", "nb_selections", "dernière_utilisation"])
|
| 139 |
+
|
| 140 |
+
stats = (
|
| 141 |
+
df.groupby("scenario", sort=False)
|
| 142 |
+
.agg(
|
| 143 |
+
nb_selections=("scenario", "size"),
|
| 144 |
+
dernière_utilisation=("timestamp", "max"),
|
| 145 |
+
)
|
| 146 |
+
.reset_index()
|
| 147 |
+
.sort_values("nb_selections", ascending=False)
|
| 148 |
+
.head(n)
|
| 149 |
+
)
|
| 150 |
+
return stats
|