mannnon commited on
Commit
b2ad86e
·
verified ·
1 Parent(s): 5f7c301

Create app.py

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