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." )