import numpy as np import pandas as pd import streamlit as st import matplotlib.pyplot as plt st.set_page_config(page_title="Fiche biomécanique Zebris", layout="wide") REQUIRED_COLUMNS = { "nom": ["Nom"], "date": ["Date"], "vitesse": ["Vitesse (km/h)", "Vitesse"], "cadence": ["Cadence (pas/min)", "Cadence"], "contact": ["Contact (%)", "Total contact", "Total contact (%)"], "flight": ["Flight (%)", "Total flight", "Total flight (%)"], "force_talon_g": ["Force talon G (N)"], "force_talon_d": ["Force talon D (N)"], "force_avant_g": ["Force avant-pied G (N)", "Force avant pied G (N)"], "force_avant_d": ["Force avant-pied D (N)", "Force avant pied D (N)"], "pression_talon_g": ["Pression talon G (N/cm²)", "Pression talon G (N/cm2)"], "pression_talon_d": ["Pression talon D (N/cm²)", "Pression talon D (N/cm2)"], "cop_g": ["COP G (mm)"], "cop_d": ["COP D (mm)"], "rotation_g": ["Rotation G (°)", "Rotation G"], "rotation_d": ["Rotation D (°)", "Rotation D"], "transition_g": ["Transition G (s)"], "transition_d": ["Transition D (s)"], "longueur_foulee": ["Longueur foulée (cm)", "Longueur foulee (cm)"], "largeur_pas": ["Largeur pas (cm)"], "poids": ["Poids (kg)", "Poids"], } SAMPLE_DF = pd.DataFrame( [ { "Nom": "Baptiste PRETOT", "Date": "03/03/2026", "Vitesse (km/h)": 8, "Cadence (pas/min)": 154, "Contact (%)": 74.5, "Flight (%)": 25.5, "Force talon G (N)": 520, "Force talon D (N)": 500, "Force avant-pied G (N)": 450, "Force avant-pied D (N)": 470, "Pression talon G (N/cm²)": 22, "Pression talon D (N/cm²)": 21, "COP G (mm)": 230, "COP D (mm)": 228, "Rotation G (°)": 4, "Rotation D (°)": 11, "Transition G (s)": 0.08, "Transition D (s)": 0.08, "Longueur foulée (cm)": 180, "Largeur pas (cm)": 4, "Poids (kg)": 70, }, { "Nom": "Baptiste PRETOT", "Date": "03/03/2026", "Vitesse (km/h)": 14, "Cadence (pas/min)": 167, "Contact (%)": 71.8, "Flight (%)": 28.2, "Force talon G (N)": 638.7, "Force talon D (N)": 563.6, "Force avant-pied G (N)": 577.8, "Force avant-pied D (N)": 613.4, "Pression talon G (N/cm²)": 26.3, "Pression talon D (N/cm²)": 25.4, "COP G (mm)": 242.6, "COP D (mm)": 235.3, "Rotation G (°)": 4.6, "Rotation D (°)": 16.0, "Transition G (s)": 0.07, "Transition D (s)": 0.06, "Longueur foulée (cm)": 280, "Largeur pas (cm)": 3, "Poids (kg)": 70, }, { "Nom": "Baptiste PRETOT", "Date": "03/03/2026", "Vitesse (km/h)": 18, "Cadence (pas/min)": 176, "Contact (%)": 69.2, "Flight (%)": 30.8, "Force talon G (N)": 700, "Force talon D (N)": 650, "Force avant-pied G (N)": 690, "Force avant-pied D (N)": 710, "Pression talon G (N/cm²)": 28, "Pression talon D (N/cm²)": 27, "COP G (mm)": 248, "COP D (mm)": 243, "Rotation G (°)": 5, "Rotation D (°)": 17, "Transition G (s)": 0.06, "Transition D (s)": 0.05, "Longueur foulée (cm)": 330, "Largeur pas (cm)": 3, "Poids (kg)": 70, }, ] ) def normalize_header(value): return ( str(value) .strip() .lower() .replace("é", "e") .replace("è", "e") .replace("ê", "e") .replace("à", "a") .replace("ù", "u") .replace("ç", "c") .replace("²", "2") ) def resolve_column(df, candidates): normalized = {normalize_header(c): c for c in df.columns} for candidate in candidates: key = normalize_header(candidate) if key in normalized: return normalized[key] return None def standardize_dataframe(df): out = pd.DataFrame() missing = [] for target, candidates in REQUIRED_COLUMNS.items(): col = resolve_column(df, candidates) if col is None: if target == "poids": out[target] = np.nan continue missing.append(candidates[0]) continue out[target] = df[col] if missing: st.error("Colonnes manquantes : " + ", ".join(missing)) st.stop() for col in out.columns: if col not in ["nom", "date"]: out[col] = pd.to_numeric(out[col], errors="coerce") return out.dropna(subset=["nom", "vitesse"]).reset_index(drop=True) def avg(a, b): return (float(a) + float(b)) / 2 def asym(a, b): m = avg(a, b) if m == 0: return 0.0 return abs(float(a) - float(b)) / m * 100 def clamp_score(value, low, high, reverse=False): if pd.isna(value): return 0 score = (value - low) / (high - low) * 100 score = max(0, min(100, score)) return 100 - score if reverse else score def compute_metrics(row, poids_override): poids_n = poids_override * 9.81 if poids_override else np.nan force_talon_moy = avg(row["force_talon_g"], row["force_talon_d"]) force_avant_moy = avg(row["force_avant_g"], row["force_avant_d"]) pression_moy = avg(row["pression_talon_g"], row["pression_talon_d"]) cop_moy = avg(row["cop_g"], row["cop_d"]) transition_moy = avg(row["transition_g"], row["transition_d"]) asym_talon = asym(row["force_talon_g"], row["force_talon_d"]) asym_avant = asym(row["force_avant_g"], row["force_avant_d"]) asym_cop = asym(row["cop_g"], row["cop_d"]) diff_rotation = abs(row["rotation_g"] - row["rotation_d"]) force_talon_bw = force_talon_moy / poids_n if poids_n and not pd.isna(poids_n) else np.nan ratio_talon_avant = force_talon_moy / force_avant_moy if force_avant_moy else np.nan impact = round( 0.6 * clamp_score(force_talon_bw if not pd.isna(force_talon_bw) else force_talon_moy, 0.6 if not pd.isna(force_talon_bw) else 400, 1.1 if not pd.isna(force_talon_bw) else 750) + 0.4 * clamp_score(pression_moy, 15, 30) ) dynamique = round( 0.6 * clamp_score(row["cadence"], 150, 185) + 0.4 * clamp_score(row["contact"], 68, 76, reverse=True) ) symetrie = round(100 - min(100, (asym_talon + asym_avant + asym_cop + diff_rotation) * 2.5)) technique = round( 0.5 * clamp_score(cop_moy, 210, 260) + 0.5 * clamp_score(transition_moy, 0.05, 0.09, reverse=True) ) attaque = "mixte" if ratio_talon_avant > 1.05: attaque = "talon" elif ratio_talon_avant < 0.95: attaque = "avant-pied" return { "impact": impact, "dynamique": dynamique, "symetrie": symetrie, "technique": technique, "attaque": attaque, "force_talon_moy": force_talon_moy, "force_talon_bw": force_talon_bw, "asym_talon": asym_talon, "cop_moy": cop_moy, "diff_rotation": diff_rotation, } def build_summary(row, metrics): impact_txt = "marqué" if metrics["impact"] >= 70 else "modéré" if metrics["impact"] >= 45 else "faible" dyn_txt = "bonne" if metrics["dynamique"] >= 70 else "moyenne" if metrics["dynamique"] >= 45 else "faible" sym_txt = "satisfaisante" if metrics["symetrie"] >= 70 else "perfectible" tech_txt = "efficace" if metrics["technique"] >= 70 else "à surveiller" return ( f"À {row['vitesse']} km/h, {row['nom']} présente une attaque {metrics['attaque']}, " f"un impact {impact_txt}, une dynamique {dyn_txt}, une symétrie {sym_txt} " f"et un déroulé du pied {tech_txt}." ) def draw_radar(metrics): labels = ["Impact", "Dynamique", "Symétrie", "Technique"] values = [metrics["impact"], metrics["dynamique"], metrics["symetrie"], metrics["technique"]] values += values[:1] angles = np.linspace(0, 2 * np.pi, len(labels), endpoint=False).tolist() angles += angles[:1] fig = plt.figure(figsize=(5, 5)) ax = plt.subplot(111, polar=True) ax.plot(angles, values, linewidth=2) ax.fill(angles, values, alpha=0.25) ax.set_xticks(angles[:-1]) ax.set_xticklabels(labels) ax.set_ylim(0, 100) ax.set_yticks([25, 50, 75, 100]) ax.set_title("Radar biomécanique", pad=20) return fig def draw_evolution(df, poids_override): data = [] for _, row in df.sort_values("vitesse").iterrows(): m = compute_metrics(row, poids_override) data.append({ "Vitesse": row["vitesse"], "Impact": m["impact"], "Dynamique": m["dynamique"], "Symétrie": m["symetrie"], "Technique": m["technique"], }) evo = pd.DataFrame(data) fig, ax = plt.subplots(figsize=(8, 4)) for col in ["Impact", "Dynamique", "Symétrie", "Technique"]: ax.plot(evo["Vitesse"], evo[col], marker="o", label=col) ax.set_ylim(0, 100) ax.set_xlabel("Vitesse (km/h)") ax.set_ylabel("Score /100") ax.set_title("Évolution avec l’allure") ax.legend() ax.grid(True, alpha=0.3) return fig st.title("Fiche biomécanique Zebris") st.caption("Version test — mode démo + import Excel standardisé") with st.sidebar: st.header("Mode") mode_demo = st.toggle("Utiliser le jeu de données de démonstration", value=True) uploaded_file = None if not mode_demo: uploaded_file = st.file_uploader( "Importer un fichier .xlsx, .xls ou .csv", type=["xlsx", "xls", "csv"], accept_multiple_files=False, ) if uploaded_file is not None and uploaded_file.size > 5 * 1024 * 1024: st.error("Fichier trop volumineux (>5 MB)") st.stop() try: if mode_demo: df = standardize_dataframe(SAMPLE_DF) else: if uploaded_file is None: st.info("Importe un fichier ou active le mode démonstration.") st.stop() if uploaded_file.name.lower().endswith(".csv"): raw_df = pd.read_csv(uploaded_file) else: raw_df = pd.read_excel(uploaded_file) df = standardize_dataframe(raw_df) except Exception as e: st.error(f"Erreur de lecture du fichier : {e}") st.stop() athletes = sorted(df["nom"].dropna().unique().tolist()) selected_athlete = st.selectbox("Sportif", athletes) sub_df = df[df["nom"] == selected_athlete].sort_values("vitesse") allures = sub_df["vitesse"].tolist() selected_speed = st.selectbox("Allure analysée (km/h)", allures) row = sub_df[sub_df["vitesse"] == selected_speed].iloc[0] poids_default = row["poids"] if pd.notna(row["poids"]) else 70.0 poids_override = st.number_input("Poids du sportif (kg)", min_value=0.0, value=float(poids_default), step=0.1) metrics = compute_metrics(row, poids_override) summary = build_summary(row, metrics) c1, c2, c3, c4 = st.columns(4) with c1: st.metric("Impact", f"{metrics['impact']}/100") with c2: st.metric("Dynamique", f"{metrics['dynamique']}/100") with c3: st.metric("Symétrie", f"{metrics['symetrie']}/100") with c4: st.metric("Technique", f"{metrics['technique']}/100") left, right = st.columns([1.2, 1]) with left: st.subheader("Carte d’identité biomécanique") st.write(summary) indicators = pd.DataFrame( { "Indicateur": [ "Cadence", "Contact", "Flight", "Force talon moyenne", "Force talon normalisée", "Asymétrie talon", "COP moyen", "Différence rotation", "Attaque", ], "Valeur": [ f"{row['cadence']:.1f} pas/min", f"{row['contact']:.1f} %", f"{row['flight']:.1f} %", f"{metrics['force_talon_moy']:.1f} N", f"{metrics['force_talon_bw']:.2f} BW" if not pd.isna(metrics['force_talon_bw']) else "N/A", f"{metrics['asym_talon']:.1f} %", f"{metrics['cop_moy']:.1f} mm", f"{metrics['diff_rotation']:.1f}°", metrics["attaque"], ], } ) st.dataframe(indicators, hide_index=True, use_container_width=True) st.subheader("Points d’attention") if metrics["impact"] >= 70: st.warning("Contraintes d’impact à surveiller") if metrics["symetrie"] < 55: st.warning("Asymétrie fonctionnelle à contrôler") if metrics["technique"] < 55: st.warning("Déroulé / transition à surveiller") if metrics["attaque"] == "avant-pied": st.warning("Charge distale potentiellement plus élevée") if not ( metrics["impact"] >= 70 or metrics["symetrie"] < 55 or metrics["technique"] < 55 or metrics["attaque"] == "avant-pied" ): st.success("Aucun point d’attention majeur sur cette allure.") with right: st.subheader("Radar biomécanique") st.pyplot(draw_radar(metrics), use_container_width=True) st.subheader("Évolution avec l’allure") st.pyplot(draw_evolution(sub_df, poids_override), use_container_width=True)