Update app.py
Browse files
app.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# -*- coding: utf-8 -*-
|
| 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 |
-
|
| 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'
|
| 47 |
-
REGEX_MEAN = r'x̄\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g'
|
| 48 |
-
REGEX_STD = r's\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g'
|
| 49 |
-
REGEX_MIN = r'Min\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g'
|
| 50 |
-
REGEX_MAX = r'Max\s+\d*[,\.]?\d*\s*%\s+([\d,\.]+)\s*g'
|
| 51 |
REGEX_NOMINAL = r'Nominal\s+([\d,\.]+)\s*g'
|
| 52 |
|
| 53 |
-
FALLBACK_ID_PREFIX = "IncompleteID_"
|
| 54 |
|
| 55 |
# --- Fonctions Utilitaires ---
|
| 56 |
|
| 57 |
-
def safe_extract_group(match, group_index=1, 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 |
-
#
|
| 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
|
| 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>
|
| 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 |
-
|
| 498 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
|
| 507 |
# --- Application des Filtres ---
|
| 508 |
filtered_data = all_data.copy()
|
| 509 |
-
if 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 551 |
-
|
|
|
|
|
|
|
| 552 |
st.plotly_chart(fig_hist_detail, use_container_width=True)
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
| 554 |
|
| 555 |
|
| 556 |
-
with tab2: # Analyse Temporelle
|
| 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"),
|
|
|
|
|
|
|
| 562 |
).reset_index()
|
|
|
|
| 563 |
if not time_data.empty:
|
| 564 |
-
fig_line = px.line(
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
st.plotly_chart(fig_line, use_container_width=True)
|
| 567 |
-
else:
|
| 568 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 576 |
-
|
| 577 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
st.plotly_chart(fig_heatmap, use_container_width=True)
|
| 579 |
-
else:
|
| 580 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"),
|
|
|
|
| 592 |
).reset_index()
|
|
|
|
| 593 |
if not dev_stats.empty:
|
| 594 |
-
fig_bar_dev = px.bar(
|
| 595 |
-
|
|
|
|
|
|
|
|
|
|
| 596 |
fig_bar_dev.update_layout(showlegend=False)
|
| 597 |
st.plotly_chart(fig_bar_dev, use_container_width=True)
|
| 598 |
-
else:
|
| 599 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"),
|
| 608 |
-
|
|
|
|
|
|
|
|
|
|
| 609 |
).reset_index()
|
| 610 |
-
|
| 611 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
categories = list(categories_map.values())
|
|
|
|
|
|
|
| 613 |
df_to_normalize = pd.DataFrame(index=radar_metrics.index)
|
| 614 |
-
|
| 615 |
-
df_to_normalize["
|
| 616 |
-
df_to_normalize["
|
| 617 |
-
df_to_normalize["
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
for col in df_to_normalize.columns:
|
| 619 |
-
if df_to_normalize[col].isnull().any():
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 où 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)
|