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