Spaces:
Sleeping
Sleeping
| import io | |
| import re | |
| import numpy as np | |
| import pandas as pd | |
| import streamlit as st | |
| import matplotlib.pyplot as plt | |
| import pdfplumber | |
| from PyPDF2 import PdfReader | |
| from zebris_extractor import extract_zebris_csv | |
| st.set_page_config(page_title="Zebris — Profil biomécanique complet", layout="wide") | |
| st.title("Zebris — Profil biomécanique complet") | |
| st.caption("Import CSV + PDF Zebris → fiche biomécanique enrichie + seuils individualisés") | |
| with st.sidebar: | |
| st.header("Imports CSV") | |
| uploaded_csvs = st.file_uploader( | |
| "Importer un ou plusieurs CSV Zebris", | |
| type=["csv"], | |
| accept_multiple_files=True, | |
| key="csvs", | |
| ) | |
| st.header("Imports PDF") | |
| uploaded_pdfs = st.file_uploader( | |
| "Importer un ou plusieurs PDF Zebris", | |
| type=["pdf"], | |
| accept_multiple_files=True, | |
| key="pdfs", | |
| ) | |
| st.header("Contexte") | |
| volume_horaire = st.number_input( | |
| "Volume horaire / semaine", | |
| min_value=0.5, | |
| max_value=40.0, | |
| value=5.0, | |
| step=0.5, | |
| ) | |
| if not uploaded_csvs: | |
| st.info("Importe au moins un CSV Zebris.") | |
| st.stop() | |
| def avg(a, b): | |
| if pd.isna(a) and pd.isna(b): | |
| return np.nan | |
| if pd.isna(a): | |
| return float(b) | |
| if pd.isna(b): | |
| return float(a) | |
| return (float(a) + float(b)) / 2 | |
| def asym(a, b): | |
| m = avg(a, b) | |
| if pd.isna(m) or m == 0 or pd.isna(a) or pd.isna(b): | |
| return np.nan | |
| return abs(float(a) - float(b)) / m * 100 | |
| def clamp_score(value, low, high, reverse=False): | |
| if pd.isna(value): | |
| return np.nan | |
| score = (value - low) / (high - low) * 100 | |
| score = max(0, min(100, score)) | |
| return 100 - score if reverse else score | |
| def safe_mean(values): | |
| vals = [v for v in values if pd.notna(v)] | |
| if not vals: | |
| return np.nan | |
| return float(np.mean(vals)) | |
| def normalize_name(name: str) -> str: | |
| if not name: | |
| return "" | |
| return ( | |
| str(name) | |
| .strip() | |
| .lower() | |
| .replace("é", "e") | |
| .replace("è", "e") | |
| .replace("ê", "e") | |
| .replace("à", "a") | |
| .replace("ù", "u") | |
| .replace("ç", "c") | |
| ) | |
| def compute_external_thresholds(poids_kg, volume_horaire): | |
| poids_n = poids_kg * 9.81 | |
| if volume_horaire <= 3: | |
| charge = "faible" | |
| force_bw_low, force_bw_high = 0.25, 0.40 | |
| pression_low, pression_high = 4.0, 8.0 | |
| cadence_low, cadence_high = 160, 172 | |
| contact_low, contact_high = 69, 74 | |
| flight_low, flight_high = 26, 30 | |
| asym_low, asym_high = 6, 10 | |
| rotation_low, rotation_high = 6, 10 | |
| elif volume_horaire <= 6: | |
| charge = "modérée" | |
| force_bw_low, force_bw_high = 0.22, 0.37 | |
| pression_low, pression_high = 4.0, 7.5 | |
| cadence_low, cadence_high = 164, 176 | |
| contact_low, contact_high = 68, 73 | |
| flight_low, flight_high = 27, 31 | |
| asym_low, asym_high = 5, 9 | |
| rotation_low, rotation_high = 5, 9 | |
| else: | |
| charge = "élevée" | |
| force_bw_low, force_bw_high = 0.20, 0.35 | |
| pression_low, pression_high = 4.0, 7.0 | |
| cadence_low, cadence_high = 168, 180 | |
| contact_low, contact_high = 67, 72 | |
| flight_low, flight_high = 28, 32 | |
| asym_low, asym_high = 4, 8 | |
| rotation_low, rotation_high = 4, 8 | |
| return { | |
| "charge": charge, | |
| "poids_n": poids_n, | |
| "force_n_low": force_bw_low * poids_n, | |
| "force_n_high": force_bw_high * poids_n, | |
| "pression_low": pression_low, | |
| "pression_high": pression_high, | |
| "cadence_low": cadence_low, | |
| "cadence_high": cadence_high, | |
| "contact_low": contact_low, | |
| "contact_high": contact_high, | |
| "flight_low": flight_low, | |
| "flight_high": flight_high, | |
| "asym_low": asym_low, | |
| "asym_high": asym_high, | |
| "rotation_low": rotation_low, | |
| "rotation_high": rotation_high, | |
| } | |
| def estimate_attack_from_csv(force_talon_moy, force_avant_moy, transition_moy): | |
| if pd.isna(force_talon_moy) or pd.isna(force_avant_moy) or force_avant_moy == 0: | |
| return "indéterminée" | |
| ratio = force_talon_moy / force_avant_moy | |
| if pd.isna(transition_moy): | |
| if ratio > 1.10: | |
| return "attaque talon" | |
| elif ratio < 0.90: | |
| return "attaque avant-pied" | |
| return "attaque médio-pied" | |
| if ratio > 1.10 and transition_moy >= 0.070: | |
| return "attaque talon" | |
| elif ratio < 0.90 and transition_moy <= 0.055: | |
| return "attaque avant-pied" | |
| return "attaque médio-pied" | |
| def compute_profile_metrics(row, poids_kg): | |
| poids_n = poids_kg * 9.81 | |
| force_talon_moy = avg(row["Force talon G (N)"], row["Force talon D (N)"]) | |
| force_avant_moy = avg(row["Force avant-pied G (N)"], row["Force avant-pied D (N)"]) | |
| pression_talon_moy = avg(row["Pression talon G (N/cm²)"], row["Pression talon D (N/cm²)"]) | |
| cop_moy = avg(row["COP G (mm)"], row["COP D (mm)"]) | |
| transition_moy = avg(row["Transition G (s)"], row["Transition D (s)"]) | |
| asym_talon = asym(row["Force talon G (N)"], row["Force talon D (N)"]) | |
| asym_avant = asym(row["Force avant-pied G (N)"], row["Force avant-pied D (N)"]) | |
| asym_cop = asym(row["COP G (mm)"], row["COP D (mm)"]) | |
| diff_rotation = ( | |
| abs(float(row["Rotation G (°)"]) - float(row["Rotation D (°)"])) | |
| if pd.notna(row["Rotation G (°)"]) and pd.notna(row["Rotation D (°)"]) | |
| else np.nan | |
| ) | |
| force_talon_bw = force_talon_moy / poids_n if pd.notna(force_talon_moy) and poids_n else np.nan | |
| contraintes_force_score = clamp_score(force_talon_bw, 0.15, 0.45) | |
| contraintes_pressure_score = clamp_score(pression_talon_moy, 3, 10) | |
| contraintes = safe_mean([ | |
| 0.6 * contraintes_force_score if pd.notna(contraintes_force_score) else np.nan, | |
| 0.4 * contraintes_pressure_score if pd.notna(contraintes_pressure_score) else np.nan, | |
| ]) | |
| contraintes = round(contraintes) if pd.notna(contraintes) else np.nan | |
| dynamique = safe_mean([ | |
| 0.6 * clamp_score(row["Cadence (pas/min)"], 150, 185), | |
| 0.4 * clamp_score(row["Contact (%)"], 68, 76, reverse=True), | |
| ]) | |
| dynamique = round(dynamique) if pd.notna(dynamique) else np.nan | |
| sym_components = [x for x in [asym_talon, asym_avant, asym_cop, diff_rotation] if pd.notna(x)] | |
| symetrie = round(100 - min(100, np.mean(sym_components) * 2.5)) if sym_components else np.nan | |
| deroule = safe_mean([ | |
| 0.5 * clamp_score(cop_moy, 210, 260), | |
| 0.5 * clamp_score(transition_moy, 0.05, 0.09, reverse=True), | |
| ]) | |
| deroule = round(deroule) if pd.notna(deroule) else np.nan | |
| attaque_csv = estimate_attack_from_csv(force_talon_moy, force_avant_moy, transition_moy) | |
| return { | |
| "force_talon_moy": force_talon_moy, | |
| "force_avant_moy": force_avant_moy, | |
| "pression_talon_moy": pression_talon_moy, | |
| "cop_moy": cop_moy, | |
| "transition_moy": transition_moy, | |
| "asym_talon": asym_talon, | |
| "asym_avant": asym_avant, | |
| "asym_cop": asym_cop, | |
| "diff_rotation": diff_rotation, | |
| "contraintes": contraintes, | |
| "dynamique": dynamique, | |
| "symetrie": symetrie, | |
| "deroule": deroule, | |
| "attaque_csv": attaque_csv, | |
| } | |
| def draw_radar(metrics): | |
| labels = ["Contraintes", "Dynamique", "Symétrie", "Déroulé"] | |
| values = [ | |
| metrics["contraintes"] if pd.notna(metrics["contraintes"]) else 0, | |
| metrics["dynamique"] if pd.notna(metrics["dynamique"]) else 0, | |
| metrics["symetrie"] if pd.notna(metrics["symetrie"]) else 0, | |
| metrics["deroule"] if pd.notna(metrics["deroule"]) else 0, | |
| ] | |
| 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_kg): | |
| data = [] | |
| for _, r in df.sort_values("Vitesse (km/h)").iterrows(): | |
| m = compute_profile_metrics(r, poids_kg) | |
| data.append({ | |
| "Vitesse": r["Vitesse (km/h)"], | |
| "Contraintes": m["contraintes"], | |
| "Dynamique": m["dynamique"], | |
| "Symétrie": m["symetrie"], | |
| "Déroulé": m["deroule"], | |
| }) | |
| evo = pd.DataFrame(data) | |
| fig, ax = plt.subplots(figsize=(8, 4)) | |
| for col in ["Contraintes", "Dynamique", "Symétrie", "Déroulé"]: | |
| 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 | |
| def extract_text_from_pdf(uploaded_pdf) -> str: | |
| uploaded_pdf.seek(0) | |
| raw = uploaded_pdf.read() | |
| uploaded_pdf.seek(0) | |
| # 1) Essai avec pdfplumber | |
| try: | |
| text_parts = [] | |
| with pdfplumber.open(io.BytesIO(raw)) as pdf: | |
| for page in pdf.pages: | |
| txt = page.extract_text() or "" | |
| if txt: | |
| text_parts.append(txt) | |
| text = "\n".join(text_parts).strip() | |
| if text: | |
| return text | |
| except Exception: | |
| pass | |
| # 2) Fallback avec PyPDF2 | |
| try: | |
| reader = PdfReader(io.BytesIO(raw)) | |
| text_parts = [] | |
| for page in reader.pages: | |
| txt = page.extract_text() or "" | |
| if txt: | |
| text_parts.append(txt) | |
| text = "\n".join(text_parts).strip() | |
| if text: | |
| return text | |
| except Exception: | |
| pass | |
| # 3) Si rien ne marche | |
| return "" | |
| def find_float_after_label(text: str, label: str, max_numbers: int = 2, window: int = 1200): | |
| """ | |
| Cherche un label dans le texte extrait puis récupère les premiers nombres après ce label. | |
| Version tolérante aux retours ligne / espaces / accents du PDF Zebris. | |
| """ | |
| if not text or not label: | |
| return [] | |
| text_norm = text.lower().replace("\xa0", " ") | |
| label_norm = label.lower().replace("\xa0", " ") | |
| idx = text_norm.find(label_norm) | |
| if idx == -1: | |
| return [] | |
| snippet = text[idx: idx + window] | |
| nums = re.findall(r"(\d+,\d+|\d+\.\d+|\d+)", snippet) | |
| out = [] | |
| for n in nums[:max_numbers]: | |
| try: | |
| out.append(float(n.replace(",", "."))) | |
| except Exception: | |
| pass | |
| return out | |
| def extract_name_from_filename(filename: str): | |
| if not filename: | |
| return None | |
| base = filename.rsplit("/", 1)[-1] | |
| base = base.rsplit(".", 1)[0] | |
| # ex: 19850515_ERIC_TEVANE_124522_Analyse... | |
| m = re.search(r"\d{8}_([A-Z]+)_([A-Z]+)_", base.upper()) | |
| if m: | |
| first_name = m.group(1).strip() | |
| last_name = m.group(2).strip() | |
| return f"{first_name} {last_name}" | |
| return None | |
| def extract_pdf_name(text: str, source_pdf: str = None): | |
| # priorité au nom du fichier | |
| name_from_file = extract_name_from_filename(source_pdf) if source_pdf else None | |
| if name_from_file: | |
| return name_from_file | |
| if not text: | |
| return None | |
| patterns = [ | |
| r"Personne:\s*([A-Za-zÀ-ÿ\- ]+),\s*\d{2}/\d{2}/\d{4}", | |
| r"Personne:\s*([A-Za-zÀ-ÿ\- ]+)", | |
| ] | |
| for pattern in patterns: | |
| m = re.search(pattern, text, flags=re.S) | |
| if m: | |
| name = " ".join(m.group(1).split()).strip() | |
| if len(name) >= 4: | |
| return name.upper() | |
| return None | |
| def extract_speed_from_text(full_text: str): | |
| if not full_text: | |
| return np.nan | |
| text = full_text.replace("\xa0", " ") | |
| text = re.sub(r"\s+", " ", text) | |
| # Cas propres | |
| patterns = [ | |
| r"VMA\s*(\d+(?:[.,]\d+)?)\s*kmh", | |
| r"VMA\s*(\d+(?:[.,]\d+)?)\s*km/h", | |
| r"(\d+(?:[.,]\d+)?)\s*kmh", | |
| r"(\d+(?:[.,]\d+)?)\s*km/h", | |
| ] | |
| for pattern in patterns: | |
| m = re.search(pattern, text, flags=re.I) | |
| if m: | |
| return float(m.group(1).replace(",", ".")) | |
| # Cas OCR cassé observé : "A Mmh14Vk" | |
| m = re.search(r"Mmh\s*(\d+(?:[.,]\d+)?)\s*Vk", text, flags=re.I) | |
| if m: | |
| return float(m.group(1).replace(",", ".")) | |
| # Variante encore plus souple | |
| m = re.search(r"[Mm]\w{0,3}\s*(\d+(?:[.,]\d+)?)\s*[Vv][Kk]", text) | |
| if m: | |
| return float(m.group(1).replace(",", ".")) | |
| return np.nan | |
| def parse_zebris_pdf(uploaded_pdf): | |
| uploaded_pdf.seek(0) | |
| raw = uploaded_pdf.read() | |
| uploaded_pdf.seek(0) | |
| source_pdf = uploaded_pdf.name | |
| data = { | |
| "athlete_name": extract_name_from_filename(source_pdf), | |
| "source_pdf": source_pdf, | |
| "speed_kmh": np.nan, | |
| "transition_g": np.nan, | |
| "transition_d": np.nan, | |
| "heel_force_g": np.nan, | |
| "heel_force_d": np.nan, | |
| "mid_force_g": np.nan, | |
| "mid_force_d": np.nan, | |
| "fore_force_g": np.nan, | |
| "fore_force_d": np.nan, | |
| "heel_pressure_g": np.nan, | |
| "heel_pressure_d": np.nan, | |
| "mid_pressure_g": np.nan, | |
| "mid_pressure_d": np.nan, | |
| "fore_pressure_g": np.nan, | |
| "fore_pressure_d": np.nan, | |
| "heel_peak_time_pct_g": np.nan, | |
| "heel_peak_time_pct_d": np.nan, | |
| "mid_peak_time_pct_g": np.nan, | |
| "mid_peak_time_pct_d": np.nan, | |
| "fore_peak_time_pct_g": np.nan, | |
| "fore_peak_time_pct_d": np.nan, | |
| "attaque_pdf": "indéterminée", | |
| } | |
| def to_float(x): | |
| return float(x.replace(",", ".").replace(" ", "")) | |
| # -------------------------------------------------- | |
| # Lecture texte PDF robuste : pdfplumber puis PyPDF2 | |
| # -------------------------------------------------- | |
| page_texts = [] | |
| try: | |
| with pdfplumber.open(io.BytesIO(raw)) as pdf: | |
| for page in pdf.pages: | |
| txt = page.extract_text() or "" | |
| txt = txt.replace("\xa0", " ") | |
| txt = re.sub(r"\s+", " ", txt).strip() | |
| page_texts.append(txt) | |
| except Exception: | |
| try: | |
| reader = PdfReader(io.BytesIO(raw)) | |
| for page in reader.pages: | |
| txt = page.extract_text() or "" | |
| txt = txt.replace("\xa0", " ") | |
| txt = re.sub(r"\s+", " ", txt).strip() | |
| page_texts.append(txt) | |
| except Exception: | |
| data["attaque_pdf"] = "indéterminée" | |
| return data | |
| full_text = " ".join(page_texts) | |
| if not full_text.strip(): | |
| data["attaque_pdf"] = "indéterminée" | |
| return data | |
| # Nom athlète fallback | |
| if not data["athlete_name"]: | |
| data["athlete_name"] = extract_pdf_name(full_text, source_pdf=source_pdf) | |
| # Allure PDF | |
| data["speed_kmh"] = extract_speed_from_text(full_text) | |
| # -------------------------------------------------- | |
| # Chercher la page utile Zebris | |
| # -------------------------------------------------- | |
| zone_page_text = None | |
| for txt in page_texts: | |
| txt_lower = txt.lower() | |
| if ( | |
| "force maximale" in txt_lower | |
| and "pression maximale" in txt_lower | |
| and "instant pic de force" in txt_lower | |
| ): | |
| zone_page_text = txt | |
| break | |
| if zone_page_text is None: | |
| data["attaque_pdf"] = estimate_attack_from_pdf(data) | |
| return data | |
| zone_page_text = zone_page_text.replace("\xa0", " ") | |
| zone_page_text = re.sub(r"\s+", " ", zone_page_text).strip() | |
| pair_pattern = re.compile( | |
| r"Gauche\s+(\d+,\d+|\d+\.\d+|\d+)\s*(?:±|[–-])?\s*(\d+,\d+|\d+\.\d+|\d+)?" | |
| r".{0,60}?" | |
| r"(?:Droite|Droit|oeitDr)\s+(\d+,\d+|\d+\.\d+|\d+)\s*(?:±|[–-])?\s*(\d+,\d+|\d+\.\d+|\d+)?", | |
| flags=re.S | |
| ) | |
| matches = pair_pattern.findall(zone_page_text) | |
| values = [] | |
| for m in matches: | |
| try: | |
| g_mean = to_float(m[0]) | |
| d_mean = to_float(m[2]) | |
| values.append((g_mean, d_mean)) | |
| except Exception: | |
| pass | |
| if len(values) >= 11: | |
| data["transition_g"], data["transition_d"] = values[0] | |
| data["fore_force_g"], data["fore_force_d"] = values[2] | |
| data["mid_force_g"], data["mid_force_d"] = values[3] | |
| data["heel_force_g"], data["heel_force_d"] = values[4] | |
| data["fore_pressure_g"], data["fore_pressure_d"] = values[5] | |
| data["mid_pressure_g"], data["mid_pressure_d"] = values[6] | |
| data["heel_pressure_g"], data["heel_pressure_d"] = values[7] | |
| data["fore_peak_time_pct_g"], data["fore_peak_time_pct_d"] = values[8] | |
| data["mid_peak_time_pct_g"], data["mid_peak_time_pct_d"] = values[9] | |
| data["heel_peak_time_pct_g"], data["heel_peak_time_pct_d"] = values[10] | |
| data["attaque_pdf"] = estimate_attack_from_pdf(data) | |
| return data | |
| def estimate_attack_from_pdf(pdf_data: dict): | |
| heel_peak_t = avg(pdf_data.get("heel_peak_time_pct_g"), pdf_data.get("heel_peak_time_pct_d")) | |
| fore_peak_t = avg(pdf_data.get("fore_peak_time_pct_g"), pdf_data.get("fore_peak_time_pct_d")) | |
| heel_force = avg(pdf_data.get("heel_force_g"), pdf_data.get("heel_force_d")) | |
| fore_force = avg(pdf_data.get("fore_force_g"), pdf_data.get("fore_force_d")) | |
| transition = avg(pdf_data.get("transition_g"), pdf_data.get("transition_d")) | |
| # sécurité | |
| if pd.isna(transition) and pd.isna(heel_peak_t): | |
| return "indéterminée" | |
| ratio = np.nan | |
| if pd.notna(heel_force) and pd.notna(fore_force) and fore_force != 0: | |
| ratio = heel_force / fore_force | |
| # -------------------------------------------------- | |
| # 1. Attaque talon : appui talon précoce + transition pas trop immédiate | |
| # -------------------------------------------------- | |
| if pd.notna(heel_peak_t) and pd.notna(transition): | |
| if heel_peak_t <= 15 and transition >= 0.025: | |
| return "attaque talon" | |
| # -------------------------------------------------- | |
| # 2. Attaque avant-pied : très peu de talon + transition très précoce | |
| # -------------------------------------------------- | |
| if pd.notna(heel_peak_t) and pd.notna(transition) and pd.notna(ratio): | |
| if heel_peak_t >= 18 and transition <= 0.015 and ratio < 0.20: | |
| return "attaque avant-pied" | |
| # -------------------------------------------------- | |
| # 3. Médio-pied : entre les deux | |
| # -------------------------------------------------- | |
| if pd.notna(transition): | |
| if 0.015 < transition < 0.025: | |
| return "attaque médio-pied" | |
| # -------------------------------------------------- | |
| # 4. Fallback basé sur timing talon | |
| # -------------------------------------------------- | |
| if pd.notna(heel_peak_t): | |
| if heel_peak_t <= 13: | |
| return "attaque talon" | |
| elif heel_peak_t >= 18: | |
| return "attaque avant-pied" | |
| else: | |
| return "attaque médio-pied" | |
| # -------------------------------------------------- | |
| # 5. Fallback basé sur ratio de charge uniquement | |
| # -------------------------------------------------- | |
| if pd.notna(ratio): | |
| if ratio >= 0.25: | |
| return "attaque talon" | |
| elif ratio <= 0.10: | |
| return "attaque avant-pied" | |
| else: | |
| return "attaque médio-pied" | |
| return "indéterminée" | |
| def match_pdf_to_athlete_and_speed(pdfs_data, athlete_name, selected_speed, tolerance=0.3): | |
| target = normalize_name(athlete_name) | |
| target_parts = set(target.split()) | |
| best_pdf = None | |
| best_score = -1 | |
| for pdf in pdfs_data: | |
| pdf_name = normalize_name(pdf.get("athlete_name")) | |
| pdf_parts = set(pdf_name.split()) | |
| if not pdf_name: | |
| continue | |
| # score nom | |
| name_score = len(target_parts.intersection(pdf_parts)) | |
| # bonus si allure du PDF = allure sélectionnée | |
| pdf_speed = pdf.get("speed_kmh") | |
| speed_score = 0 | |
| if pd.notna(pdf_speed) and abs(float(pdf_speed) - float(selected_speed)) <= tolerance: | |
| speed_score = 10 | |
| total_score = name_score + speed_score | |
| # priorité absolue si nom exact + bonne allure | |
| if pdf_name == target and speed_score == 10: | |
| return pdf | |
| if total_score > best_score: | |
| best_score = total_score | |
| best_pdf = pdf | |
| # on accepte si on a au moins un vrai match de nom | |
| if best_score >= 1: | |
| return best_pdf | |
| # fallback seulement si un seul PDF | |
| if len(pdfs_data) == 1: | |
| return pdfs_data[0] | |
| return None | |
| # ========================================================= | |
| # ANALYSE V3 | |
| # ========================================================= | |
| ANALYSIS_CONFIG = { | |
| "heel_force_N": { | |
| "label": "Force talon", | |
| "unit": "N", | |
| "description_low": "Force talon plutôt faible par rapport à la zone attendue.", | |
| "description_normal": "Force talon dans la zone attendue.", | |
| "description_high": "Force talon élevée, pouvant refléter une contrainte d'impact majorée." | |
| }, | |
| "heel_pressure_N_cm2": { | |
| "label": "Pression talon", | |
| "unit": "N/cm²", | |
| "description_low": "Pression talon plutôt faible.", | |
| "description_normal": "Pression talon dans la zone attendue.", | |
| "description_high": "Pression talon élevée, pouvant indiquer une concentration de charge accrue." | |
| }, | |
| "cadence_spm": { | |
| "label": "Cadence", | |
| "unit": "pas/min", | |
| "description_low": "Cadence basse par rapport à la zone attendue.", | |
| "description_normal": "Cadence dans la zone attendue.", | |
| "description_high": "Cadence élevée par rapport à la zone attendue." | |
| }, | |
| "contact_pct": { | |
| "label": "Temps de contact", | |
| "unit": "%", | |
| "description_low": "Temps de contact plutôt faible.", | |
| "description_normal": "Temps de contact dans la zone attendue.", | |
| "description_high": "Temps de contact élevé, pouvant traduire une dynamique de course réduite." | |
| }, | |
| "flight_pct": { | |
| "label": "Temps de vol", | |
| "unit": "%", | |
| "description_low": "Temps de vol plutôt faible.", | |
| "description_normal": "Temps de vol dans la zone attendue.", | |
| "description_high": "Temps de vol élevé par rapport à la zone attendue." | |
| }, | |
| "asymmetry_pct": { | |
| "label": "Asymétrie talon", | |
| "unit": "%", | |
| "description_low": "Asymétrie faible.", | |
| "description_normal": "Asymétrie dans la zone acceptable.", | |
| "description_high": "Asymétrie élevée, à surveiller." | |
| }, | |
| "foot_rotation_deg": { | |
| "label": "Différence rotation G/D", | |
| "unit": "°", | |
| "description_low": "Différence de rotation plutôt faible.", | |
| "description_normal": "Différence de rotation dans la zone attendue.", | |
| "description_high": "Différence de rotation élevée, pouvant majorer certaines contraintes mécaniques." | |
| } | |
| } | |
| def clamp(value, min_value=0, max_value=100): | |
| return max(min_value, min(max_value, value)) | |
| def classify_value(value, low, high): | |
| if value is None or pd.isna(value): | |
| return "non disponible" | |
| if value < low: | |
| return "basse" | |
| if value > high: | |
| return "élevée" | |
| return "normale" | |
| def compute_deviation_score(value, low, high): | |
| if value is None or pd.isna(value): | |
| return 0.0 | |
| if low <= value <= high: | |
| return 0.0 | |
| if value < low: | |
| if low == 0: | |
| return 0.0 | |
| return round((low - value) / low, 3) | |
| if value > high: | |
| if high == 0: | |
| return 0.0 | |
| return round((value - high) / high, 3) | |
| return 0.0 | |
| def get_priority(status, deviation_score, variable_key=None): | |
| if status == "normale": | |
| return "RAS" | |
| if status == "basse": | |
| if variable_key in ["cadence_spm", "flight_pct"]: | |
| return "modérée" | |
| return "faible" | |
| if status == "élevée": | |
| if deviation_score >= 0.20: | |
| return "élevée" | |
| return "modérée" | |
| return "RAS" | |
| def priority_to_points(priority): | |
| mapping = {"RAS": 0, "faible": 1, "modérée": 2, "élevée": 3} | |
| return mapping.get(priority, 0) | |
| def pattern_priority_to_points(priority): | |
| mapping = {"modérée": 2, "élevée": 3} | |
| return mapping.get(priority, 0) | |
| def build_analysis_inputs(row, metrics, thresholds, attaque_finale): | |
| merged_data = { | |
| "heel_force_N": metrics["force_talon_moy"], | |
| "heel_pressure_N_cm2": metrics["pression_talon_moy"], | |
| "cadence_spm": row["Cadence (pas/min)"], | |
| "contact_pct": row["Contact (%)"], | |
| "flight_pct": row["Flight (%)"], | |
| "asymmetry_pct": metrics["asym_talon"], | |
| "foot_rotation_deg": metrics["diff_rotation"], | |
| "impact_score": metrics["contraintes"], | |
| "dynamic_score": metrics["dynamique"], | |
| "symmetry_score": metrics["symetrie"], | |
| "rollover_score": metrics["deroule"], | |
| "attack_type": attaque_finale, | |
| } | |
| thresholds_analysis = { | |
| "heel_force_N": { | |
| "low": thresholds["force_n_low"], | |
| "high": thresholds["force_n_high"], | |
| }, | |
| "heel_pressure_N_cm2": { | |
| "low": thresholds["pression_low"], | |
| "high": thresholds["pression_high"], | |
| }, | |
| "cadence_spm": { | |
| "low": thresholds["cadence_low"], | |
| "high": thresholds["cadence_high"], | |
| }, | |
| "contact_pct": { | |
| "low": thresholds["contact_low"], | |
| "high": thresholds["contact_high"], | |
| }, | |
| "flight_pct": { | |
| "low": thresholds["flight_low"], | |
| "high": thresholds["flight_high"], | |
| }, | |
| "asymmetry_pct": { | |
| "low": thresholds["asym_low"], | |
| "high": thresholds["asym_high"], | |
| }, | |
| "foot_rotation_deg": { | |
| "low": thresholds["rotation_low"], | |
| "high": thresholds["rotation_high"], | |
| }, | |
| } | |
| return merged_data, thresholds_analysis | |
| def analyze_variable(key, value, thresholds): | |
| if key not in ANALYSIS_CONFIG: | |
| return None | |
| if key not in thresholds: | |
| return None | |
| config = ANALYSIS_CONFIG[key] | |
| low = thresholds[key]["low"] | |
| high = thresholds[key]["high"] | |
| status = classify_value(value, low, high) | |
| deviation_score = compute_deviation_score(value, low, high) | |
| priority = get_priority(status, deviation_score, variable_key=key) | |
| if status == "basse": | |
| interpretation = config["description_low"] | |
| elif status == "élevée": | |
| interpretation = config["description_high"] | |
| elif status == "normale": | |
| interpretation = config["description_normal"] | |
| else: | |
| interpretation = "Donnée non disponible." | |
| return { | |
| "variable": key, | |
| "label": config["label"], | |
| "value": value, | |
| "unit": config["unit"], | |
| "low": low, | |
| "high": high, | |
| "status": status, | |
| "priority": priority, | |
| "deviation_score": deviation_score, | |
| "interpretation": interpretation, | |
| } | |
| def run_biomech_analysis(merged_data, thresholds_analysis): | |
| results = [] | |
| for key in ANALYSIS_CONFIG.keys(): | |
| result = analyze_variable(key, merged_data.get(key), thresholds_analysis) | |
| if result is not None: | |
| results.append(result) | |
| return pd.DataFrame(results) | |
| def get_status_map(df_analysis): | |
| if df_analysis.empty: | |
| return {} | |
| return dict(zip(df_analysis["variable"], df_analysis["status"])) | |
| def is_high(status_map, key): | |
| return status_map.get(key) == "élevée" | |
| def is_low(status_map, key): | |
| return status_map.get(key) == "basse" | |
| def detect_combined_patterns(merged_data, df_analysis): | |
| patterns = [] | |
| status_map = get_status_map(df_analysis) | |
| impact_score = merged_data.get("impact_score") | |
| dynamic_score = merged_data.get("dynamic_score") | |
| symmetry_score = merged_data.get("symmetry_score") | |
| rollover_score = merged_data.get("rollover_score") | |
| attack_type = merged_data.get("attack_type", "indéterminée") | |
| if is_high(status_map, "heel_force_N") and is_high(status_map, "heel_pressure_N_cm2"): | |
| patterns.append({ | |
| "name": "impact_load_flag", | |
| "title": "Contrainte d'impact majorée", | |
| "priority": "élevée", | |
| "category": "impact", | |
| "message": "La combinaison d'une force talon élevée et d'une pression talon élevée suggère une contrainte d'impact majorée." | |
| }) | |
| elif is_high(status_map, "heel_force_N") or is_high(status_map, "heel_pressure_N_cm2"): | |
| patterns.append({ | |
| "name": "impact_signal_flag", | |
| "title": "Signal d'impact à surveiller", | |
| "priority": "modérée", | |
| "category": "impact", | |
| "message": "Un marqueur d'impact talonnier est au-dessus de la zone attendue." | |
| }) | |
| if is_low(status_map, "cadence_spm") and is_high(status_map, "contact_pct"): | |
| patterns.append({ | |
| "name": "low_dynamics_flag", | |
| "title": "Dynamique de course possiblement réduite", | |
| "priority": "élevée", | |
| "category": "dynamics", | |
| "message": "La combinaison d'une cadence basse et d'un temps de contact élevé évoque une dynamique de course potentiellement réduite." | |
| }) | |
| if is_low(status_map, "flight_pct") and is_high(status_map, "contact_pct"): | |
| patterns.append({ | |
| "name": "reactivity_flag", | |
| "title": "Réactivité mécanique possiblement diminuée", | |
| "priority": "modérée", | |
| "category": "dynamics", | |
| "message": "Le temps de vol bas associé à un temps de contact élevé évoque une moindre réactivité mécanique." | |
| }) | |
| if pd.notna(dynamic_score) and dynamic_score < 50: | |
| if is_high(status_map, "contact_pct") or is_low(status_map, "flight_pct"): | |
| patterns.append({ | |
| "name": "global_dynamic_flag", | |
| "title": "Déficit dynamique renforcé", | |
| "priority": "élevée", | |
| "category": "dynamics", | |
| "message": "Le score de dynamique bas renforce l'hypothèse d'une dynamique de course altérée." | |
| }) | |
| if is_high(status_map, "asymmetry_pct"): | |
| if pd.notna(symmetry_score) and symmetry_score < 60: | |
| patterns.append({ | |
| "name": "asymmetry_flag", | |
| "title": "Asymétrie renforcée", | |
| "priority": "élevée", | |
| "category": "symmetry", | |
| "message": "L'asymétrie mesurée est élevée et cohérente avec un score de symétrie faible." | |
| }) | |
| else: | |
| patterns.append({ | |
| "name": "asymmetry_watch_flag", | |
| "title": "Asymétrie à surveiller", | |
| "priority": "modérée", | |
| "category": "symmetry", | |
| "message": "Une asymétrie au-dessus de la zone attendue est observée." | |
| }) | |
| if is_high(status_map, "foot_rotation_deg"): | |
| if pd.notna(rollover_score) and rollover_score < 60: | |
| patterns.append({ | |
| "name": "mechanical_pattern_flag", | |
| "title": "Pattern mécanique distal à surveiller", | |
| "priority": "modérée", | |
| "category": "mechanics", | |
| "message": "La différence de rotation élevée associée à un déroulé peu efficient suggère un pattern mécanique distal à surveiller." | |
| }) | |
| else: | |
| patterns.append({ | |
| "name": "rotation_flag", | |
| "title": "Différence de rotation élevée", | |
| "priority": "modérée", | |
| "category": "mechanics", | |
| "message": "La différence de rotation est au-dessus de la zone attendue." | |
| }) | |
| if attack_type == "attaque talon": | |
| if is_high(status_map, "heel_force_N") or is_high(status_map, "heel_pressure_N_cm2"): | |
| patterns.append({ | |
| "name": "rearfoot_impact_context", | |
| "title": "Attaque talon avec charge d'impact marquée", | |
| "priority": "modérée", | |
| "category": "attack", | |
| "message": "Le profil d'attaque talon est associé à des marqueurs d'impact élevés." | |
| }) | |
| if pd.notna(dynamic_score) and pd.notna(rollover_score): | |
| if dynamic_score < 50 and rollover_score < 55: | |
| patterns.append({ | |
| "name": "global_efficiency_flag", | |
| "title": "Efficience mécanique possiblement réduite", | |
| "priority": "modérée", | |
| "category": "global", | |
| "message": "La combinaison d'un score de dynamique bas et d'un déroulé faible évoque une efficience mécanique possiblement réduite." | |
| }) | |
| return deduplicate_patterns(patterns) | |
| def deduplicate_patterns(patterns): | |
| seen = set() | |
| unique_patterns = [] | |
| for pattern in patterns: | |
| key = (pattern["name"], pattern["title"]) | |
| if key not in seen: | |
| seen.add(key) | |
| unique_patterns.append(pattern) | |
| return unique_patterns | |
| def compute_domain_scores(merged_data, df_analysis, patterns): | |
| if df_analysis.empty: | |
| return { | |
| "impact": 0, | |
| "dynamics": 0, | |
| "symmetry": 0, | |
| "mechanics": 0, | |
| "attack": 0, | |
| "global": 0, | |
| } | |
| row_map = {row["variable"]: row for _, row in df_analysis.iterrows()} | |
| def var_points(var_name, weight=1.0): | |
| row = row_map.get(var_name) | |
| if row is None: | |
| return 0.0 | |
| base = priority_to_points(row["priority"]) * 10 | |
| bonus = row["deviation_score"] * 20 | |
| return (base + bonus) * weight | |
| def pattern_points(category): | |
| total = 0 | |
| for p in patterns: | |
| if p["category"] == category: | |
| total += pattern_priority_to_points(p["priority"]) * 10 | |
| return total | |
| impact_score_profile = merged_data.get("impact_score") | |
| dynamic_score_profile = merged_data.get("dynamic_score") | |
| symmetry_score_profile = merged_data.get("symmetry_score") | |
| rollover_score_profile = merged_data.get("rollover_score") | |
| impact = 0 | |
| impact += var_points("heel_force_N", 1.2) | |
| impact += var_points("heel_pressure_N_cm2", 1.2) | |
| impact += pattern_points("impact") | |
| impact += pattern_points("attack") | |
| if pd.notna(impact_score_profile) and impact_score_profile >= 70: | |
| impact += (impact_score_profile - 70) * 0.2 | |
| dynamics = 0 | |
| dynamics += var_points("cadence_spm", 1.0) | |
| dynamics += var_points("contact_pct", 1.2) | |
| dynamics += var_points("flight_pct", 1.0) | |
| dynamics += pattern_points("dynamics") | |
| if pd.notna(dynamic_score_profile) and dynamic_score_profile < 60: | |
| dynamics += (60 - dynamic_score_profile) * 0.5 | |
| symmetry = 0 | |
| symmetry += var_points("asymmetry_pct", 1.5) | |
| symmetry += pattern_points("symmetry") | |
| if pd.notna(symmetry_score_profile) and symmetry_score_profile < 70: | |
| symmetry += (70 - symmetry_score_profile) * 0.5 | |
| mechanics = 0 | |
| mechanics += var_points("foot_rotation_deg", 1.4) | |
| mechanics += pattern_points("mechanics") | |
| if pd.notna(rollover_score_profile) and rollover_score_profile < 65: | |
| mechanics += (65 - rollover_score_profile) * 0.35 | |
| attack = 0 | |
| attack += pattern_points("attack") | |
| attack += 0.5 * var_points("heel_force_N", 1.0) | |
| attack += 0.5 * var_points("heel_pressure_N_cm2", 1.0) | |
| global_score = ( | |
| impact * 0.30 + | |
| dynamics * 0.30 + | |
| symmetry * 0.20 + | |
| mechanics * 0.20 | |
| ) | |
| return { | |
| "impact": int(clamp(round(impact))), | |
| "dynamics": int(clamp(round(dynamics))), | |
| "symmetry": int(clamp(round(symmetry))), | |
| "mechanics": int(clamp(round(mechanics))), | |
| "attack": int(clamp(round(attack))), | |
| "global": int(clamp(round(global_score))), | |
| } | |
| def get_domain_label(score): | |
| if score >= 75: | |
| return "élevé" | |
| if score >= 45: | |
| return "modéré" | |
| if score >= 20: | |
| return "léger" | |
| return "faible" | |
| def get_primary_domains(domain_scores, top_n=3): | |
| filtered = {k: v for k, v in domain_scores.items() if k != "global"} | |
| return sorted(filtered.items(), key=lambda x: x[1], reverse=True)[:top_n] | |
| def compute_global_summary_v3(df_analysis, patterns, domain_scores): | |
| if df_analysis.empty: | |
| return { | |
| "normal_count": 0, | |
| "attention_count": 0, | |
| "high_priority_count": 0, | |
| "moderate_priority_count": 0, | |
| "pattern_count": 0, | |
| "global_level": "indéterminé", | |
| } | |
| normal_count = int((df_analysis["status"] == "normale").sum()) | |
| attention_count = int((df_analysis["status"] != "normale").sum()) | |
| high_priority_count = int((df_analysis["priority"] == "élevée").sum()) | |
| moderate_priority_count = int((df_analysis["priority"] == "modérée").sum()) | |
| pattern_high = sum(1 for p in patterns if p["priority"] == "élevée") | |
| pattern_moderate = sum(1 for p in patterns if p["priority"] == "modérée") | |
| global_domain_score = domain_scores.get("global", 0) | |
| total_high = high_priority_count + pattern_high | |
| total_moderate = moderate_priority_count + pattern_moderate | |
| if total_high >= 2 or global_domain_score >= 75: | |
| global_level = "élevé" | |
| elif total_high == 1 or total_moderate >= 3 or global_domain_score >= 45: | |
| global_level = "modéré" | |
| elif attention_count >= 1 or len(patterns) >= 1 or global_domain_score >= 20: | |
| global_level = "léger" | |
| else: | |
| global_level = "faible" | |
| return { | |
| "normal_count": normal_count, | |
| "attention_count": attention_count, | |
| "high_priority_count": total_high, | |
| "moderate_priority_count": total_moderate, | |
| "pattern_count": len(patterns), | |
| "global_level": global_level, | |
| } | |
| def generate_global_narrative(summary, domain_scores): | |
| if summary["global_level"] == "faible": | |
| return ( | |
| "Le profil est globalement cohérent par rapport aux seuils individualisés, " | |
| "sans signal biomécanique majeur détecté à ce stade." | |
| ) | |
| label_map = { | |
| "impact": "contrainte d'impact", | |
| "dynamics": "dynamique de course", | |
| "symmetry": "symétrie", | |
| "mechanics": "mécanique distale", | |
| "attack": "organisation de l'attaque", | |
| } | |
| top_domains = get_primary_domains(domain_scores, top_n=3) | |
| top_labels = [label_map.get(name, name) for name, score in top_domains if score >= 20] | |
| domains_text = ", ".join(top_labels) if top_labels else "plusieurs dimensions biomécaniques" | |
| if summary["global_level"] == "léger": | |
| return f"Le profil met en évidence quelques signaux isolés, principalement autour de : {domains_text}." | |
| if summary["global_level"] == "modéré": | |
| return f"Le profil présente plusieurs points d'attention cohérents, notamment sur : {domains_text}." | |
| return ( | |
| f"Le profil présente plusieurs signaux convergents, en particulier sur : {domains_text}. " | |
| "Une interprétation approfondie est justifiée avant la phase de recommandations." | |
| ) | |
| def prepare_analysis_table(df_analysis): | |
| if df_analysis.empty: | |
| return df_analysis | |
| priority_order = {"élevée": 3, "modérée": 2, "faible": 1, "RAS": 0} | |
| status_order = {"élevée": 2, "basse": 1, "normale": 0, "non disponible": -1} | |
| df = df_analysis.copy() | |
| df["priority_rank"] = df["priority"].map(priority_order).fillna(0) | |
| df["status_rank"] = df["status"].map(status_order).fillna(-1) | |
| df = df.sort_values( | |
| by=["priority_rank", "status_rank", "deviation_score"], | |
| ascending=[False, False, False] | |
| ) | |
| return df.drop(columns=["priority_rank", "status_rank"]) | |
| def display_status_badge(status): | |
| if status == "normale": | |
| st.success("Normale") | |
| elif status == "basse": | |
| st.warning("Basse") | |
| elif status == "élevée": | |
| st.error("Élevée") | |
| else: | |
| st.info("Non disponible") | |
| def display_priority_badge(priority): | |
| if priority == "RAS": | |
| st.success("RAS") | |
| elif priority == "faible": | |
| st.info("Faible") | |
| elif priority == "modérée": | |
| st.warning("Modérée") | |
| elif priority == "élevée": | |
| st.error("Élevée") | |
| else: | |
| st.info(priority) | |
| def display_pattern_badge(priority): | |
| if priority == "élevée": | |
| st.error("Pattern prioritaire") | |
| elif priority == "modérée": | |
| st.warning("Pattern à surveiller") | |
| else: | |
| st.info(priority) | |
| def render_analysis_tab_v3(merged_data, thresholds_analysis): | |
| st.subheader("📈 Analyse des données") | |
| df_analysis = run_biomech_analysis(merged_data, thresholds_analysis) | |
| patterns = detect_combined_patterns(merged_data, df_analysis) | |
| domain_scores = compute_domain_scores(merged_data, df_analysis, patterns) | |
| summary = compute_global_summary_v3(df_analysis, patterns, domain_scores) | |
| narrative = generate_global_narrative(summary, domain_scores) | |
| c1, c2, c3, c4, c5 = st.columns(5) | |
| c1.metric("Variables normales", summary["normal_count"]) | |
| c2.metric("Points d'attention", summary["attention_count"]) | |
| c3.metric("Priorités hautes", summary["high_priority_count"]) | |
| c4.metric("Patterns détectés", summary["pattern_count"]) | |
| c5.metric("Niveau global", summary["global_level"].capitalize()) | |
| st.markdown("---") | |
| st.markdown("### Conclusion synthétique") | |
| if summary["global_level"] == "faible": | |
| st.success(narrative) | |
| elif summary["global_level"] == "léger": | |
| st.info(narrative) | |
| elif summary["global_level"] == "modéré": | |
| st.warning(narrative) | |
| else: | |
| st.error(narrative) | |
| st.markdown("---") | |
| st.markdown("### Scores par domaine") | |
| d1, d2, d3, d4, d5 = st.columns(5) | |
| d1.metric("Impact", f"{domain_scores['impact']}/100") | |
| d2.metric("Dynamique", f"{domain_scores['dynamics']}/100") | |
| d3.metric("Symétrie", f"{domain_scores['symmetry']}/100") | |
| d4.metric("Mécanique", f"{domain_scores['mechanics']}/100") | |
| d5.metric("Attaque", f"{domain_scores['attack']}/100") | |
| st.markdown("---") | |
| st.markdown("### Synthèse par variable") | |
| if df_analysis.empty: | |
| st.info("Aucune donnée disponible pour l'analyse.") | |
| else: | |
| df_display = prepare_analysis_table(df_analysis)[[ | |
| "label", "value", "unit", "low", "high", "status", "priority" | |
| ]].copy() | |
| df_display.columns = [ | |
| "Variable", "Valeur", "Unité", "Seuil bas", "Seuil haut", "Statut", "Priorité" | |
| ] | |
| st.dataframe(df_display, hide_index=True, use_container_width=True) | |
| st.markdown("---") | |
| st.markdown("### Patterns biomécaniques détectés") | |
| if not patterns: | |
| st.success("Aucun pattern combiné majeur détecté.") | |
| else: | |
| for pattern in patterns: | |
| col1, col2 = st.columns([4, 1]) | |
| with col1: | |
| st.markdown(f"**{pattern['title']}**") | |
| st.write(pattern["message"]) | |
| with col2: | |
| display_pattern_badge(pattern["priority"]) | |
| st.markdown("---") | |
| st.markdown("### Détail par variable") | |
| if not df_analysis.empty: | |
| df_sorted = prepare_analysis_table(df_analysis) | |
| for _, row in df_sorted.iterrows(): | |
| col1, col2, col3 = st.columns([2.5, 1, 1]) | |
| with col1: | |
| st.markdown(f"**{row['label']}**") | |
| st.write( | |
| f"Valeur mesurée : **{row['value']:.2f} {row['unit']}** \n" | |
| f"Zone attendue : **{row['low']:.2f} à {row['high']:.2f} {row['unit']}**" | |
| if pd.notna(row["value"]) else | |
| f"Valeur mesurée : **N/A** \nZone attendue : **{row['low']:.2f} à {row['high']:.2f} {row['unit']}**" | |
| ) | |
| st.write(row["interpretation"]) | |
| with col2: | |
| st.markdown("**Statut**") | |
| display_status_badge(row["status"]) | |
| with col3: | |
| st.markdown("**Priorité**") | |
| display_priority_badge(row["priority"]) | |
| st.markdown("---") | |
| st.markdown("### Axes dominants à prioriser") | |
| top_domains = get_primary_domains(domain_scores, top_n=3) | |
| domain_name_map = { | |
| "impact": "Impact", | |
| "dynamics": "Dynamique", | |
| "symmetry": "Symétrie", | |
| "mechanics": "Mécanique distale", | |
| "attack": "Attaque", | |
| } | |
| shown = False | |
| for domain_key, score in top_domains: | |
| if score >= 20: | |
| shown = True | |
| st.markdown(f"- **{domain_name_map.get(domain_key, domain_key)}** : {score}/100 (**{get_domain_label(score)}**)") | |
| if not shown: | |
| st.success("Aucun axe dominant majeur ne se dégage à ce stade.") | |
| # Charge CSV | |
| dfs = [] | |
| load_errors = [] | |
| for f in uploaded_csvs: | |
| try: | |
| df_one, _ = extract_zebris_csv(f) | |
| if not df_one.empty: | |
| df_one["Source fichier CSV"] = f.name | |
| dfs.append(df_one) | |
| else: | |
| load_errors.append(f"{f.name} : aucune ligne exploitable") | |
| except Exception as e: | |
| load_errors.append(f"{f.name} : {e}") | |
| if load_errors: | |
| for err in load_errors: | |
| st.warning(err) | |
| if not dfs: | |
| st.error("Aucun CSV exploitable n’a pu être importé.") | |
| st.stop() | |
| df_std = pd.concat(dfs, ignore_index=True) | |
| # Charge PDF | |
| pdfs_data = [] | |
| if uploaded_pdfs: | |
| for pdf in uploaded_pdfs: | |
| try: | |
| pdfs_data.append(parse_zebris_pdf(pdf)) | |
| except Exception as e: | |
| st.warning(f"{pdf.name} : erreur lecture PDF ({e})") | |
| # Sélection athlète | |
| all_athletes = sorted(df_std["Nom"].dropna().unique().tolist()) | |
| selected_athlete = st.selectbox("Athlète", all_athletes) | |
| sub_df = df_std[df_std["Nom"] == selected_athlete].copy() | |
| if sub_df.empty: | |
| st.error("Aucune donnée trouvée pour cet athlète.") | |
| st.stop() | |
| sources = sorted(sub_df["Source fichier CSV"].dropna().unique().tolist()) | |
| if len(sources) > 1: | |
| selected_source = st.selectbox("Fichier CSV source", sources) | |
| sub_df = sub_df[sub_df["Source fichier CSV"] == selected_source].copy() | |
| sub_df = sub_df.sort_values("Vitesse (km/h)") | |
| speeds = sub_df["Vitesse (km/h)"].dropna().tolist() | |
| selected_speed = st.selectbox("Allure analysée (km/h)", speeds) | |
| matched_pdf = match_pdf_to_athlete_and_speed(pdfs_data, selected_athlete, selected_speed) | |
| row = sub_df[sub_df["Vitesse (km/h)"] == selected_speed].iloc[0] | |
| poids_csv = row["Poids (kg)"] if pd.notna(row["Poids (kg)"]) else np.nan | |
| poids_kg = st.number_input( | |
| "Poids du sportif (kg)", | |
| min_value=30.0, | |
| max_value=150.0, | |
| value=float(poids_csv) if pd.notna(poids_csv) else 70.0, | |
| step=0.1, | |
| ) | |
| metrics = compute_profile_metrics(row, poids_kg) | |
| thresholds = compute_external_thresholds(poids_kg, volume_horaire) | |
| attaque_finale = "indéterminée" | |
| if matched_pdf and matched_pdf.get("attaque_pdf") and matched_pdf["attaque_pdf"] != "indéterminée": | |
| attaque_finale = matched_pdf["attaque_pdf"] | |
| merged_data, thresholds_analysis = build_analysis_inputs( | |
| row=row, | |
| metrics=metrics, | |
| thresholds=thresholds, | |
| attaque_finale=attaque_finale, | |
| ) | |
| def build_summary(row, metrics, attaque_finale): | |
| contraintes_txt = ( | |
| "élevées" if pd.notna(metrics["contraintes"]) and metrics["contraintes"] >= 70 | |
| else "modérées" if pd.notna(metrics["contraintes"]) and metrics["contraintes"] >= 45 | |
| else "faibles" | |
| ) | |
| dyn_txt = ( | |
| "bonne" if pd.notna(metrics["dynamique"]) and metrics["dynamique"] >= 70 | |
| else "moyenne" if pd.notna(metrics["dynamique"]) and metrics["dynamique"] >= 45 | |
| else "faible" | |
| ) | |
| sym_txt = "satisfaisante" if pd.notna(metrics["symetrie"]) and metrics["symetrie"] >= 70 else "perfectible" | |
| der_txt = ( | |
| "favorable" if pd.notna(metrics["deroule"]) and metrics["deroule"] >= 70 | |
| else "intermédiaire" if pd.notna(metrics["deroule"]) and metrics["deroule"] >= 45 | |
| else "à surveiller" | |
| ) | |
| return ( | |
| f"À {row['Vitesse (km/h)']} km/h, {row['Nom']} présente un type d’attaque estimé : {attaque_finale}, " | |
| f"des contraintes mécaniques {contraintes_txt}, une dynamique {dyn_txt}, une symétrie {sym_txt} " | |
| f"et un déroulé {der_txt}." | |
| ) | |
| summary = build_summary(row, metrics, attaque_finale) | |
| tab_profil, tab_seuils, tab_analyse, tab_pdf = st.tabs( | |
| ["Profil biomécanique", "Seuils individualisés", "Analyse", "Apports du PDF Zebris"] | |
| ) | |
| with tab_profil: | |
| c1, c2, c3, c4 = st.columns(4) | |
| with c1: | |
| st.metric("Contraintes", f"{metrics['contraintes']}/100" if pd.notna(metrics["contraintes"]) else "N/A") | |
| with c2: | |
| st.metric("Dynamique", f"{metrics['dynamique']}/100" if pd.notna(metrics["dynamique"]) else "N/A") | |
| with c3: | |
| st.metric("Symétrie", f"{metrics['symetrie']}/100" if pd.notna(metrics["symetrie"]) else "N/A") | |
| with c4: | |
| st.metric("Déroulé", f"{metrics['deroule']}/100" if pd.notna(metrics["deroule"]) else "N/A") | |
| left, right = st.columns([1.2, 1]) | |
| with left: | |
| st.subheader("Carte d’identité biomécanique") | |
| st.write(summary) | |
| indicators = pd.DataFrame( | |
| { | |
| "Indicateur": [ | |
| "Fichier CSV source", | |
| "Poids", | |
| "Cadence", | |
| "Contact", | |
| "Flight", | |
| "Force talon moyenne", | |
| "Force avant-pied moyenne", | |
| "Pression talon moyenne", | |
| "Asymétrie talon", | |
| "COP moyen", | |
| "Différence rotation", | |
| "Type d’attaque estimé", | |
| "Source attaque", | |
| ], | |
| "Valeur": [ | |
| row.get("Source fichier CSV", "N/A"), | |
| f"{poids_kg:.1f} kg", | |
| f"{row['Cadence (pas/min)']:.1f} pas/min" if pd.notna(row["Cadence (pas/min)"]) else "N/A", | |
| f"{row['Contact (%)']:.1f} %" if pd.notna(row["Contact (%)"]) else "N/A", | |
| f"{row['Flight (%)']:.1f} %" if pd.notna(row["Flight (%)"]) else "N/A", | |
| f"{metrics['force_talon_moy']:.1f} N" if pd.notna(metrics["force_talon_moy"]) else "N/A", | |
| f"{metrics['force_avant_moy']:.1f} N" if pd.notna(metrics["force_avant_moy"]) else "N/A", | |
| f"{metrics['pression_talon_moy']:.1f} N/cm²" if pd.notna(metrics["pression_talon_moy"]) else "N/A", | |
| f"{metrics['asym_talon']:.1f} %" if pd.notna(metrics["asym_talon"]) else "N/A", | |
| f"{metrics['cop_moy']:.1f} mm" if pd.notna(metrics["cop_moy"]) else "N/A", | |
| f"{metrics['diff_rotation']:.1f}°" if pd.notna(metrics["diff_rotation"]) else "N/A", | |
| attaque_finale, | |
| matched_pdf["source_pdf"] if (matched_pdf and attaque_finale != "indéterminée") else "PDF non exploitable", | |
| ], | |
| } | |
| ) | |
| st.dataframe(indicators, hide_index=True, use_container_width=True) | |
| 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_kg), use_container_width=True) | |
| with tab_seuils: | |
| r1, r2, r3 = st.columns(3) | |
| with r1: | |
| st.metric("Poids", f"{poids_kg:.1f} kg") | |
| with r2: | |
| st.metric("Poids en Newton", f"{thresholds['poids_n']:.1f} N") | |
| with r3: | |
| st.metric("Charge", thresholds["charge"]) | |
| impact_df = pd.DataFrame({ | |
| "Variable": [ | |
| "Force talon", | |
| "Pression talon", | |
| ], | |
| "Zone basse / faible": [ | |
| f"< {thresholds['force_n_low']:.1f} N", | |
| f"< {thresholds['pression_low']:.1f} N/cm²", | |
| ], | |
| "Zone attendue": [ | |
| f"{thresholds['force_n_low']:.1f} à {thresholds['force_n_high']:.1f} N", | |
| f"{thresholds['pression_low']:.1f} à {thresholds['pression_high']:.1f} N/cm²", | |
| ], | |
| "Zone haute / élevée": [ | |
| f"> {thresholds['force_n_high']:.1f} N", | |
| f"> {thresholds['pression_high']:.1f} N/cm²", | |
| ], | |
| }) | |
| dynamique_df = pd.DataFrame({ | |
| "Variable": [ | |
| "Cadence", | |
| "Temps de contact", | |
| "Temps de vol", | |
| ], | |
| "Zone basse / faible": [ | |
| f"< {thresholds['cadence_low']} pas/min", | |
| f"< {thresholds['contact_low']} %", | |
| f"< {thresholds['flight_low']} %", | |
| ], | |
| "Zone attendue": [ | |
| f"{thresholds['cadence_low']} à {thresholds['cadence_high']} pas/min", | |
| f"{thresholds['contact_low']} à {thresholds['contact_high']} %", | |
| f"{thresholds['flight_low']} à {thresholds['flight_high']} %", | |
| ], | |
| "Zone haute / élevée": [ | |
| f"> {thresholds['cadence_high']} pas/min", | |
| f"> {thresholds['contact_high']} %", | |
| f"> {thresholds['flight_high']} %", | |
| ], | |
| }) | |
| symetrie_df = pd.DataFrame({ | |
| "Variable": [ | |
| "Asymétrie force talon", | |
| "Asymétrie force avant-pied", | |
| "Asymétrie COP", | |
| "Différence rotation G/D", | |
| ], | |
| "Zone faible": [ | |
| f"< {thresholds['asym_low']} %", | |
| f"< {thresholds['asym_low']} %", | |
| f"< {thresholds['asym_low']} %", | |
| f"< {thresholds['rotation_low']}°", | |
| ], | |
| "Zone modérée": [ | |
| f"{thresholds['asym_low']} à {thresholds['asym_high']} %", | |
| f"{thresholds['asym_low']} à {thresholds['asym_high']} %", | |
| f"{thresholds['asym_low']} à {thresholds['asym_high']} %", | |
| f"{thresholds['rotation_low']} à {thresholds['rotation_high']}°", | |
| ], | |
| "Zone marquée": [ | |
| f"> {thresholds['asym_high']} %", | |
| f"> {thresholds['asym_high']} %", | |
| f"> {thresholds['asym_high']} %", | |
| f"> {thresholds['rotation_high']}°", | |
| ], | |
| }) | |
| s1, s2, s3 = st.tabs(["Impact", "Dynamique", "Symétrie"]) | |
| with s1: | |
| st.dataframe(impact_df, hide_index=True, use_container_width=True) | |
| with s2: | |
| st.dataframe(dynamique_df, hide_index=True, use_container_width=True) | |
| with s3: | |
| st.dataframe(symetrie_df, hide_index=True, use_container_width=True) | |
| with tab_analyse: | |
| render_analysis_tab_v3(merged_data, thresholds_analysis) | |
| with tab_pdf: | |
| if not matched_pdf: | |
| st.info("Aucun PDF Zebris associé à cet athlète n’a été trouvé.") | |
| else: | |
| st.subheader("Données extraites du PDF") | |
| pdf_df = pd.DataFrame( | |
| { | |
| "Indicateur": [ | |
| "PDF source", | |
| "Allure du PDF", | |
| "Type d’attaque estimé", | |
| "Transition talon→avant-pied G", | |
| "Transition talon→avant-pied D", | |
| "Pic force talon G", | |
| "Pic force talon D", | |
| "Pic force médio-pied G", | |
| "Pic force médio-pied D", | |
| "Pic force avant-pied G", | |
| "Pic force avant-pied D", | |
| "Timing pic talon G", | |
| "Timing pic talon D", | |
| "Timing pic médio-pied G", | |
| "Timing pic médio-pied D", | |
| "Timing pic avant-pied G", | |
| "Timing pic avant-pied D", | |
| ], | |
| "Valeur": [ | |
| matched_pdf["source_pdf"], | |
| f"{matched_pdf['speed_kmh']:.1f} km/h" if pd.notna(matched_pdf.get("speed_kmh")) else "N/A", | |
| matched_pdf["attaque_pdf"], | |
| f"{matched_pdf['transition_g']:.3f} s" if pd.notna(matched_pdf["transition_g"]) else "N/A", | |
| f"{matched_pdf['transition_d']:.3f} s" if pd.notna(matched_pdf["transition_d"]) else "N/A", | |
| f"{matched_pdf['heel_force_g']:.1f} N" if pd.notna(matched_pdf["heel_force_g"]) else "N/A", | |
| f"{matched_pdf['heel_force_d']:.1f} N" if pd.notna(matched_pdf["heel_force_d"]) else "N/A", | |
| f"{matched_pdf['mid_force_g']:.1f} N" if pd.notna(matched_pdf["mid_force_g"]) else "N/A", | |
| f"{matched_pdf['mid_force_d']:.1f} N" if pd.notna(matched_pdf["mid_force_d"]) else "N/A", | |
| f"{matched_pdf['fore_force_g']:.1f} N" if pd.notna(matched_pdf["fore_force_g"]) else "N/A", | |
| f"{matched_pdf['fore_force_d']:.1f} N" if pd.notna(matched_pdf["fore_force_d"]) else "N/A", | |
| f"{matched_pdf['heel_peak_time_pct_g']:.1f} %" if pd.notna(matched_pdf["heel_peak_time_pct_g"]) else "N/A", | |
| f"{matched_pdf['heel_peak_time_pct_d']:.1f} %" if pd.notna(matched_pdf["heel_peak_time_pct_d"]) else "N/A", | |
| f"{matched_pdf['mid_peak_time_pct_g']:.1f} %" if pd.notna(matched_pdf["mid_peak_time_pct_g"]) else "N/A", | |
| f"{matched_pdf['mid_peak_time_pct_d']:.1f} %" if pd.notna(matched_pdf["mid_peak_time_pct_d"]) else "N/A", | |
| f"{matched_pdf['fore_peak_time_pct_g']:.1f} %" if pd.notna(matched_pdf["fore_peak_time_pct_g"]) else "N/A", | |
| f"{matched_pdf['fore_peak_time_pct_d']:.1f} %" if pd.notna(matched_pdf["fore_peak_time_pct_d"]) else "N/A", | |
| ], | |
| } | |
| ) | |
| st.dataframe(pdf_df, hide_index=True, use_container_width=True) | |
| st.write( | |
| "Le PDF apporte surtout des informations temporelles et zonales plus fines, " | |
| "notamment pour l’estimation du type d’attaque." | |
| ) |