mannnon commited on
Commit
83b4871
·
verified ·
1 Parent(s): 5f6d2ea

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +516 -0
app.py ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+ import streamlit as st
4
+ import matplotlib.pyplot as plt
5
+
6
+ from zebris_extractor import extract_zebris_csv
7
+
8
+ st.set_page_config(page_title="Zebris — Profil & Seuils", layout="wide")
9
+
10
+ st.title("Zebris — Profil biomécanique & seuils individualisés")
11
+ st.caption("Import de plusieurs CSV Zebris → choix d’un athlète → profil biomécanique + seuils personnalisés")
12
+
13
+ with st.sidebar:
14
+ st.header("Import")
15
+ uploaded_files = st.file_uploader(
16
+ "Importer un ou plusieurs fichiers CSV Zebris",
17
+ type=["csv"],
18
+ accept_multiple_files=True,
19
+ )
20
+
21
+ if uploaded_files:
22
+ total_size = sum(f.size for f in uploaded_files)
23
+ if total_size > 100 * 1024 * 1024:
24
+ st.error("Volume total de fichiers trop important (>100 MB)")
25
+ st.stop()
26
+
27
+ st.header("Contexte")
28
+ volume_horaire = st.number_input(
29
+ "Volume horaire / semaine",
30
+ min_value=0.5,
31
+ max_value=40.0,
32
+ value=5.0,
33
+ step=0.5,
34
+ )
35
+
36
+ if not uploaded_files:
37
+ st.info("Importe un ou plusieurs CSV Zebris pour afficher les profils et les seuils.")
38
+ st.stop()
39
+
40
+
41
+ def avg(a, b):
42
+ if pd.isna(a) and pd.isna(b):
43
+ return np.nan
44
+ if pd.isna(a):
45
+ return float(b)
46
+ if pd.isna(b):
47
+ return float(a)
48
+ return (float(a) + float(b)) / 2
49
+
50
+
51
+ def asym(a, b):
52
+ m = avg(a, b)
53
+ if pd.isna(m) or m == 0 or pd.isna(a) or pd.isna(b):
54
+ return np.nan
55
+ return abs(float(a) - float(b)) / m * 100
56
+
57
+
58
+ def clamp_score(value, low, high, reverse=False):
59
+ if pd.isna(value):
60
+ return np.nan
61
+ score = (value - low) / (high - low) * 100
62
+ score = max(0, min(100, score))
63
+ return 100 - score if reverse else score
64
+
65
+
66
+ def safe_mean(values):
67
+ vals = [v for v in values if pd.notna(v)]
68
+ if not vals:
69
+ return np.nan
70
+ return float(np.mean(vals))
71
+
72
+
73
+ def compute_profile_metrics(row, poids_kg):
74
+ poids_n = poids_kg * 9.81
75
+
76
+ force_talon_moy = avg(row["Force talon G (N)"], row["Force talon D (N)"])
77
+ force_avant_moy = avg(row["Force avant-pied G (N)"], row["Force avant-pied D (N)"])
78
+ pression_talon_moy = avg(row["Pression talon G (N/cm²)"], row["Pression talon D (N/cm²)"])
79
+ cop_moy = avg(row["COP G (mm)"], row["COP D (mm)"])
80
+ transition_moy = avg(row["Transition G (s)"], row["Transition D (s)"])
81
+
82
+ asym_talon = asym(row["Force talon G (N)"], row["Force talon D (N)"])
83
+ asym_avant = asym(row["Force avant-pied G (N)"], row["Force avant-pied D (N)"])
84
+ asym_cop = asym(row["COP G (mm)"], row["COP D (mm)"])
85
+
86
+ diff_rotation = (
87
+ abs(float(row["Rotation G (°)"]) - float(row["Rotation D (°)"]))
88
+ if pd.notna(row["Rotation G (°)"]) and pd.notna(row["Rotation D (°)"])
89
+ else np.nan
90
+ )
91
+
92
+ force_talon_bw = force_talon_moy / poids_n if pd.notna(force_talon_moy) and poids_n else np.nan
93
+ ratio_talon_avant = (
94
+ force_talon_moy / force_avant_moy
95
+ if pd.notna(force_talon_moy) and pd.notna(force_avant_moy) and force_avant_moy != 0
96
+ else np.nan
97
+ )
98
+
99
+ # Contraintes locales recalées sur ton export Zebris
100
+ contraintes_force_score = clamp_score(force_talon_bw, 0.15, 0.45)
101
+ contraintes_pressure_score = clamp_score(pression_talon_moy, 3, 10)
102
+
103
+ contraintes = safe_mean([
104
+ 0.6 * contraintes_force_score if pd.notna(contraintes_force_score) else np.nan,
105
+ 0.4 * contraintes_pressure_score if pd.notna(contraintes_pressure_score) else np.nan,
106
+ ])
107
+ contraintes = round(contraintes) if pd.notna(contraintes) else np.nan
108
+
109
+ dynamique = safe_mean([
110
+ 0.6 * clamp_score(row["Cadence (pas/min)"], 150, 185),
111
+ 0.4 * clamp_score(row["Contact (%)"], 68, 76, reverse=True),
112
+ ])
113
+ dynamique = round(dynamique) if pd.notna(dynamique) else np.nan
114
+
115
+ sym_components = [x for x in [asym_talon, asym_avant, asym_cop, diff_rotation] if pd.notna(x)]
116
+ symetrie = round(100 - min(100, np.mean(sym_components) * 2.5)) if sym_components else np.nan
117
+
118
+ deroule = safe_mean([
119
+ 0.5 * clamp_score(cop_moy, 210, 260),
120
+ 0.5 * clamp_score(transition_moy, 0.05, 0.09, reverse=True),
121
+ ])
122
+ deroule = round(deroule) if pd.notna(deroule) else np.nan
123
+
124
+ attaque = "mixte"
125
+ if pd.notna(ratio_talon_avant):
126
+ if ratio_talon_avant > 1.05:
127
+ attaque = "talon"
128
+ elif ratio_talon_avant < 0.95:
129
+ attaque = "avant-pied"
130
+
131
+ return {
132
+ "force_talon_moy": force_talon_moy,
133
+ "force_avant_moy": force_avant_moy,
134
+ "pression_talon_moy": pression_talon_moy,
135
+ "cop_moy": cop_moy,
136
+ "transition_moy": transition_moy,
137
+ "asym_talon": asym_talon,
138
+ "asym_avant": asym_avant,
139
+ "asym_cop": asym_cop,
140
+ "diff_rotation": diff_rotation,
141
+ "force_talon_bw": force_talon_bw,
142
+ "ratio_talon_avant": ratio_talon_avant,
143
+ "contraintes": contraintes,
144
+ "dynamique": dynamique,
145
+ "symetrie": symetrie,
146
+ "deroule": deroule,
147
+ "attaque": attaque,
148
+ }
149
+
150
+
151
+ def build_summary(row, metrics):
152
+ contraintes_txt = (
153
+ "élevées" if pd.notna(metrics["contraintes"]) and metrics["contraintes"] >= 70
154
+ else "modérées" if pd.notna(metrics["contraintes"]) and metrics["contraintes"] >= 45
155
+ else "faibles"
156
+ )
157
+ dyn_txt = (
158
+ "bonne" if pd.notna(metrics["dynamique"]) and metrics["dynamique"] >= 70
159
+ else "moyenne" if pd.notna(metrics["dynamique"]) and metrics["dynamique"] >= 45
160
+ else "faible"
161
+ )
162
+ sym_txt = "satisfaisante" if pd.notna(metrics["symetrie"]) and metrics["symetrie"] >= 70 else "perfectible"
163
+ der_txt = (
164
+ "favorable" if pd.notna(metrics["deroule"]) and metrics["deroule"] >= 70
165
+ else "intermédiaire" if pd.notna(metrics["deroule"]) and metrics["deroule"] >= 45
166
+ else "à surveiller"
167
+ )
168
+
169
+ return (
170
+ f"À {row['Vitesse (km/h)']} km/h, {row['Nom']} présente une attaque {metrics['attaque']}, "
171
+ f"des contraintes mécaniques {contraintes_txt}, une dynamique {dyn_txt}, une symétrie {sym_txt} "
172
+ f"et un déroulé {der_txt}."
173
+ )
174
+
175
+
176
+ def draw_radar(metrics):
177
+ labels = ["Contraintes", "Dynamique", "Symétrie", "Déroulé"]
178
+ values = [
179
+ metrics["contraintes"] if pd.notna(metrics["contraintes"]) else 0,
180
+ metrics["dynamique"] if pd.notna(metrics["dynamique"]) else 0,
181
+ metrics["symetrie"] if pd.notna(metrics["symetrie"]) else 0,
182
+ metrics["deroule"] if pd.notna(metrics["deroule"]) else 0,
183
+ ]
184
+ values += values[:1]
185
+ angles = np.linspace(0, 2 * np.pi, len(labels), endpoint=False).tolist()
186
+ angles += angles[:1]
187
+
188
+ fig = plt.figure(figsize=(5, 5))
189
+ ax = plt.subplot(111, polar=True)
190
+ ax.plot(angles, values, linewidth=2)
191
+ ax.fill(angles, values, alpha=0.25)
192
+ ax.set_xticks(angles[:-1])
193
+ ax.set_xticklabels(labels)
194
+ ax.set_ylim(0, 100)
195
+ ax.set_yticks([25, 50, 75, 100])
196
+ ax.set_title("Radar biomécanique", pad=20)
197
+ return fig
198
+
199
+
200
+ def draw_evolution(df, poids_kg):
201
+ data = []
202
+ for _, r in df.sort_values("Vitesse (km/h)").iterrows():
203
+ m = compute_profile_metrics(r, poids_kg)
204
+ data.append({
205
+ "Vitesse": r["Vitesse (km/h)"],
206
+ "Contraintes": m["contraintes"],
207
+ "Dynamique": m["dynamique"],
208
+ "Symétrie": m["symetrie"],
209
+ "Déroulé": m["deroule"],
210
+ })
211
+
212
+ evo = pd.DataFrame(data)
213
+ fig, ax = plt.subplots(figsize=(8, 4))
214
+ for col in ["Contraintes", "Dynamique", "Symétrie", "Déroulé"]:
215
+ ax.plot(evo["Vitesse"], evo[col], marker="o", label=col)
216
+ ax.set_ylim(0, 100)
217
+ ax.set_xlabel("Vitesse (km/h)")
218
+ ax.set_ylabel("Score /100")
219
+ ax.set_title("Évolution avec l’allure")
220
+ ax.legend()
221
+ ax.grid(True, alpha=0.3)
222
+ return fig
223
+
224
+
225
+ def compute_advanced_thresholds(sub_df, poids_kg, volume_horaire):
226
+ poids_n = poids_kg * 9.81
227
+ df = sub_df.copy()
228
+
229
+ df["force_talon_moy"] = df.apply(lambda r: avg(r["Force talon G (N)"], r["Force talon D (N)"]), axis=1)
230
+ df["force_talon_bw"] = df["force_talon_moy"] / poids_n
231
+ df["pression_talon_moy"] = df.apply(lambda r: avg(r["Pression talon G (N/cm²)"], r["Pression talon D (N/cm²)"]), axis=1)
232
+ df["asym_talon"] = df.apply(lambda r: asym(r["Force talon G (N)"], r["Force talon D (N)"]), axis=1)
233
+ df["asym_avant"] = df.apply(lambda r: asym(r["Force avant-pied G (N)"], r["Force avant-pied D (N)"]), axis=1)
234
+ df["asym_cop"] = df.apply(lambda r: asym(r["COP G (mm)"], r["COP D (mm)"]), axis=1)
235
+ df["diff_rotation"] = df.apply(
236
+ lambda r: abs(float(r["Rotation G (°)"]) - float(r["Rotation D (°)"]))
237
+ if pd.notna(r["Rotation G (°)"]) and pd.notna(r["Rotation D (°)"]) else np.nan,
238
+ axis=1
239
+ )
240
+
241
+ if volume_horaire <= 3:
242
+ tol = 1.15
243
+ charge = "faible"
244
+ elif volume_horaire <= 6:
245
+ tol = 1.00
246
+ charge = "modérée"
247
+ else:
248
+ tol = 0.85
249
+ charge = "élevée"
250
+
251
+ def zone_from_series(series, min_floor=None):
252
+ s = pd.to_numeric(series, errors="coerce").dropna()
253
+ if len(s) == 0:
254
+ return (np.nan, np.nan)
255
+ mean = s.mean()
256
+ std = s.std(ddof=0)
257
+ if pd.isna(std):
258
+ std = 0
259
+ low = mean - std * tol
260
+ high = mean + std * tol
261
+ if min_floor is not None:
262
+ low = max(low, min_floor)
263
+ return (low, high)
264
+
265
+ force_bw_low, force_bw_high = zone_from_series(df["force_talon_bw"], min_floor=0)
266
+ pression_low, pression_high = zone_from_series(df["pression_talon_moy"], min_floor=0)
267
+ cadence_low, cadence_high = zone_from_series(df["Cadence (pas/min)"], min_floor=0)
268
+ contact_low, contact_high = zone_from_series(df["Contact (%)"], min_floor=0)
269
+ flight_low, flight_high = zone_from_series(df["Flight (%)"], min_floor=0)
270
+
271
+ asym_talon_low, asym_talon_high = zone_from_series(df["asym_talon"], min_floor=0)
272
+ asym_avant_low, asym_avant_high = zone_from_series(df["asym_avant"], min_floor=0)
273
+ asym_cop_low, asym_cop_high = zone_from_series(df["asym_cop"], min_floor=0)
274
+ rotation_low, rotation_high = zone_from_series(df["diff_rotation"], min_floor=0)
275
+
276
+ return {
277
+ "charge": charge,
278
+ "poids_n": poids_n,
279
+
280
+ "force_bw_low": force_bw_low,
281
+ "force_bw_high": force_bw_high,
282
+ "force_n_low": force_bw_low * poids_n if pd.notna(force_bw_low) else np.nan,
283
+ "force_n_high": force_bw_high * poids_n if pd.notna(force_bw_high) else np.nan,
284
+
285
+ "pression_low": pression_low,
286
+ "pression_high": pression_high,
287
+
288
+ "cadence_low": cadence_low,
289
+ "cadence_high": cadence_high,
290
+ "contact_low": contact_low,
291
+ "contact_high": contact_high,
292
+ "flight_low": flight_low,
293
+ "flight_high": flight_high,
294
+
295
+ "asym_talon_low": asym_talon_low,
296
+ "asym_talon_high": asym_talon_high,
297
+ "asym_avant_low": asym_avant_low,
298
+ "asym_avant_high": asym_avant_high,
299
+ "asym_cop_low": asym_cop_low,
300
+ "asym_cop_high": asym_cop_high,
301
+ "rotation_low": rotation_low,
302
+ "rotation_high": rotation_high,
303
+ }
304
+
305
+
306
+ # Fusion de plusieurs CSV
307
+ dfs = []
308
+ load_errors = []
309
+
310
+ for f in uploaded_files:
311
+ try:
312
+ df_one, debug = extract_zebris_csv(f)
313
+ if not df_one.empty:
314
+ df_one["Source fichier"] = f.name
315
+ dfs.append(df_one)
316
+ else:
317
+ load_errors.append(f"{f.name} : aucune ligne exploitable")
318
+ except Exception as e:
319
+ load_errors.append(f"{f.name} : {e}")
320
+
321
+ if load_errors:
322
+ for err in load_errors:
323
+ st.warning(err)
324
+
325
+ if not dfs:
326
+ st.error("Aucun fichier exploitable n’a pu être importé.")
327
+ st.stop()
328
+
329
+ df_std = pd.concat(dfs, ignore_index=True)
330
+
331
+ # Sélection athlète
332
+ all_athletes = sorted(df_std["Nom"].dropna().unique().tolist())
333
+ selected_athlete = st.selectbox("Athlète", all_athletes)
334
+
335
+ sub_df = df_std[df_std["Nom"] == selected_athlete].copy()
336
+ if sub_df.empty:
337
+ st.error("Aucune donnée trouvée pour cet athlète.")
338
+ st.stop()
339
+
340
+ sources = sorted(sub_df["Source fichier"].dropna().unique().tolist())
341
+ if len(sources) > 1:
342
+ selected_source = st.selectbox("Fichier source", sources)
343
+ sub_df = sub_df[sub_df["Source fichier"] == selected_source].copy()
344
+
345
+ sub_df = sub_df.sort_values("Vitesse (km/h)")
346
+ speeds = sub_df["Vitesse (km/h)"].dropna().tolist()
347
+ selected_speed = st.selectbox("Allure analysée (km/h)", speeds)
348
+ row = sub_df[sub_df["Vitesse (km/h)"] == selected_speed].iloc[0]
349
+
350
+ poids_csv = row["Poids (kg)"] if pd.notna(row["Poids (kg)"]) else np.nan
351
+ poids_kg = st.number_input(
352
+ "Poids du sportif (kg)",
353
+ min_value=30.0,
354
+ max_value=150.0,
355
+ value=float(poids_csv) if pd.notna(poids_csv) else 70.0,
356
+ step=0.1,
357
+ )
358
+
359
+ metrics = compute_profile_metrics(row, poids_kg)
360
+ summary = build_summary(row, metrics)
361
+ thresholds = compute_advanced_thresholds(sub_df, poids_kg, volume_horaire)
362
+
363
+ tab_profil, tab_seuils = st.tabs(["Profil biomécanique", "Seuils individualisés"])
364
+
365
+ with tab_profil:
366
+ c1, c2, c3, c4 = st.columns(4)
367
+ with c1:
368
+ st.metric("Contraintes", f"{metrics['contraintes']}/100" if pd.notna(metrics["contraintes"]) else "N/A")
369
+ with c2:
370
+ st.metric("Dynamique", f"{metrics['dynamique']}/100" if pd.notna(metrics["dynamique"]) else "N/A")
371
+ with c3:
372
+ st.metric("Symétrie", f"{metrics['symetrie']}/100" if pd.notna(metrics["symetrie"]) else "N/A")
373
+ with c4:
374
+ st.metric("Déroulé", f"{metrics['deroule']}/100" if pd.notna(metrics["deroule"]) else "N/A")
375
+
376
+ left, right = st.columns([1.2, 1])
377
+
378
+ with left:
379
+ st.subheader("Carte d’identité biomécanique")
380
+ st.write(summary)
381
+
382
+ indicators = pd.DataFrame(
383
+ {
384
+ "Indicateur": [
385
+ "Fichier source",
386
+ "Poids",
387
+ "Cadence",
388
+ "Contact",
389
+ "Flight",
390
+ "Force talon moyenne",
391
+ "Force talon normalisée",
392
+ "Pression talon moyenne",
393
+ "Asymétrie talon",
394
+ "COP moyen",
395
+ "Différence rotation",
396
+ "Attaque",
397
+ ],
398
+ "Valeur": [
399
+ row.get("Source fichier", "N/A"),
400
+ f"{poids_kg:.1f} kg",
401
+ f"{row['Cadence (pas/min)']:.1f} pas/min" if pd.notna(row["Cadence (pas/min)"]) else "N/A",
402
+ f"{row['Contact (%)']:.1f} %" if pd.notna(row["Contact (%)"]) else "N/A",
403
+ f"{row['Flight (%)']:.1f} %" if pd.notna(row["Flight (%)"]) else "N/A",
404
+ f"{metrics['force_talon_moy']:.1f} N" if pd.notna(metrics["force_talon_moy"]) else "N/A",
405
+ f"{metrics['force_talon_bw']:.2f} BW" if pd.notna(metrics["force_talon_bw"]) else "N/A",
406
+ f"{metrics['pression_talon_moy']:.1f} N/cm²" if pd.notna(metrics["pression_talon_moy"]) else "N/A",
407
+ f"{metrics['asym_talon']:.1f} %" if pd.notna(metrics["asym_talon"]) else "N/A",
408
+ f"{metrics['cop_moy']:.1f} mm" if pd.notna(metrics["cop_moy"]) else "N/A",
409
+ f"{metrics['diff_rotation']:.1f}°" if pd.notna(metrics["diff_rotation"]) else "N/A",
410
+ metrics["attaque"],
411
+ ],
412
+ }
413
+ )
414
+ st.dataframe(indicators, hide_index=True, use_container_width=True)
415
+
416
+ with right:
417
+ st.subheader("Radar biomécanique")
418
+ st.pyplot(draw_radar(metrics), use_container_width=True)
419
+
420
+ st.subheader("Évolution avec l’allure")
421
+ st.pyplot(draw_evolution(sub_df, poids_kg), use_container_width=True)
422
+
423
+ with tab_seuils:
424
+ r1, r2, r3 = st.columns(3)
425
+ with r1:
426
+ st.metric("Poids", f"{poids_kg:.1f} kg")
427
+ with r2:
428
+ st.metric("Poids en Newton", f"{thresholds['poids_n']:.1f} N")
429
+ with r3:
430
+ st.metric("Charge", thresholds["charge"])
431
+
432
+ impact_df = pd.DataFrame({
433
+ "Variable": [
434
+ "Force talon normalisée",
435
+ "Force talon convertie",
436
+ "Pression talon",
437
+ ],
438
+ "Zone basse / faible": [
439
+ f"< {thresholds['force_bw_low']:.2f} BW" if pd.notna(thresholds["force_bw_low"]) else "N/A",
440
+ f"< {thresholds['force_n_low']:.1f} N" if pd.notna(thresholds["force_n_low"]) else "N/A",
441
+ f"< {thresholds['pression_low']:.2f} N/cm²" if pd.notna(thresholds["pression_low"]) else "N/A",
442
+ ],
443
+ "Zone attendue": [
444
+ f"{thresholds['force_bw_low']:.2f} à {thresholds['force_bw_high']:.2f} BW" if pd.notna(thresholds["force_bw_low"]) and pd.notna(thresholds["force_bw_high"]) else "N/A",
445
+ f"{thresholds['force_n_low']:.1f} à {thresholds['force_n_high']:.1f} N" if pd.notna(thresholds["force_n_low"]) and pd.notna(thresholds["force_n_high"]) else "N/A",
446
+ f"{thresholds['pression_low']:.2f} à {thresholds['pression_high']:.2f} N/cm²" if pd.notna(thresholds["pression_low"]) and pd.notna(thresholds["pression_high"]) else "N/A",
447
+ ],
448
+ "Zone haute / élevée": [
449
+ f"> {thresholds['force_bw_high']:.2f} BW" if pd.notna(thresholds["force_bw_high"]) else "N/A",
450
+ f"> {thresholds['force_n_high']:.1f} N" if pd.notna(thresholds["force_n_high"]) else "N/A",
451
+ f"> {thresholds['pression_high']:.2f} N/cm²" if pd.notna(thresholds["pression_high"]) else "N/A",
452
+ ],
453
+ })
454
+
455
+ dynamique_df = pd.DataFrame({
456
+ "Variable": [
457
+ "Cadence",
458
+ "Temps de contact",
459
+ "Temps de vol",
460
+ ],
461
+ "Zone basse / faible": [
462
+ f"< {thresholds['cadence_low']:.1f} pas/min" if pd.notna(thresholds["cadence_low"]) else "N/A",
463
+ f"< {thresholds['contact_low']:.1f} %" if pd.notna(thresholds["contact_low"]) else "N/A",
464
+ f"< {thresholds['flight_low']:.1f} %" if pd.notna(thresholds["flight_low"]) else "N/A",
465
+ ],
466
+ "Zone attendue": [
467
+ f"{thresholds['cadence_low']:.1f} à {thresholds['cadence_high']:.1f} pas/min" if pd.notna(thresholds["cadence_low"]) and pd.notna(thresholds["cadence_high"]) else "N/A",
468
+ f"{thresholds['contact_low']:.1f} à {thresholds['contact_high']:.1f} %" if pd.notna(thresholds["contact_low"]) and pd.notna(thresholds["contact_high"]) else "N/A",
469
+ f"{thresholds['flight_low']:.1f} à {thresholds['flight_high']:.1f} %" if pd.notna(thresholds["flight_low"]) and pd.notna(thresholds["flight_high"]) else "N/A",
470
+ ],
471
+ "Zone haute / élevée": [
472
+ f"> {thresholds['cadence_high']:.1f} pas/min" if pd.notna(thresholds["cadence_high"]) else "N/A",
473
+ f"> {thresholds['contact_high']:.1f} %" if pd.notna(thresholds["contact_high"]) else "N/A",
474
+ f"> {thresholds['flight_high']:.1f} %" if pd.notna(thresholds["flight_high"]) else "N/A",
475
+ ],
476
+ })
477
+
478
+ symetrie_df = pd.DataFrame({
479
+ "Variable": [
480
+ "Asymétrie force talon",
481
+ "Asymétrie force avant-pied",
482
+ "Asymétrie COP",
483
+ "Différence rotation G/D",
484
+ ],
485
+ "Zone faible": [
486
+ f"< {thresholds['asym_talon_low']:.1f} %" if pd.notna(thresholds["asym_talon_low"]) else "N/A",
487
+ f"< {thresholds['asym_avant_low']:.1f} %" if pd.notna(thresholds["asym_avant_low"]) else "N/A",
488
+ f"< {thresholds['asym_cop_low']:.1f} %" if pd.notna(thresholds["asym_cop_low"]) else "N/A",
489
+ f"< {thresholds['rotation_low']:.1f}°" if pd.notna(thresholds["rotation_low"]) else "N/A",
490
+ ],
491
+ "Zone attendue / modérée": [
492
+ f"{thresholds['asym_talon_low']:.1f} à {thresholds['asym_talon_high']:.1f} %" if pd.notna(thresholds["asym_talon_low"]) and pd.notna(thresholds["asym_talon_high"]) else "N/A",
493
+ f"{thresholds['asym_avant_low']:.1f} à {thresholds['asym_avant_high']:.1f} %" if pd.notna(thresholds["asym_avant_low"]) and pd.notna(thresholds["asym_avant_high"]) else "N/A",
494
+ f"{thresholds['asym_cop_low']:.1f} à {thresholds['asym_cop_high']:.1f} %" if pd.notna(thresholds["asym_cop_low"]) and pd.notna(thresholds["asym_cop_high"]) else "N/A",
495
+ f"{thresholds['rotation_low']:.1f} à {thresholds['rotation_high']:.1f}°" if pd.notna(thresholds["rotation_low"]) and pd.notna(thresholds["rotation_high"]) else "N/A",
496
+ ],
497
+ "Zone haute / marquée": [
498
+ f"> {thresholds['asym_talon_high']:.1f} %" if pd.notna(thresholds["asym_talon_high"]) else "N/A",
499
+ f"> {thresholds['asym_avant_high']:.1f} %" if pd.notna(thresholds["asym_avant_high"]) else "N/A",
500
+ f"> {thresholds['asym_cop_high']:.1f} %" if pd.notna(thresholds["asym_cop_high"]) else "N/A",
501
+ f"> {thresholds['rotation_high']:.1f}°" if pd.notna(thresholds["rotation_high"]) else "N/A",
502
+ ],
503
+ })
504
+
505
+ s1, s2, s3 = st.tabs(["Impact", "Dynamique", "Symétrie"])
506
+ with s1:
507
+ st.dataframe(impact_df, hide_index=True, use_container_width=True)
508
+ with s2:
509
+ st.dataframe(dynamique_df, hide_index=True, use_container_width=True)
510
+ with s3:
511
+ st.dataframe(symetrie_df, hide_index=True, use_container_width=True)
512
+
513
+ st.write(
514
+ "Ces seuils sont individualisés à partir des données Zebris propres à l’athlète "
515
+ "et modulés par le volume horaire hebdomadaire."
516
+ )