Applicomplete / app.py
mannnon's picture
Update app.py
88d229b verified
Raw
History Blame Contribute Delete
57.8 kB
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."
)