JosephMcDonnell commited on
Commit
13bb974
·
1 Parent(s): f6b9880
Files changed (4) hide show
  1. src/app.py +270 -31
  2. src/config.py +3 -2
  3. src/logigramme.json +8 -11
  4. 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(["📊 Formulation produit", "🔍 Recherche unitaire"])
 
 
124
 
125
 
126
  # ============================================================================
127
  # TAB 1 : FORMULATION PRODUIT
128
  # ============================================================================
129
  with tab_formulation:
130
- st.subheader("📊 Tableau de formulation — Calcul d'impact complet")
131
  st.markdown("""
132
- Remplissez le tableau ci-dessous avec les matières premières de votre formulation.
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, 3])
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"], new_row], ignore_index=True
 
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
- # Impact total = impact unitaire + forfait transport
 
 
 
 
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
- "Impact + transport (kg CO2 eq/t)": round(impact_avec_transport, 2) if impact_avec_transport else None,
 
 
261
  "Impact pondéré (kg CO2 eq/t)": round(impact_pondere, 2) if impact_pondere else None,
262
- "Intrant utilisé": res.intrant_utilise or "",
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
- "Impact + transport (kg CO2 eq/t)": st.column_config.NumberColumn(format="%.2f"),
 
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
- impact_total = impact_kg_t + transport_val
 
 
 
 
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
- "2. Si la valeur n'existe pas,j'utilise la valeur GFLI si elle existe",
138
- "3. 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
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 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.
149
- "4. Si cela n'est pas possible, je prends la valeur pour l'intrant correspondant dans ECOALIM",
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 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.
160
- "4. Si cela n'est pas possible, je prends la valeur pour l'intrant correspondant dans ECOALIM",
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