MMOON commited on
Commit
363f66e
·
verified ·
1 Parent(s): d43c97d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +266 -103
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # -*- coding: utf-8 -*- # Ajout pour assurer la compatibilité de l'encodage
2
 
3
  import streamlit as st
4
  import pandas as pd
@@ -27,7 +27,6 @@ st.set_page_config(
27
  # --- Styles CSS Personnalisés ---
28
  st.markdown("""
29
  <style>
30
- /* Styles CSS (gardés pour la concision) */
31
  .main-header { font-size: 2.5rem; color: #1E88E5; font-weight: 700; margin-bottom: 1rem; }
32
  .sub-header { font-size: 1.5rem; color: #0D47A1; font-weight: 600; margin-top: 1.5rem; margin-bottom: 1rem; }
33
  .card { background-color: #f9f9f9; border-radius: 10px; padding: 20px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); margin-bottom: 1rem; }
@@ -39,28 +38,26 @@ st.markdown("""
39
  """, unsafe_allow_html=True)
40
 
41
  # --- Constantes (Regex et autres) ---
42
- # Suppression de REGEX_REPORT_ID
43
- REGEX_PRODUCT_NAME = r'^([A-Z0-9\s/]+)' # Majuscules, chiffres, espaces ET slash pour certains noms
44
  REGEX_DLUO = r'DLUO\s+(\d{2}\.\d{2}\.\d{2})'
45
  REGEX_DATETIME = r'(\d{2}/\d{2}/\d{4}\s+-\s+\d{2}:\d{2})'
46
- REGEX_WEIGHTS = r'(\d+)\s*\.{5,}\s*([\d,\.]+)\s*g' # Au moins 5 points pour séparer
47
- REGEX_MEAN = r'x̄\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g' # Rend les pourcentages optionnels visuellement
48
- REGEX_STD = r's\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g' # Rend les pourcentages optionnels visuellement
49
- REGEX_MIN = r'Min\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g' # Rend les pourcentages optionnels visuellement
50
- REGEX_MAX = r'Max\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g' # Rend les pourcentages optionnels visuellement
51
  REGEX_NOMINAL = r'Nominal\s+([\d,\.]+)\s*g'
52
 
53
- FALLBACK_ID_PREFIX = "IncompleteID_" # Préfixe pour les ID générés quand des infos manquent
54
 
55
  # --- Fonctions Utilitaires ---
56
 
57
- def safe_extract_group(match, group_index=1, default="Non trouvé"): # Default à "Non trouvé"
58
  """Extrait un groupe d'une correspondance regex en toute sécurité."""
59
  if match:
60
  try:
61
- # Nettoyage supplémentaire: supprimer les espaces blancs excessifs
62
  extracted = match.group(group_index).strip()
63
- # Remplacer les séquences de plusieurs espaces par un seul
64
  return re.sub(r'\s+', ' ', extracted) if extracted else default
65
  except IndexError:
66
  return default
@@ -95,7 +92,7 @@ def create_batch_id(product, dluo, date_time):
95
  def extract_data_from_pdf(pdf_file_path):
96
  """
97
  Extrait les données structurées d'un fichier PDF de rapport de pesée.
98
- Utilise maintenant Produit+DLUO+DateHeure comme identifiant de batch.
99
  """
100
  try:
101
  text_content = ""
@@ -355,12 +352,13 @@ if page == "Extraction des PDF":
355
  max_w = metadata.get('max_weight', 'N/A')
356
  max_w_display = f"{max_w} g" if isinstance(max_w, (int, float)) else max_w
357
 
 
358
  st.markdown(f"""
359
  <div class="highlight">
360
  <strong>Produit:</strong> {metadata.get('product_name', 'N/A')}<br>
361
  <strong>DLUO:</strong> {metadata.get('dluo', 'N/A')}<br>
362
  <strong>Date/Heure:</strong> {metadata.get('date_time', 'N/A')}<br>
363
- <strong>ID Batch:</strong> <small>{metadata.get('batch_id', 'N/A')}</small><br> {/* Affichage ID Batch */}
364
  <strong>Poids Nominal:</strong> {nom_w_display}<br>
365
  <strong>Stats Rapport (Moy/Std/Min/Max):</strong>
366
  {mean_w_display} / {std_w_display} /
@@ -375,7 +373,6 @@ if page == "Extraction des PDF":
375
  current_data = st.session_state.app_data["current_batch_data"]
376
  if not current_data.empty:
377
  st.markdown('<div class="sub-header">📈 Visualisation du Dernier Batch Ajouté</div>', unsafe_allow_html=True)
378
- # ... (Code de visualisation du batch inchangé, il utilise les données de current_data) ...
379
  col1, col2 = st.columns(2)
380
  with col1:
381
  fig_hist = px.histogram(
@@ -451,7 +448,7 @@ elif page == "Analyse des Données":
451
 
452
  # --- Affichage et Analyse si des Données existent ---
453
  if not st.session_state.app_data["all_data"].empty:
454
- all_data = st.session_state.app_data["all_data"].copy()
455
 
456
  # --- Préparation des Données d'Analyse ---
457
  # Conversion Date (robuste)
@@ -494,22 +491,57 @@ elif page == "Analyse des Données":
494
  col_f1, col_f2 = st.columns(2)
495
  with col_f1:
496
  products = sorted(all_data["Produit"].unique())
497
- if products: selected_products = st.multiselect("Filtrer par Produit", options=products, default=products)
498
- else: selected_products = []
 
 
 
 
 
 
499
  with col_f2:
 
500
  if "Date" in all_data.columns and all_data["Date"].notna().any():
501
  valid_dates = sorted(all_data["Date"].dropna().unique())
502
- min_date, max_date = min(valid_dates), max(valid_dates)
503
- selected_date_range = st.date_input("Filtrer par Plage de Dates", value=(min_date, max_date),
504
- min_value=min_date, max_value=max_date, key="date_filter")
505
- else: selected_date_range = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
 
507
  # --- Application des Filtres ---
508
  filtered_data = all_data.copy()
509
- if selected_products: filtered_data = filtered_data[filtered_data["Produit"].isin(selected_products)]
 
 
 
 
510
  if selected_date_range and len(selected_date_range) == 2 and "Date" in filtered_data.columns and filtered_data["Date"].notna().any():
511
  start_date, end_date = selected_date_range
512
- filtered_data = filtered_data[(filtered_data["Date"] >= start_date) & (filtered_data["Date"] <= end_date)]
 
 
 
 
 
513
 
514
  # --- Affichage si données filtrées ---
515
  if not filtered_data.empty:
@@ -525,111 +557,226 @@ elif page == "Analyse des Données":
525
  "📦 Analyse par Produit/DLUO" # Nouvelle tabulation
526
  ])
527
 
528
- with tab1: # Distribution & Histogrammes (inchangé)
529
  st.markdown("#### Distribution des Poids par Produit (Boîtes à Moustaches)")
530
  if not filtered_data.empty:
531
  fig_box = px.box(
532
- filtered_data, x="Produit", y="Poids (g)", color="Produit", points=False,
533
  title="Distribution des Poids par Produit Filtré", template="plotly_white"
534
  )
535
  fig_box.update_layout(xaxis_title="Produit", yaxis_title="Poids (g)", showlegend=False)
536
  st.plotly_chart(fig_box, use_container_width=True)
 
 
537
 
538
  st.markdown("#### Histogramme Détaillé par Produit")
539
  unique_filtered_products = sorted(filtered_data["Produit"].unique())
540
  if unique_filtered_products:
541
- histo_product = st.selectbox("Choisir un produit pour l'histogramme détaillé", options=unique_filtered_products, key="histo_select")
 
 
 
 
542
  if histo_product:
543
  product_data = filtered_data[filtered_data["Produit"] == histo_product]
544
  if not product_data.empty:
545
  fig_hist_detail = px.histogram(product_data, x="Poids (g)", marginal="rug", nbins=30,
546
  title=f"Histogramme des Poids - {histo_product}", template="plotly_white")
 
547
  if "Poids_Nominal" in product_data.columns and product_data["Poids_Nominal"].notna().any():
548
- nominal_val = product_data["Poids_Nominal"].dropna().iloc[0]
549
  if pd.notna(nominal_val):
550
- fig_hist_detail.add_vline(x=nominal_val, line_dash="dash", line_color="red",
551
- annotation_text=f"Nominal: {nominal_val}g", annotation_position="top right")
 
 
552
  st.plotly_chart(fig_hist_detail, use_container_width=True)
553
- else: st.info("Aucun produit disponible pour l'histogramme après filtrage.")
 
 
 
554
 
555
 
556
- with tab2: # Analyse Temporelle (inchangé)
557
  st.markdown("#### Évolution Temporelle des Poids Moyens")
 
558
  if "Date" in filtered_data.columns and filtered_data["Date"].notna().any():
 
559
  try:
 
560
  time_data = filtered_data.dropna(subset=["Date", "Produit", "Poids (g)"]).groupby(["Date", "Produit"], observed=True).agg(
561
- Poids_Moyen=("Poids (g)", "mean"), Ecart_Type=("Poids (g)", "std"), Nb_Echantillons=("Poids (g)", "count")
 
 
562
  ).reset_index()
 
563
  if not time_data.empty:
564
- fig_line = px.line(time_data, x="Date", y="Poids_Moyen", color="Produit", markers=True, error_y="Ecart_Type",
565
- title="Évolution Poids Moyens (± Écart Type)", template="plotly_white", labels={"Poids_Moyen": "Poids Moyen (g)"})
 
 
 
 
566
  st.plotly_chart(fig_line, use_container_width=True)
567
- else: st.info("Pas assez de données valides pour l'analyse temporelle.")
568
- except Exception as e_agg: logging.error(f"Erreur agrégation temporelle: {e_agg}", exc_info=True); time_data = pd.DataFrame()
 
 
 
 
 
569
 
570
  st.markdown("#### Heatmap des Poids Moyens par Produit et Date")
 
571
  if not time_data.empty and time_data['Date'].nunique() > 1 and time_data['Produit'].nunique() > 0:
572
  try:
 
573
  heatmap_pivot = time_data.pivot_table(index="Produit", columns="Date", values="Poids_Moyen")
 
574
  if not heatmap_pivot.empty:
575
- fig_heatmap = px.imshow(heatmap_pivot, aspect="auto", color_continuous_scale="RdBu_r", title="Heatmap Poids Moyens", template="plotly_white")
576
- fig_heatmap.update_layout(height=max(400, len(heatmap_pivot.index) * 50 + 100))
577
- fig_heatmap.update_layout(xaxis_title="Date", yaxis_title="Produit", coloraxis_colorbar_title="Poids Moyen (g)")
 
 
 
 
 
 
 
 
 
578
  st.plotly_chart(fig_heatmap, use_container_width=True)
579
- else: st.info("Pas assez de données variées pour générer une heatmap.")
580
- except Exception as e_heatmap: logging.warning(f"Impossible de générer la heatmap: {e_heatmap}", exc_info=True)
581
- else: st.info("Pas assez de données (dates/produits distincts) pour une heatmap pertinente.")
582
- else: st.info("Analyse temporelle non disponible (colonne 'Date' invalide/manquante).")
583
 
 
 
 
 
 
 
584
 
585
- with tab3: # Comparaison Produits (Global) - Radar Chart etc. (inchangé)
 
 
 
 
586
  st.markdown("#### Comparaison des Performances Produits (Global)")
 
587
  if "Déviation_%" in filtered_data.columns and filtered_data["Déviation_%"].notna().any():
 
588
  st.markdown("##### Déviation Moyenne (%) par Produit")
589
  try:
 
590
  dev_stats = filtered_data.dropna(subset=["Produit", "Déviation_%"]).groupby("Produit", observed=True).agg(
591
- Déviation_Moyenne=("Déviation_%", "mean"), Écart_Type_Déviation=("Déviation_%", "std")
 
592
  ).reset_index()
 
593
  if not dev_stats.empty:
594
- fig_bar_dev = px.bar(dev_stats, x="Produit", y="Déviation_Moyenne", error_y="Écart_Type_Déviation", color="Produit",
595
- title="Déviation Moyenne / Nominal (%) par Produit", template="plotly_white", labels={"Déviation_Moyenne": "Déviation Moyenne (%)"})
 
 
 
596
  fig_bar_dev.update_layout(showlegend=False)
597
  st.plotly_chart(fig_bar_dev, use_container_width=True)
598
- else: st.info("Aucune donnée de déviation valide pour le graphique.")
599
- except Exception as e_dev_bar: logging.error(f"Erreur barre déviation: {e_dev_bar}", exc_info=True); dev_stats = pd.DataFrame()
 
 
 
 
 
600
 
601
- # --- Radar Chart ---
602
  st.markdown("##### Radar Chart Comparatif (Global Produit)")
603
  num_unique_products = filtered_data["Produit"].nunique()
604
  if num_unique_products > 1:
605
  try:
 
606
  radar_metrics = filtered_data.groupby("Produit", observed=True).agg(
607
- Poids_Moyen=("Poids (g)", "mean"), Poids_Std=("Poids (g)", "std"),
608
- Abs_Dev_Moyenne=("Déviation_%", lambda x: x.abs().mean()), Dev_Std=("Déviation_%", "std")
 
 
 
609
  ).reset_index()
610
- categories_map = {"Poids_Moyen": "Précision Poids", "Poids_Std": "Stabilité Poids",
611
- "Abs_Dev_Moyenne": "Conformité Nominale", "Dev_Std": "Consistance Conformité"}
 
 
 
 
 
 
 
612
  categories = list(categories_map.values())
 
 
613
  df_to_normalize = pd.DataFrame(index=radar_metrics.index)
614
- df_to_normalize["Précision Poids"] = radar_metrics["Poids_Moyen"]
615
- df_to_normalize["Stabilité Poids"] = -radar_metrics["Poids_Std"]
616
- df_to_normalize["Conformité Nominale"] = -radar_metrics["Abs_Dev_Moyenne"]
617
- df_to_normalize["Consistance Conformité"] = -radar_metrics["Dev_Std"]
 
 
 
 
618
  for col in df_to_normalize.columns:
619
- if df_to_normalize[col].isnull().any(): df_to_normalize[col].fillna(df_to_normalize[col].min(), inplace=True)
620
- normalized_df = df_to_normalize.apply(lambda x: (x - x.min()) / (x.max() - x.min()) if (x.max() - x.min()) > 1e-9 else 0.5, axis=0)
621
- normalized_df["Produit"] = radar_metrics["Produit"]
622
- fig_radar = go.Figure()
623
- theta_cats = categories + categories[:1]
624
- for i, row in normalized_df.iterrows():
625
- values = row[categories].tolist() + row[categories].tolist()[:1]
626
- fig_radar.add_trace(go.Scatterpolar(r=values, theta=theta_cats, fill='toself', name=row["Produit"]))
627
- fig_radar.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 1])),
628
- title="Comparaison Radar Produits (0-1, mieux si proche de 1)", template="plotly_white", legend_title="Produit")
629
- st.plotly_chart(fig_radar, use_container_width=True)
630
- except Exception as e_radar: logging.error(f"Erreur radar chart: {e_radar}", exc_info=True); st.error(f"Erreur radar chart: {e_radar}")
631
- else: st.info("Au moins deux produits nécessaires pour le radar chart.")
632
- else: st.warning("Comparaison basée sur la déviation non disponible ('Déviation_%' manquante/vide).")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
 
634
  # --- NOUVELLE TABULATION : Analyse par Produit/DLUO ---
635
  with tab4:
@@ -647,7 +794,7 @@ elif page == "Analyse des Données":
647
  agg_funcs["Déviation_%"] = ["mean", "std"]
648
 
649
  try:
650
- # Group by Produit et DLUO
651
  prod_dluo_stats = filtered_data.dropna(subset=["Produit", "DLUO"]).groupby(["Produit", "DLUO"], observed=True).agg(agg_funcs).reset_index()
652
 
653
  # Aplatir les multi-index des colonnes
@@ -655,6 +802,8 @@ elif page == "Analyse des Données":
655
 
656
  # Renommer les colonnes pour plus de clarté
657
  prod_dluo_stats.rename(columns={
 
 
658
  "Poids (g)_mean": "Poids Moyen (g)",
659
  "Poids (g)_std": "Poids Std Dev (g)",
660
  "Poids (g)_min": "Poids Min (g)",
@@ -670,32 +819,43 @@ elif page == "Analyse des Données":
670
  # Optionnel : Graphique comparatif des DLUO pour un produit sélectionné
671
  st.markdown("---")
672
  st.markdown("##### Comparaison des DLUO pour un Produit")
673
- sel_prod_for_dluo_comp = st.selectbox(
674
- "Choisir un produit pour comparer ses DLUO",
675
- options=sorted(prod_dluo_stats["Produit"].unique()),
676
- key="dluo_comp_select"
677
- )
678
- if sel_prod_for_dluo_comp:
679
- dluo_comp_data = prod_dluo_stats[prod_dluo_stats["Produit"] == sel_prod_for_dluo_comp]
680
- if not dluo_comp_data.empty:
681
- # Choix de la métrique à comparer
682
- available_metrics = [col for col in dluo_comp_data.columns if col not in ["Produit", "DLUO"]]
683
- metric_to_compare = st.selectbox(
684
- "Choisir la métrique à comparer",
685
- options=available_metrics,
686
- index=available_metrics.index("Poids Moyen (g)") if "Poids Moyen (g)" in available_metrics else 0,
687
- key="dluo_metric_select"
688
- )
689
- if metric_to_compare:
690
- fig_dluo_comp = px.bar(
691
- dluo_comp_data, x="DLUO", y=metric_to_compare,
692
- title=f"{metric_to_compare} par DLUO pour {sel_prod_for_dluo_comp}",
693
- template="plotly_white", color="DLUO"
694
- )
695
- fig_dluo_comp.update_layout(showlegend=False)
696
- st.plotly_chart(fig_dluo_comp, use_container_width=True)
697
- else:
698
- st.info(f"Aucune donnée de DLUO trouvée pour le produit {sel_prod_for_dluo_comp}.")
 
 
 
 
 
 
 
 
 
 
 
699
 
700
  else:
701
  st.info("Aucune statistique à afficher après regroupement par Produit/DLUO.")
@@ -710,11 +870,13 @@ elif page == "Analyse des Données":
710
 
711
  # --- Affichage Table Données Filtrées ---
712
  st.markdown('<div class="sub-header">📋 Données Filtrées Détaillées</div>', unsafe_allow_html=True)
 
713
  st.dataframe(filtered_data.drop(columns=["Date"], errors='ignore'), use_container_width=True)
714
 
715
  # --- Téléchargement Données Filtrées ---
716
  st.markdown('<div class="sub-header">💾 Téléchargement des Données Filtrées</div>', unsafe_allow_html=True)
717
  col_dl_hist1, col_dl_hist2 = st.columns(2)
 
718
  df_to_download = filtered_data.drop(columns=["Date"], errors='ignore')
719
  with col_dl_hist1:
720
  csv_link_hist = get_download_link(df_to_download, "donnees_filtrees.csv", "📥 Télécharger en CSV")
@@ -723,14 +885,15 @@ elif page == "Analyse des Données":
723
  excel_link_hist = get_download_link(df_to_download, "donnees_filtrees.xlsx", "📥 Télécharger en Excel")
724
  st.markdown(excel_link_hist, unsafe_allow_html=True)
725
 
726
- elif not st.session_state.app_data["all_data"].empty:
727
  st.warning("Aucune donnée ne correspond aux filtres sélectionnés.")
 
728
 
729
  # --- Pied de Page ---
730
  st.markdown("---")
731
  st.markdown("""
732
  <div style="text-align: center; margin-top: 20px; color: #888; font-size: 0.9em;">
733
- <p>⚖️ Extracteur & Analyseur de Rapports de Pesées | Version 1.3 (ID Batch)</p>
734
  <p>Développé avec Streamlit & Plotly</p>
735
  </div>
736
  """, unsafe_allow_html=True)
 
1
+ # -*- coding: utf-8 -*-
2
 
3
  import streamlit as st
4
  import pandas as pd
 
27
  # --- Styles CSS Personnalisés ---
28
  st.markdown("""
29
  <style>
 
30
  .main-header { font-size: 2.5rem; color: #1E88E5; font-weight: 700; margin-bottom: 1rem; }
31
  .sub-header { font-size: 1.5rem; color: #0D47A1; font-weight: 600; margin-top: 1.5rem; margin-bottom: 1rem; }
32
  .card { background-color: #f9f9f9; border-radius: 10px; padding: 20px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); margin-bottom: 1rem; }
 
38
  """, unsafe_allow_html=True)
39
 
40
  # --- Constantes (Regex et autres) ---
41
+ REGEX_PRODUCT_NAME = r'^([A-Z0-9\s/]+)'
 
42
  REGEX_DLUO = r'DLUO\s+(\d{2}\.\d{2}\.\d{2})'
43
  REGEX_DATETIME = r'(\d{2}/\d{2}/\d{4}\s+-\s+\d{2}:\d{2})'
44
+ REGEX_WEIGHTS = r'(\d+)\s*\.{5,}\s*([\d,\.]+)\s*g'
45
+ REGEX_MEAN = r'x̄\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g'
46
+ REGEX_STD = r's\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g'
47
+ REGEX_MIN = r'Min\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g'
48
+ REGEX_MAX = r'Max\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g'
49
  REGEX_NOMINAL = r'Nominal\s+([\d,\.]+)\s*g'
50
 
51
+ FALLBACK_ID_PREFIX = "IncompleteID_"
52
 
53
  # --- Fonctions Utilitaires ---
54
 
55
+ def safe_extract_group(match, group_index=1, default="Non trouvé"):
56
  """Extrait un groupe d'une correspondance regex en toute sécurité."""
57
  if match:
58
  try:
 
59
  extracted = match.group(group_index).strip()
60
+ # Nettoyage supplémentaire: supprimer les espaces blancs excessifs
61
  return re.sub(r'\s+', ' ', extracted) if extracted else default
62
  except IndexError:
63
  return default
 
92
  def extract_data_from_pdf(pdf_file_path):
93
  """
94
  Extrait les données structurées d'un fichier PDF de rapport de pesée.
95
+ Utilise Produit+DLUO+DateHeure comme identifiant de batch.
96
  """
97
  try:
98
  text_content = ""
 
352
  max_w = metadata.get('max_weight', 'N/A')
353
  max_w_display = f"{max_w} g" if isinstance(max_w, (int, float)) else max_w
354
 
355
+ # Correction de la f-string ici :
356
  st.markdown(f"""
357
  <div class="highlight">
358
  <strong>Produit:</strong> {metadata.get('product_name', 'N/A')}<br>
359
  <strong>DLUO:</strong> {metadata.get('dluo', 'N/A')}<br>
360
  <strong>Date/Heure:</strong> {metadata.get('date_time', 'N/A')}<br>
361
+ <strong>ID Batch:</strong> <small>{metadata.get('batch_id', 'N/A')}</small><br>
362
  <strong>Poids Nominal:</strong> {nom_w_display}<br>
363
  <strong>Stats Rapport (Moy/Std/Min/Max):</strong>
364
  {mean_w_display} / {std_w_display} /
 
373
  current_data = st.session_state.app_data["current_batch_data"]
374
  if not current_data.empty:
375
  st.markdown('<div class="sub-header">📈 Visualisation du Dernier Batch Ajouté</div>', unsafe_allow_html=True)
 
376
  col1, col2 = st.columns(2)
377
  with col1:
378
  fig_hist = px.histogram(
 
448
 
449
  # --- Affichage et Analyse si des Données existent ---
450
  if not st.session_state.app_data["all_data"].empty:
451
+ all_data = st.session_state.app_data["all_data"].copy() # Travailler sur une copie
452
 
453
  # --- Préparation des Données d'Analyse ---
454
  # Conversion Date (robuste)
 
491
  col_f1, col_f2 = st.columns(2)
492
  with col_f1:
493
  products = sorted(all_data["Produit"].unique())
494
+ # Gère le cas il n'y a aucun produit
495
+ if products:
496
+ selected_products = st.multiselect("Filtrer par Produit", options=products, default=products)
497
+ else:
498
+ selected_products = []
499
+ st.info("Aucun produit trouvé dans les données.")
500
+
501
+
502
  with col_f2:
503
+ # Vérifie que la colonne Date existe et contient des dates valides
504
  if "Date" in all_data.columns and all_data["Date"].notna().any():
505
  valid_dates = sorted(all_data["Date"].dropna().unique())
506
+ # Vérifie qu'il y a au moins une date valide
507
+ if valid_dates:
508
+ min_date = min(valid_dates)
509
+ max_date = max(valid_dates)
510
+ # Assure que min_date n'est pas postérieur à max_date
511
+ default_start = min_date
512
+ default_end = max_date
513
+ if min_date > max_date:
514
+ default_start, default_end = max_date, min_date # Swap si nécessaire
515
+
516
+ selected_date_range = st.date_input(
517
+ "Filtrer par Plage de Dates",
518
+ value=(default_start, default_end),
519
+ min_value=default_start,
520
+ max_value=default_end,
521
+ key="date_filter"
522
+ )
523
+ else:
524
+ selected_date_range = None
525
+ st.info("Aucune date valide trouvée pour le filtre.")
526
+ else:
527
+ selected_date_range = None
528
+ st.info("Filtrage par date non disponible (colonne 'Date' manquante ou invalide).")
529
 
530
  # --- Application des Filtres ---
531
  filtered_data = all_data.copy()
532
+ if selected_products:
533
+ filtered_data = filtered_data[filtered_data["Produit"].isin(selected_products)]
534
+
535
+ # Applique le filtre de date seulement si la colonne Date existe,
536
+ # que la plage est valide et qu'elle contient des dates non-NaT
537
  if selected_date_range and len(selected_date_range) == 2 and "Date" in filtered_data.columns and filtered_data["Date"].notna().any():
538
  start_date, end_date = selected_date_range
539
+ # Comparaison sécurisée avec les dates (ignore les NaT)
540
+ filtered_data = filtered_data[
541
+ (filtered_data["Date"].notna()) &
542
+ (filtered_data["Date"] >= start_date) &
543
+ (filtered_data["Date"] <= end_date)
544
+ ]
545
 
546
  # --- Affichage si données filtrées ---
547
  if not filtered_data.empty:
 
557
  "📦 Analyse par Produit/DLUO" # Nouvelle tabulation
558
  ])
559
 
560
+ with tab1: # Distribution & Histogrammes
561
  st.markdown("#### Distribution des Poids par Produit (Boîtes à Moustaches)")
562
  if not filtered_data.empty:
563
  fig_box = px.box(
564
+ filtered_data, x="Produit", y="Poids (g)", color="Produit", points=False, # 'all' peut être lent
565
  title="Distribution des Poids par Produit Filtré", template="plotly_white"
566
  )
567
  fig_box.update_layout(xaxis_title="Produit", yaxis_title="Poids (g)", showlegend=False)
568
  st.plotly_chart(fig_box, use_container_width=True)
569
+ else:
570
+ st.info("Aucune donnée à afficher pour la distribution.")
571
 
572
  st.markdown("#### Histogramme Détaillé par Produit")
573
  unique_filtered_products = sorted(filtered_data["Produit"].unique())
574
  if unique_filtered_products:
575
+ histo_product = st.selectbox(
576
+ "Choisir un produit pour l'histogramme détaillé",
577
+ options=unique_filtered_products,
578
+ key="histo_select"
579
+ )
580
  if histo_product:
581
  product_data = filtered_data[filtered_data["Produit"] == histo_product]
582
  if not product_data.empty:
583
  fig_hist_detail = px.histogram(product_data, x="Poids (g)", marginal="rug", nbins=30,
584
  title=f"Histogramme des Poids - {histo_product}", template="plotly_white")
585
+ # Ajout ligne nominale si disponible et valide
586
  if "Poids_Nominal" in product_data.columns and product_data["Poids_Nominal"].notna().any():
587
+ nominal_val = product_data["Poids_Nominal"].dropna().iloc[0] # Prend la première valeur non nulle
588
  if pd.notna(nominal_val):
589
+ fig_hist_detail.add_vline(
590
+ x=nominal_val, line_dash="dash", line_color="red",
591
+ annotation_text=f"Nominal: {nominal_val}g", annotation_position="top right"
592
+ )
593
  st.plotly_chart(fig_hist_detail, use_container_width=True)
594
+ else:
595
+ st.info(f"Aucune donnée pour le produit sélectionné : {histo_product}")
596
+ else:
597
+ st.info("Aucun produit disponible pour l'histogramme après filtrage.")
598
 
599
 
600
+ with tab2: # Analyse Temporelle
601
  st.markdown("#### Évolution Temporelle des Poids Moyens")
602
+ # Vérifie la présence de dates valides dans les données filtrées
603
  if "Date" in filtered_data.columns and filtered_data["Date"].notna().any():
604
+ # Agréger par date et produit
605
  try:
606
+ # Utilise observed=True pour éviter les combinaisons vides si les colonnes sont catégorielles
607
  time_data = filtered_data.dropna(subset=["Date", "Produit", "Poids (g)"]).groupby(["Date", "Produit"], observed=True).agg(
608
+ Poids_Moyen=("Poids (g)", "mean"),
609
+ Ecart_Type=("Poids (g)", "std"),
610
+ Nb_Echantillons=("Poids (g)", "count")
611
  ).reset_index()
612
+
613
  if not time_data.empty:
614
+ fig_line = px.line(
615
+ time_data, x="Date", y="Poids_Moyen", color="Produit",
616
+ markers=True, error_y="Ecart_Type",
617
+ title="Évolution des Poids Moyens (± Écart Type) dans le Temps",
618
+ template="plotly_white", labels={"Poids_Moyen": "Poids Moyen (g)"}
619
+ )
620
  st.plotly_chart(fig_line, use_container_width=True)
621
+ else:
622
+ st.info("Pas assez de données valides (Date, Produit, Poids) pour l'analyse temporelle.")
623
+
624
+ except Exception as e_agg:
625
+ st.error(f"Erreur lors de l'agrégation des données temporelles: {e_agg}")
626
+ logging.error(f"Erreur agrégation temporelle: {e_agg}", exc_info=True)
627
+ time_data = pd.DataFrame() # Assurer que time_data existe mais est vide
628
 
629
  st.markdown("#### Heatmap des Poids Moyens par Produit et Date")
630
+ # Vérifie s'il y a des données et assez de variété pour une heatmap
631
  if not time_data.empty and time_data['Date'].nunique() > 1 and time_data['Produit'].nunique() > 0:
632
  try:
633
+ # Créer une matrice pivot pour la heatmap
634
  heatmap_pivot = time_data.pivot_table(index="Produit", columns="Date", values="Poids_Moyen")
635
+
636
  if not heatmap_pivot.empty:
637
+ fig_heatmap = px.imshow(
638
+ heatmap_pivot, aspect="auto", # Ajuste l'aspect
639
+ color_continuous_scale="RdBu_r", # Rouge = bas, Bleu = haut
640
+ title="Heatmap des Poids Moyens",
641
+ template="plotly_white"
642
+ )
643
+ # Amélioration de la taille de la heatmap
644
+ fig_heatmap.update_layout(height=max(400, len(heatmap_pivot.index) * 50 + 100)) # Hauteur dynamique + marge
645
+ fig_heatmap.update_layout(
646
+ xaxis_title="Date", yaxis_title="Produit",
647
+ coloraxis_colorbar_title="Poids Moyen (g)"
648
+ )
649
  st.plotly_chart(fig_heatmap, use_container_width=True)
650
+ else:
651
+ st.info("Pas assez de données variées (produits/dates) pour générer une heatmap.")
 
 
652
 
653
+ except Exception as e_heatmap:
654
+ st.warning(f"Impossible de générer la heatmap: {e_heatmap}")
655
+ logging.warning(f"Erreur heatmap: {e_heatmap}", exc_info=True)
656
+ elif not time_data.empty:
657
+ st.info("Pas assez de données (dates ou produits distincts) pour générer une heatmap pertinente.")
658
+ # else: # Cas où time_data était vide géré plus haut
659
 
660
+ else:
661
+ st.info("Analyse temporelle non disponible (colonne 'Date' invalide ou manquante dans les données filtrées).")
662
+
663
+
664
+ with tab3: # Comparaison Produits (Global)
665
  st.markdown("#### Comparaison des Performances Produits (Global)")
666
+ # Vérifie la présence de déviation valide
667
  if "Déviation_%" in filtered_data.columns and filtered_data["Déviation_%"].notna().any():
668
+
669
  st.markdown("##### Déviation Moyenne (%) par Produit")
670
  try:
671
+ # Utilise observed=True pour la performance avec les catégories
672
  dev_stats = filtered_data.dropna(subset=["Produit", "Déviation_%"]).groupby("Produit", observed=True).agg(
673
+ Déviation_Moyenne=("Déviation_%", "mean"),
674
+ Écart_Type_Déviation=("Déviation_%", "std")
675
  ).reset_index()
676
+
677
  if not dev_stats.empty:
678
+ fig_bar_dev = px.bar(
679
+ dev_stats, x="Produit", y="Déviation_Moyenne", error_y="Écart_Type_Déviation",
680
+ color="Produit", title="Déviation Moyenne / Nominal (%) par Produit",
681
+ template="plotly_white", labels={"Déviation_Moyenne": "Déviation Moyenne (%)"}
682
+ )
683
  fig_bar_dev.update_layout(showlegend=False)
684
  st.plotly_chart(fig_bar_dev, use_container_width=True)
685
+ else:
686
+ st.info("Aucune donnée de déviation valide pour le graphique à barres.")
687
+
688
+ except Exception as e_dev_bar:
689
+ st.error(f"Erreur lors du calcul des statistiques de déviation: {e_dev_bar}")
690
+ logging.error(f"Erreur barre déviation: {e_dev_bar}", exc_info=True)
691
+ dev_stats = pd.DataFrame()
692
 
693
+ # --- Radar Chart Amélioré ---
694
  st.markdown("##### Radar Chart Comparatif (Global Produit)")
695
  num_unique_products = filtered_data["Produit"].nunique()
696
  if num_unique_products > 1:
697
  try:
698
+ # Calculer les métriques agrégées par produit, en ignorant les NaN pour chaque métrique
699
  radar_metrics = filtered_data.groupby("Produit", observed=True).agg(
700
+ Poids_Moyen=("Poids (g)", "mean"),
701
+ Poids_Std=("Poids (g)", "std"),
702
+ # Utiliser la moyenne de la valeur absolue pour la déviation moyenne
703
+ Abs_Dev_Moyenne=("Déviation_%", lambda x: x.abs().mean()),
704
+ Dev_Std=("Déviation_%", "std")
705
  ).reset_index()
706
+
707
+ # Définir les catégories pour le radar (avec des noms plus intuitifs)
708
+ # Inverse Poids_Std, Abs_Dev_Moyenne, Dev_Std car plus petit = mieux
709
+ categories_map = {
710
+ "Poids_Moyen": "Précision Poids", # Proche de la cible (si connue) - Ici juste la moyenne
711
+ "Poids_Std": "Stabilité Poids", # Faible std = stable
712
+ "Abs_Dev_Moyenne": "Conformité Nominale", # Faible déviation = conforme
713
+ "Dev_Std": "Consistance Conformité" # Faible std de déviation = consistant
714
+ }
715
  categories = list(categories_map.values())
716
+
717
+ # Préparer les données pour la normalisation, gérant les NaN et inversant si nécessaire
718
  df_to_normalize = pd.DataFrame(index=radar_metrics.index)
719
+ # S'assurer que les colonnes existent avant de les utiliser
720
+ if "Poids_Moyen" in radar_metrics.columns: df_to_normalize["Précision Poids"] = radar_metrics["Poids_Moyen"]
721
+ if "Poids_Std" in radar_metrics.columns: df_to_normalize["Stabilité Poids"] = -radar_metrics["Poids_Std"] # Négatif car moins = mieux
722
+ if "Abs_Dev_Moyenne" in radar_metrics.columns: df_to_normalize["Conformité Nominale"] = -radar_metrics["Abs_Dev_Moyenne"] # Négatif car moins = mieux
723
+ if "Dev_Std" in radar_metrics.columns: df_to_normalize["Consistance Conformité"] = -radar_metrics["Dev_Std"] # Négatif car moins = mieux
724
+
725
+ # Remplacer les NaN restants *après* l'inversion (ex: std d'un seul point est NaN)
726
+ # On remplace par une valeur qui sera "mauvaise" après normalisation (la minimale de la colonne inversée)
727
  for col in df_to_normalize.columns:
728
+ if df_to_normalize[col].isnull().any():
729
+ min_val = df_to_normalize[col].min() # Le min après inversion est la "pire" performance
730
+ df_to_normalize[col].fillna(min_val, inplace=True)
731
+
732
+ # Normalisation Min-Max (0 à 1, où 1 est le "meilleur")
733
+ # Vérifier si df_to_normalize n'est pas vide
734
+ if not df_to_normalize.empty:
735
+ normalized_df = df_to_normalize.apply(
736
+ lambda x: (x - x.min()) / (x.max() - x.min()) if (x.max() - x.min()) > 1e-9 else 0.5, # Gère division par zéro ou quasi-zéro
737
+ axis=0
738
+ )
739
+ normalized_df["Produit"] = radar_metrics["Produit"] # Rajouter le produit
740
+
741
+ # Créer la figure Radar
742
+ fig_radar = go.Figure()
743
+ # S'assurer que les catégories utilisées existent bien dans normalized_df
744
+ valid_categories = [cat for cat in categories if cat in normalized_df.columns]
745
+ if valid_categories:
746
+ theta_cats = valid_categories + valid_categories[:1] # Pour fermer le polygone
747
+
748
+ for i, row in normalized_df.iterrows():
749
+ values = row[valid_categories].tolist()
750
+ values += values[:1] # Fermer le polygone
751
+ fig_radar.add_trace(go.Scatterpolar(
752
+ r=values,
753
+ theta=theta_cats,
754
+ fill='toself',
755
+ name=row["Produit"]
756
+ ))
757
+
758
+ fig_radar.update_layout(
759
+ polar=dict(radialaxis=dict(visible=True, range=[0, 1])),
760
+ title="Comparaison Radar des Produits (Normalisé 0-1, plus proche de 1 = mieux)",
761
+ template="plotly_white",
762
+ legend_title="Produit"
763
+ )
764
+ st.plotly_chart(fig_radar, use_container_width=True)
765
+ else:
766
+ st.warning("Impossible de générer le radar: aucune métrique valide calculée.")
767
+ else:
768
+ st.warning("Impossible de générer le radar: pas de données à normaliser.")
769
+
770
+
771
+ except Exception as e_radar:
772
+ st.error(f"Erreur lors de la génération du radar chart: {e_radar}")
773
+ logging.error(f"Erreur radar chart: {e_radar}", exc_info=True)
774
+
775
+ else:
776
+ st.info("Au moins deux produits sont nécessaires dans les données filtrées pour générer le radar chart comparatif.")
777
+ else:
778
+ st.warning("Comparaison basée sur la déviation non disponible (colonne 'Déviation_%' manquante ou vide dans les données filtrées).")
779
+
780
 
781
  # --- NOUVELLE TABULATION : Analyse par Produit/DLUO ---
782
  with tab4:
 
794
  agg_funcs["Déviation_%"] = ["mean", "std"]
795
 
796
  try:
797
+ # Group by Produit et DLUO, en ignorant les lignes où Produit ou DLUO sont NaN
798
  prod_dluo_stats = filtered_data.dropna(subset=["Produit", "DLUO"]).groupby(["Produit", "DLUO"], observed=True).agg(agg_funcs).reset_index()
799
 
800
  # Aplatir les multi-index des colonnes
 
802
 
803
  # Renommer les colonnes pour plus de clarté
804
  prod_dluo_stats.rename(columns={
805
+ "Produit_": "Produit", # Correction si le nom de la colonne devient Produit_
806
+ "DLUO_": "DLUO", # Correction si le nom de la colonne devient DLUO_
807
  "Poids (g)_mean": "Poids Moyen (g)",
808
  "Poids (g)_std": "Poids Std Dev (g)",
809
  "Poids (g)_min": "Poids Min (g)",
 
819
  # Optionnel : Graphique comparatif des DLUO pour un produit sélectionné
820
  st.markdown("---")
821
  st.markdown("##### Comparaison des DLUO pour un Produit")
822
+ # S'assurer qu'il y a des produits à sélectionner
823
+ unique_prods_in_stats = sorted(prod_dluo_stats["Produit"].unique())
824
+ if unique_prods_in_stats:
825
+ sel_prod_for_dluo_comp = st.selectbox(
826
+ "Choisir un produit pour comparer ses DLUO",
827
+ options=unique_prods_in_stats,
828
+ key="dluo_comp_select"
829
+ )
830
+ if sel_prod_for_dluo_comp:
831
+ dluo_comp_data = prod_dluo_stats[prod_dluo_stats["Produit"] == sel_prod_for_dluo_comp]
832
+ if not dluo_comp_data.empty and dluo_comp_data["DLUO"].nunique() > 1: # Comparaison utile si > 1 DLUO
833
+ # Choix de la métrique à comparer
834
+ available_metrics = [col for col in dluo_comp_data.columns if col not in ["Produit", "DLUO"]]
835
+ if available_metrics:
836
+ metric_to_compare = st.selectbox(
837
+ "Choisir la métrique à comparer",
838
+ options=available_metrics,
839
+ index=available_metrics.index("Poids Moyen (g)") if "Poids Moyen (g)" in available_metrics else 0,
840
+ key="dluo_metric_select"
841
+ )
842
+ if metric_to_compare:
843
+ fig_dluo_comp = px.bar(
844
+ dluo_comp_data, x="DLUO", y=metric_to_compare,
845
+ title=f"{metric_to_compare} par DLUO pour {sel_prod_for_dluo_comp}",
846
+ template="plotly_white", color="DLUO"
847
+ )
848
+ fig_dluo_comp.update_layout(showlegend=False)
849
+ st.plotly_chart(fig_dluo_comp, use_container_width=True)
850
+ else:
851
+ st.info("Aucune métrique numérique disponible pour la comparaison des DLUO.")
852
+ elif not dluo_comp_data.empty:
853
+ st.info(f"Une seule DLUO trouvée pour le produit {sel_prod_for_dluo_comp}, comparaison non applicable.")
854
+ else:
855
+ # Cas où le produit sélectionné n'a pas de données DLUO (ne devrait pas arriver avec le filtre précédent)
856
+ st.info(f"Aucune donnée de DLUO trouvée pour le produit {sel_prod_for_dluo_comp}.")
857
+ else:
858
+ st.info("Aucun produit avec DLUO disponible pour la comparaison.")
859
 
860
  else:
861
  st.info("Aucune statistique à afficher après regroupement par Produit/DLUO.")
 
870
 
871
  # --- Affichage Table Données Filtrées ---
872
  st.markdown('<div class="sub-header">📋 Données Filtrées Détaillées</div>', unsafe_allow_html=True)
873
+ # Affiche le DataFrame sans la colonne 'Date' ajoutée techniquement
874
  st.dataframe(filtered_data.drop(columns=["Date"], errors='ignore'), use_container_width=True)
875
 
876
  # --- Téléchargement Données Filtrées ---
877
  st.markdown('<div class="sub-header">💾 Téléchargement des Données Filtrées</div>', unsafe_allow_html=True)
878
  col_dl_hist1, col_dl_hist2 = st.columns(2)
879
+ # Exclut également la colonne Date du téléchargement
880
  df_to_download = filtered_data.drop(columns=["Date"], errors='ignore')
881
  with col_dl_hist1:
882
  csv_link_hist = get_download_link(df_to_download, "donnees_filtrees.csv", "📥 Télécharger en CSV")
 
885
  excel_link_hist = get_download_link(df_to_download, "donnees_filtrees.xlsx", "📥 Télécharger en Excel")
886
  st.markdown(excel_link_hist, unsafe_allow_html=True)
887
 
888
+ elif not st.session_state.app_data["all_data"].empty: # Si on a des données globales mais rien après filtrage
889
  st.warning("Aucune donnée ne correspond aux filtres sélectionnés.")
890
+ # Le cas où all_data est vide est géré au début de la page
891
 
892
  # --- Pied de Page ---
893
  st.markdown("---")
894
  st.markdown("""
895
  <div style="text-align: center; margin-top: 20px; color: #888; font-size: 0.9em;">
896
+ <p>⚖️ Extracteur & Analyseur de Rapports de Pesées | Version 1.4 (ID Batch)</p>
897
  <p>Développé avec Streamlit & Plotly</p>
898
  </div>
899
  """, unsafe_allow_html=True)