Spaces:
Sleeping
Sleeping
| 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) |