mannnon commited on
Commit
14fd027
·
verified ·
1 Parent(s): 6d71950

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +719 -0
app.py ADDED
@@ -0,0 +1,719 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import re
3
+ import numpy as np
4
+ import pandas as pd
5
+ import streamlit as st
6
+ import matplotlib.pyplot as plt
7
+ import pdfplumber
8
+
9
+ from zebris_extractor import extract_zebris_csv
10
+
11
+ st.set_page_config(page_title="Zebris — Profil biomécanique complet", layout="wide")
12
+
13
+ st.title("Zebris — Profil biomécanique complet")
14
+ st.caption("Import CSV + PDF Zebris → fiche biomécanique enrichie + seuils individualisés")
15
+
16
+
17
+ with st.sidebar:
18
+ st.header("Imports CSV")
19
+ uploaded_csvs = st.file_uploader(
20
+ "Importer un ou plusieurs CSV Zebris",
21
+ type=["csv"],
22
+ accept_multiple_files=True,
23
+ key="csvs",
24
+ )
25
+
26
+ st.header("Imports PDF")
27
+ uploaded_pdfs = st.file_uploader(
28
+ "Importer un ou plusieurs PDF Zebris",
29
+ type=["pdf"],
30
+ accept_multiple_files=True,
31
+ key="pdfs",
32
+ )
33
+
34
+ st.header("Contexte")
35
+ volume_horaire = st.number_input(
36
+ "Volume horaire / semaine",
37
+ min_value=0.5,
38
+ max_value=40.0,
39
+ value=5.0,
40
+ step=0.5,
41
+ )
42
+
43
+ if not uploaded_csvs:
44
+ st.info("Importe au moins un CSV Zebris.")
45
+ st.stop()
46
+
47
+
48
+ def avg(a, b):
49
+ if pd.isna(a) and pd.isna(b):
50
+ return np.nan
51
+ if pd.isna(a):
52
+ return float(b)
53
+ if pd.isna(b):
54
+ return float(a)
55
+ return (float(a) + float(b)) / 2
56
+
57
+
58
+ def asym(a, b):
59
+ m = avg(a, b)
60
+ if pd.isna(m) or m == 0 or pd.isna(a) or pd.isna(b):
61
+ return np.nan
62
+ return abs(float(a) - float(b)) / m * 100
63
+
64
+
65
+ def clamp_score(value, low, high, reverse=False):
66
+ if pd.isna(value):
67
+ return np.nan
68
+ score = (value - low) / (high - low) * 100
69
+ score = max(0, min(100, score))
70
+ return 100 - score if reverse else score
71
+
72
+
73
+ def safe_mean(values):
74
+ vals = [v for v in values if pd.notna(v)]
75
+ if not vals:
76
+ return np.nan
77
+ return float(np.mean(vals))
78
+
79
+
80
+ def normalize_name(name: str) -> str:
81
+ if not name:
82
+ return ""
83
+ return (
84
+ str(name)
85
+ .strip()
86
+ .lower()
87
+ .replace("é", "e")
88
+ .replace("è", "e")
89
+ .replace("ê", "e")
90
+ .replace("à", "a")
91
+ .replace("ù", "u")
92
+ .replace("ç", "c")
93
+ )
94
+
95
+
96
+ def compute_external_thresholds(poids_kg, volume_horaire):
97
+ poids_n = poids_kg * 9.81
98
+
99
+ if volume_horaire <= 3:
100
+ charge = "faible"
101
+ force_bw_low, force_bw_high = 0.25, 0.40
102
+ pression_low, pression_high = 4.0, 8.0
103
+ cadence_low, cadence_high = 160, 172
104
+ contact_low, contact_high = 69, 74
105
+ flight_low, flight_high = 26, 30
106
+ asym_low, asym_high = 6, 10
107
+ rotation_low, rotation_high = 6, 10
108
+ elif volume_horaire <= 6:
109
+ charge = "modérée"
110
+ force_bw_low, force_bw_high = 0.22, 0.37
111
+ pression_low, pression_high = 4.0, 7.5
112
+ cadence_low, cadence_high = 164, 176
113
+ contact_low, contact_high = 68, 73
114
+ flight_low, flight_high = 27, 31
115
+ asym_low, asym_high = 5, 9
116
+ rotation_low, rotation_high = 5, 9
117
+ else:
118
+ charge = "élevée"
119
+ force_bw_low, force_bw_high = 0.20, 0.35
120
+ pression_low, pression_high = 4.0, 7.0
121
+ cadence_low, cadence_high = 168, 180
122
+ contact_low, contact_high = 67, 72
123
+ flight_low, flight_high = 28, 32
124
+ asym_low, asym_high = 4, 8
125
+ rotation_low, rotation_high = 4, 8
126
+
127
+ return {
128
+ "charge": charge,
129
+ "poids_n": poids_n,
130
+ "force_n_low": force_bw_low * poids_n,
131
+ "force_n_high": force_bw_high * poids_n,
132
+ "pression_low": pression_low,
133
+ "pression_high": pression_high,
134
+ "cadence_low": cadence_low,
135
+ "cadence_high": cadence_high,
136
+ "contact_low": contact_low,
137
+ "contact_high": contact_high,
138
+ "flight_low": flight_low,
139
+ "flight_high": flight_high,
140
+ "asym_low": asym_low,
141
+ "asym_high": asym_high,
142
+ "rotation_low": rotation_low,
143
+ "rotation_high": rotation_high,
144
+ }
145
+
146
+
147
+ def estimate_attack_from_csv(force_talon_moy, force_avant_moy, transition_moy):
148
+ if pd.isna(force_talon_moy) or pd.isna(force_avant_moy) or force_avant_moy == 0:
149
+ return "indéterminée"
150
+
151
+ ratio = force_talon_moy / force_avant_moy
152
+
153
+ if pd.isna(transition_moy):
154
+ if ratio > 1.10:
155
+ return "attaque talon"
156
+ elif ratio < 0.90:
157
+ return "attaque avant-pied"
158
+ return "attaque médio-pied"
159
+
160
+ if ratio > 1.10 and transition_moy >= 0.070:
161
+ return "attaque talon"
162
+ elif ratio < 0.90 and transition_moy <= 0.055:
163
+ return "attaque avant-pied"
164
+ return "attaque médio-pied"
165
+
166
+
167
+ def compute_profile_metrics(row, poids_kg):
168
+ poids_n = poids_kg * 9.81
169
+
170
+ force_talon_moy = avg(row["Force talon G (N)"], row["Force talon D (N)"])
171
+ force_avant_moy = avg(row["Force avant-pied G (N)"], row["Force avant-pied D (N)"])
172
+ pression_talon_moy = avg(row["Pression talon G (N/cm²)"], row["Pression talon D (N/cm²)"])
173
+ cop_moy = avg(row["COP G (mm)"], row["COP D (mm)"])
174
+ transition_moy = avg(row["Transition G (s)"], row["Transition D (s)"])
175
+
176
+ asym_talon = asym(row["Force talon G (N)"], row["Force talon D (N)"])
177
+ asym_avant = asym(row["Force avant-pied G (N)"], row["Force avant-pied D (N)"])
178
+ asym_cop = asym(row["COP G (mm)"], row["COP D (mm)"])
179
+
180
+ diff_rotation = (
181
+ abs(float(row["Rotation G (°)"]) - float(row["Rotation D (°)"]))
182
+ if pd.notna(row["Rotation G (°)"]) and pd.notna(row["Rotation D (°)"])
183
+ else np.nan
184
+ )
185
+
186
+ force_talon_bw = force_talon_moy / poids_n if pd.notna(force_talon_moy) and poids_n else np.nan
187
+
188
+ contraintes_force_score = clamp_score(force_talon_bw, 0.15, 0.45)
189
+ contraintes_pressure_score = clamp_score(pression_talon_moy, 3, 10)
190
+
191
+ contraintes = safe_mean([
192
+ 0.6 * contraintes_force_score if pd.notna(contraintes_force_score) else np.nan,
193
+ 0.4 * contraintes_pressure_score if pd.notna(contraintes_pressure_score) else np.nan,
194
+ ])
195
+ contraintes = round(contraintes) if pd.notna(contraintes) else np.nan
196
+
197
+ dynamique = safe_mean([
198
+ 0.6 * clamp_score(row["Cadence (pas/min)"], 150, 185),
199
+ 0.4 * clamp_score(row["Contact (%)"], 68, 76, reverse=True),
200
+ ])
201
+ dynamique = round(dynamique) if pd.notna(dynamique) else np.nan
202
+
203
+ sym_components = [x for x in [asym_talon, asym_avant, asym_cop, diff_rotation] if pd.notna(x)]
204
+ symetrie = round(100 - min(100, np.mean(sym_components) * 2.5)) if sym_components else np.nan
205
+
206
+ deroule = safe_mean([
207
+ 0.5 * clamp_score(cop_moy, 210, 260),
208
+ 0.5 * clamp_score(transition_moy, 0.05, 0.09, reverse=True),
209
+ ])
210
+ deroule = round(deroule) if pd.notna(deroule) else np.nan
211
+
212
+ attaque_csv = estimate_attack_from_csv(force_talon_moy, force_avant_moy, transition_moy)
213
+
214
+ return {
215
+ "force_talon_moy": force_talon_moy,
216
+ "force_avant_moy": force_avant_moy,
217
+ "pression_talon_moy": pression_talon_moy,
218
+ "cop_moy": cop_moy,
219
+ "transition_moy": transition_moy,
220
+ "asym_talon": asym_talon,
221
+ "asym_avant": asym_avant,
222
+ "asym_cop": asym_cop,
223
+ "diff_rotation": diff_rotation,
224
+ "contraintes": contraintes,
225
+ "dynamique": dynamique,
226
+ "symetrie": symetrie,
227
+ "deroule": deroule,
228
+ "attaque_csv": attaque_csv,
229
+ }
230
+
231
+
232
+ def draw_radar(metrics):
233
+ labels = ["Contraintes", "Dynamique", "Symétrie", "Déroulé"]
234
+ values = [
235
+ metrics["contraintes"] if pd.notna(metrics["contraintes"]) else 0,
236
+ metrics["dynamique"] if pd.notna(metrics["dynamique"]) else 0,
237
+ metrics["symetrie"] if pd.notna(metrics["symetrie"]) else 0,
238
+ metrics["deroule"] if pd.notna(metrics["deroule"]) else 0,
239
+ ]
240
+ values += values[:1]
241
+ angles = np.linspace(0, 2 * np.pi, len(labels), endpoint=False).tolist()
242
+ angles += angles[:1]
243
+
244
+ fig = plt.figure(figsize=(5, 5))
245
+ ax = plt.subplot(111, polar=True)
246
+ ax.plot(angles, values, linewidth=2)
247
+ ax.fill(angles, values, alpha=0.25)
248
+ ax.set_xticks(angles[:-1])
249
+ ax.set_xticklabels(labels)
250
+ ax.set_ylim(0, 100)
251
+ ax.set_yticks([25, 50, 75, 100])
252
+ ax.set_title("Radar biomécanique", pad=20)
253
+ return fig
254
+
255
+
256
+ def draw_evolution(df, poids_kg):
257
+ data = []
258
+ for _, r in df.sort_values("Vitesse (km/h)").iterrows():
259
+ m = compute_profile_metrics(r, poids_kg)
260
+ data.append({
261
+ "Vitesse": r["Vitesse (km/h)"],
262
+ "Contraintes": m["contraintes"],
263
+ "Dynamique": m["dynamique"],
264
+ "Symétrie": m["symetrie"],
265
+ "Déroulé": m["deroule"],
266
+ })
267
+
268
+ evo = pd.DataFrame(data)
269
+ fig, ax = plt.subplots(figsize=(8, 4))
270
+ for col in ["Contraintes", "Dynamique", "Symétrie", "Déroulé"]:
271
+ ax.plot(evo["Vitesse"], evo[col], marker="o", label=col)
272
+ ax.set_ylim(0, 100)
273
+ ax.set_xlabel("Vitesse (km/h)")
274
+ ax.set_ylabel("Score /100")
275
+ ax.set_title("Évolution avec l’allure")
276
+ ax.legend()
277
+ ax.grid(True, alpha=0.3)
278
+ return fig
279
+
280
+
281
+ def extract_text_from_pdf(uploaded_pdf) -> str:
282
+ uploaded_pdf.seek(0)
283
+ raw = uploaded_pdf.read()
284
+ uploaded_pdf.seek(0)
285
+
286
+ text_parts = []
287
+ with pdfplumber.open(io.BytesIO(raw)) as pdf:
288
+ for page in pdf.pages:
289
+ txt = page.extract_text() or ""
290
+ if txt:
291
+ text_parts.append(txt)
292
+ return "\n".join(text_parts)
293
+
294
+
295
+ def find_float_after_label(text: str, label: str, max_numbers: int = 2):
296
+ idx = text.lower().find(label.lower())
297
+ if idx == -1:
298
+ return []
299
+ snippet = text[idx: idx + 500]
300
+ nums = re.findall(r"(\d+,\d+|\d+\.\d+|\d+)", snippet)
301
+ out = []
302
+ for n in nums[:max_numbers]:
303
+ out.append(float(n.replace(",", ".")))
304
+ return out
305
+
306
+
307
+ def extract_pdf_name(text: str):
308
+ m = re.search(r"Personne:\s*([A-Za-zÀ-ÿ\- ]+),\s*\d{2}/\d{2}/\d{4}", text)
309
+ if m:
310
+ return m.group(1).strip()
311
+ return None
312
+
313
+
314
+ def estimate_attack_from_pdf(pdf_data: dict):
315
+ heel_peak_t = avg(pdf_data.get("heel_peak_time_pct_g"), pdf_data.get("heel_peak_time_pct_d"))
316
+ fore_peak_t = avg(pdf_data.get("fore_peak_time_pct_g"), pdf_data.get("fore_peak_time_pct_d"))
317
+ heel_force = avg(pdf_data.get("heel_force_g"), pdf_data.get("heel_force_d"))
318
+ fore_force = avg(pdf_data.get("fore_force_g"), pdf_data.get("fore_force_d"))
319
+ transition = avg(pdf_data.get("transition_g"), pdf_data.get("transition_d"))
320
+
321
+ if pd.notna(heel_peak_t) and pd.notna(fore_peak_t):
322
+ if heel_peak_t <= 12 and pd.notna(transition) and transition >= 0.055:
323
+ return "attaque talon"
324
+ if heel_peak_t > 15 and fore_peak_t < 50:
325
+ return "attaque avant-pied"
326
+
327
+ if pd.notna(heel_force) and pd.notna(fore_force) and fore_force != 0:
328
+ ratio = heel_force / fore_force
329
+ if ratio > 1.05 and pd.notna(transition) and transition >= 0.055:
330
+ return "attaque talon"
331
+ if ratio < 0.90 and pd.notna(transition) and transition <= 0.055:
332
+ return "attaque avant-pied"
333
+ return "attaque médio-pied"
334
+
335
+ return "indéterminée"
336
+
337
+
338
+ def parse_zebris_pdf(uploaded_pdf):
339
+ text = extract_text_from_pdf(uploaded_pdf)
340
+ athlete_name = extract_pdf_name(text)
341
+
342
+ data = {
343
+ "athlete_name": athlete_name,
344
+ "source_pdf": uploaded_pdf.name,
345
+ "transition_g": np.nan,
346
+ "transition_d": np.nan,
347
+ "heel_force_g": np.nan,
348
+ "heel_force_d": np.nan,
349
+ "mid_force_g": np.nan,
350
+ "mid_force_d": np.nan,
351
+ "fore_force_g": np.nan,
352
+ "fore_force_d": np.nan,
353
+ "heel_pressure_g": np.nan,
354
+ "heel_pressure_d": np.nan,
355
+ "mid_pressure_g": np.nan,
356
+ "mid_pressure_d": np.nan,
357
+ "fore_pressure_g": np.nan,
358
+ "fore_pressure_d": np.nan,
359
+ "heel_peak_time_pct_g": np.nan,
360
+ "heel_peak_time_pct_d": np.nan,
361
+ "mid_peak_time_pct_g": np.nan,
362
+ "mid_peak_time_pct_d": np.nan,
363
+ "fore_peak_time_pct_g": np.nan,
364
+ "fore_peak_time_pct_d": np.nan,
365
+ }
366
+
367
+ vals = find_float_after_label(text, "Instant du passage du talon vers\nl'avant-pied, s", 2)
368
+ if len(vals) >= 2:
369
+ data["transition_g"], data["transition_d"] = vals[0], vals[1]
370
+
371
+ vals = find_float_after_label(text, "Force maximale, N\nForefoot (Three zones)", 2)
372
+ if len(vals) >= 2:
373
+ data["fore_force_g"], data["fore_force_d"] = vals[0], vals[1]
374
+
375
+ vals = find_float_after_label(text, "Midfoot (Three zones)", 2)
376
+ if len(vals) >= 2:
377
+ # attention : la première occurrence de Midfoot peut venir de la force ou d'une autre section
378
+ if pd.isna(data["mid_force_g"]):
379
+ data["mid_force_g"], data["mid_force_d"] = vals[0], vals[1]
380
+
381
+ vals = find_float_after_label(text, "Heel (Three zones)", 2)
382
+ if len(vals) >= 2:
383
+ if pd.isna(data["heel_force_g"]):
384
+ data["heel_force_g"], data["heel_force_d"] = vals[0], vals[1]
385
+
386
+ vals = find_float_after_label(text, "Pression maximale, N/cm²\nForefoot (Three zones)", 2)
387
+ if len(vals) >= 2:
388
+ data["fore_pressure_g"], data["fore_pressure_d"] = vals[0], vals[1]
389
+
390
+ vals = find_float_after_label(text, "Pression maximale, N/cm²\nMidfoot (Three zones)", 2)
391
+ if len(vals) >= 2:
392
+ data["mid_pressure_g"], data["mid_pressure_d"] = vals[0], vals[1]
393
+
394
+ vals = find_float_after_label(text, "Pression maximale, N/cm²\nHeel (Three zones)", 2)
395
+ if len(vals) >= 2:
396
+ data["heel_pressure_g"], data["heel_pressure_d"] = vals[0], vals[1]
397
+
398
+ vals = find_float_after_label(text, "Instant pic de force, % de phase d'appui\nForefoot (Three zones)", 2)
399
+ if len(vals) >= 2:
400
+ data["fore_peak_time_pct_g"], data["fore_peak_time_pct_d"] = vals[0], vals[1]
401
+
402
+ vals = find_float_after_label(text, "Instant pic de force, % de phase d'appui\nMidfoot (Three zones)", 2)
403
+ if len(vals) >= 2:
404
+ data["mid_peak_time_pct_g"], data["mid_peak_time_pct_d"] = vals[0], vals[1]
405
+
406
+ vals = find_float_after_label(text, "Instant pic de force, % de phase d'appui\nHeel (Three zones)", 2)
407
+ if len(vals) >= 2:
408
+ data["heel_peak_time_pct_g"], data["heel_peak_time_pct_d"] = vals[0], vals[1]
409
+
410
+ data["attaque_pdf"] = estimate_attack_from_pdf(data)
411
+ return data
412
+
413
+
414
+ def match_pdf_to_athlete(pdfs_data, athlete_name):
415
+ target = normalize_name(athlete_name)
416
+ for pdf in pdfs_data:
417
+ if normalize_name(pdf.get("athlete_name")) == target:
418
+ return pdf
419
+ return None
420
+
421
+
422
+ # Charge CSV
423
+ dfs = []
424
+ load_errors = []
425
+
426
+ for f in uploaded_csvs:
427
+ try:
428
+ df_one, _ = extract_zebris_csv(f)
429
+ if not df_one.empty:
430
+ df_one["Source fichier CSV"] = f.name
431
+ dfs.append(df_one)
432
+ else:
433
+ load_errors.append(f"{f.name} : aucune ligne exploitable")
434
+ except Exception as e:
435
+ load_errors.append(f"{f.name} : {e}")
436
+
437
+ if load_errors:
438
+ for err in load_errors:
439
+ st.warning(err)
440
+
441
+ if not dfs:
442
+ st.error("Aucun CSV exploitable n’a pu être importé.")
443
+ st.stop()
444
+
445
+ df_std = pd.concat(dfs, ignore_index=True)
446
+
447
+ # Charge PDF
448
+ pdfs_data = []
449
+ if uploaded_pdfs:
450
+ for pdf in uploaded_pdfs:
451
+ try:
452
+ pdfs_data.append(parse_zebris_pdf(pdf))
453
+ except Exception as e:
454
+ st.warning(f"{pdf.name} : erreur lecture PDF ({e})")
455
+
456
+ # Sélection athlète
457
+ all_athletes = sorted(df_std["Nom"].dropna().unique().tolist())
458
+ selected_athlete = st.selectbox("Athlète", all_athletes)
459
+
460
+ sub_df = df_std[df_std["Nom"] == selected_athlete].copy()
461
+ if sub_df.empty:
462
+ st.error("Aucune donnée trouvée pour cet athlète.")
463
+ st.stop()
464
+
465
+ sources = sorted(sub_df["Source fichier CSV"].dropna().unique().tolist())
466
+ if len(sources) > 1:
467
+ selected_source = st.selectbox("Fichier CSV source", sources)
468
+ sub_df = sub_df[sub_df["Source fichier CSV"] == selected_source].copy()
469
+
470
+ sub_df = sub_df.sort_values("Vitesse (km/h)")
471
+ speeds = sub_df["Vitesse (km/h)"].dropna().tolist()
472
+ selected_speed = st.selectbox("Allure analysée (km/h)", speeds)
473
+ row = sub_df[sub_df["Vitesse (km/h)"] == selected_speed].iloc[0]
474
+
475
+ poids_csv = row["Poids (kg)"] if pd.notna(row["Poids (kg)"]) else np.nan
476
+ poids_kg = st.number_input(
477
+ "Poids du sportif (kg)",
478
+ min_value=30.0,
479
+ max_value=150.0,
480
+ value=float(poids_csv) if pd.notna(poids_csv) else 70.0,
481
+ step=0.1,
482
+ )
483
+
484
+ metrics = compute_profile_metrics(row, poids_kg)
485
+ thresholds = compute_external_thresholds(poids_kg, volume_horaire)
486
+ matched_pdf = match_pdf_to_athlete(pdfs_data, selected_athlete)
487
+
488
+ attaque_finale = metrics["attaque_csv"]
489
+ if matched_pdf and matched_pdf.get("attaque_pdf") and matched_pdf["attaque_pdf"] != "indéterminée":
490
+ attaque_finale = matched_pdf["attaque_pdf"]
491
+
492
+
493
+ def build_summary(row, metrics, attaque_finale):
494
+ contraintes_txt = (
495
+ "élevées" if pd.notna(metrics["contraintes"]) and metrics["contraintes"] >= 70
496
+ else "modérées" if pd.notna(metrics["contraintes"]) and metrics["contraintes"] >= 45
497
+ else "faibles"
498
+ )
499
+ dyn_txt = (
500
+ "bonne" if pd.notna(metrics["dynamique"]) and metrics["dynamique"] >= 70
501
+ else "moyenne" if pd.notna(metrics["dynamique"]) and metrics["dynamique"] >= 45
502
+ else "faible"
503
+ )
504
+ sym_txt = "satisfaisante" if pd.notna(metrics["symetrie"]) and metrics["symetrie"] >= 70 else "perfectible"
505
+ der_txt = (
506
+ "favorable" if pd.notna(metrics["deroule"]) and metrics["deroule"] >= 70
507
+ else "intermédiaire" if pd.notna(metrics["deroule"]) and metrics["deroule"] >= 45
508
+ else "à surveiller"
509
+ )
510
+ return (
511
+ f"À {row['Vitesse (km/h)']} km/h, {row['Nom']} présente un type d’attaque estimé : {attaque_finale}, "
512
+ f"des contraintes mécaniques {contraintes_txt}, une dynamique {dyn_txt}, une symétrie {sym_txt} "
513
+ f"et un déroulé {der_txt}."
514
+ )
515
+
516
+
517
+ summary = build_summary(row, metrics, attaque_finale)
518
+
519
+ tab_profil, tab_seuils, tab_pdf = st.tabs(
520
+ ["Profil biomécanique", "Seuils individualisés", "Apports du PDF Zebris"]
521
+ )
522
+
523
+ with tab_profil:
524
+ c1, c2, c3, c4 = st.columns(4)
525
+ with c1:
526
+ st.metric("Contraintes", f"{metrics['contraintes']}/100" if pd.notna(metrics["contraintes"]) else "N/A")
527
+ with c2:
528
+ st.metric("Dynamique", f"{metrics['dynamique']}/100" if pd.notna(metrics["dynamique"]) else "N/A")
529
+ with c3:
530
+ st.metric("Symétrie", f"{metrics['symetrie']}/100" if pd.notna(metrics["symetrie"]) else "N/A")
531
+ with c4:
532
+ st.metric("Déroulé", f"{metrics['deroule']}/100" if pd.notna(metrics["deroule"]) else "N/A")
533
+
534
+ left, right = st.columns([1.2, 1])
535
+
536
+ with left:
537
+ st.subheader("Carte d’identité biomécanique")
538
+ st.write(summary)
539
+
540
+ indicators = pd.DataFrame(
541
+ {
542
+ "Indicateur": [
543
+ "Fichier CSV source",
544
+ "Poids",
545
+ "Cadence",
546
+ "Contact",
547
+ "Flight",
548
+ "Force talon moyenne",
549
+ "Force avant-pied moyenne",
550
+ "Pression talon moyenne",
551
+ "Asymétrie talon",
552
+ "COP moyen",
553
+ "Différence rotation",
554
+ "Type d’attaque estimé",
555
+ "Source attaque",
556
+ ],
557
+ "Valeur": [
558
+ row.get("Source fichier CSV", "N/A"),
559
+ f"{poids_kg:.1f} kg",
560
+ f"{row['Cadence (pas/min)']:.1f} pas/min" if pd.notna(row["Cadence (pas/min)"]) else "N/A",
561
+ f"{row['Contact (%)']:.1f} %" if pd.notna(row["Contact (%)"]) else "N/A",
562
+ f"{row['Flight (%)']:.1f} %" if pd.notna(row["Flight (%)"]) else "N/A",
563
+ f"{metrics['force_talon_moy']:.1f} N" if pd.notna(metrics["force_talon_moy"]) else "N/A",
564
+ f"{metrics['force_avant_moy']:.1f} N" if pd.notna(metrics["force_avant_moy"]) else "N/A",
565
+ f"{metrics['pression_talon_moy']:.1f} N/cm²" if pd.notna(metrics["pression_talon_moy"]) else "N/A",
566
+ f"{metrics['asym_talon']:.1f} %" if pd.notna(metrics["asym_talon"]) else "N/A",
567
+ f"{metrics['cop_moy']:.1f} mm" if pd.notna(metrics["cop_moy"]) else "N/A",
568
+ f"{metrics['diff_rotation']:.1f}°" if pd.notna(metrics["diff_rotation"]) else "N/A",
569
+ attaque_finale,
570
+ matched_pdf["source_pdf"] if matched_pdf else "CSV seulement",
571
+ ],
572
+ }
573
+ )
574
+ st.dataframe(indicators, hide_index=True, use_container_width=True)
575
+
576
+ with right:
577
+ st.subheader("Radar biomécanique")
578
+ st.pyplot(draw_radar(metrics), use_container_width=True)
579
+
580
+ st.subheader("Évolution avec l’allure")
581
+ st.pyplot(draw_evolution(sub_df, poids_kg), use_container_width=True)
582
+
583
+ with tab_seuils:
584
+ r1, r2, r3 = st.columns(3)
585
+ with r1:
586
+ st.metric("Poids", f"{poids_kg:.1f} kg")
587
+ with r2:
588
+ st.metric("Poids en Newton", f"{thresholds['poids_n']:.1f} N")
589
+ with r3:
590
+ st.metric("Charge", thresholds["charge"])
591
+
592
+ impact_df = pd.DataFrame({
593
+ "Variable": [
594
+ "Force talon",
595
+ "Pression talon",
596
+ ],
597
+ "Zone basse / faible": [
598
+ f"< {thresholds['force_n_low']:.1f} N",
599
+ f"< {thresholds['pression_low']:.1f} N/cm²",
600
+ ],
601
+ "Zone attendue": [
602
+ f"{thresholds['force_n_low']:.1f} à {thresholds['force_n_high']:.1f} N",
603
+ f"{thresholds['pression_low']:.1f} à {thresholds['pression_high']:.1f} N/cm²",
604
+ ],
605
+ "Zone haute / élevée": [
606
+ f"> {thresholds['force_n_high']:.1f} N",
607
+ f"> {thresholds['pression_high']:.1f} N/cm²",
608
+ ],
609
+ })
610
+
611
+ dynamique_df = pd.DataFrame({
612
+ "Variable": [
613
+ "Cadence",
614
+ "Temps de contact",
615
+ "Temps de vol",
616
+ ],
617
+ "Zone basse / faible": [
618
+ f"< {thresholds['cadence_low']} pas/min",
619
+ f"< {thresholds['contact_low']} %",
620
+ f"< {thresholds['flight_low']} %",
621
+ ],
622
+ "Zone attendue": [
623
+ f"{thresholds['cadence_low']} à {thresholds['cadence_high']} pas/min",
624
+ f"{thresholds['contact_low']} à {thresholds['contact_high']} %",
625
+ f"{thresholds['flight_low']} à {thresholds['flight_high']} %",
626
+ ],
627
+ "Zone haute / élevée": [
628
+ f"> {thresholds['cadence_high']} pas/min",
629
+ f"> {thresholds['contact_high']} %",
630
+ f"> {thresholds['flight_high']} %",
631
+ ],
632
+ })
633
+
634
+ symetrie_df = pd.DataFrame({
635
+ "Variable": [
636
+ "Asymétrie force talon",
637
+ "Asymétrie force avant-pied",
638
+ "Asymétrie COP",
639
+ "Différence rotation G/D",
640
+ ],
641
+ "Zone faible": [
642
+ f"< {thresholds['asym_low']} %",
643
+ f"< {thresholds['asym_low']} %",
644
+ f"< {thresholds['asym_low']} %",
645
+ f"< {thresholds['rotation_low']}°",
646
+ ],
647
+ "Zone modérée": [
648
+ f"{thresholds['asym_low']} à {thresholds['asym_high']} %",
649
+ f"{thresholds['asym_low']} à {thresholds['asym_high']} %",
650
+ f"{thresholds['asym_low']} à {thresholds['asym_high']} %",
651
+ f"{thresholds['rotation_low']} à {thresholds['rotation_high']}°",
652
+ ],
653
+ "Zone marquée": [
654
+ f"> {thresholds['asym_high']} %",
655
+ f"> {thresholds['asym_high']} %",
656
+ f"> {thresholds['asym_high']} %",
657
+ f"> {thresholds['rotation_high']}°",
658
+ ],
659
+ })
660
+
661
+ s1, s2, s3 = st.tabs(["Impact", "Dynamique", "Symétrie"])
662
+ with s1:
663
+ st.dataframe(impact_df, hide_index=True, use_container_width=True)
664
+ with s2:
665
+ st.dataframe(dynamique_df, hide_index=True, use_container_width=True)
666
+ with s3:
667
+ st.dataframe(symetrie_df, hide_index=True, use_container_width=True)
668
+
669
+ with tab_pdf:
670
+ if not matched_pdf:
671
+ st.info("Aucun PDF Zebris associé à cet athlète n’a été trouvé.")
672
+ else:
673
+ st.subheader("Données extraites du PDF")
674
+ pdf_df = pd.DataFrame(
675
+ {
676
+ "Indicateur": [
677
+ "PDF source",
678
+ "Type d’attaque estimé",
679
+ "Transition talon→avant-pied G",
680
+ "Transition talon→avant-pied D",
681
+ "Pic force talon G",
682
+ "Pic force talon D",
683
+ "Pic force médio-pied G",
684
+ "Pic force médio-pied D",
685
+ "Pic force avant-pied G",
686
+ "Pic force avant-pied D",
687
+ "Timing pic talon G",
688
+ "Timing pic talon D",
689
+ "Timing pic médio-pied G",
690
+ "Timing pic médio-pied D",
691
+ "Timing pic avant-pied G",
692
+ "Timing pic avant-pied D",
693
+ ],
694
+ "Valeur": [
695
+ matched_pdf["source_pdf"],
696
+ matched_pdf["attaque_pdf"],
697
+ f"{matched_pdf['transition_g']:.3f} s" if pd.notna(matched_pdf["transition_g"]) else "N/A",
698
+ f"{matched_pdf['transition_d']:.3f} s" if pd.notna(matched_pdf["transition_d"]) else "N/A",
699
+ f"{matched_pdf['heel_force_g']:.1f} N" if pd.notna(matched_pdf["heel_force_g"]) else "N/A",
700
+ f"{matched_pdf['heel_force_d']:.1f} N" if pd.notna(matched_pdf["heel_force_d"]) else "N/A",
701
+ f"{matched_pdf['mid_force_g']:.1f} N" if pd.notna(matched_pdf["mid_force_g"]) else "N/A",
702
+ f"{matched_pdf['mid_force_d']:.1f} N" if pd.notna(matched_pdf["mid_force_d"]) else "N/A",
703
+ f"{matched_pdf['fore_force_g']:.1f} N" if pd.notna(matched_pdf["fore_force_g"]) else "N/A",
704
+ f"{matched_pdf['fore_force_d']:.1f} N" if pd.notna(matched_pdf["fore_force_d"]) else "N/A",
705
+ f"{matched_pdf['heel_peak_time_pct_g']:.1f} %" if pd.notna(matched_pdf["heel_peak_time_pct_g"]) else "N/A",
706
+ f"{matched_pdf['heel_peak_time_pct_d']:.1f} %" if pd.notna(matched_pdf["heel_peak_time_pct_d"]) else "N/A",
707
+ f"{matched_pdf['mid_peak_time_pct_g']:.1f} %" if pd.notna(matched_pdf["mid_peak_time_pct_g"]) else "N/A",
708
+ f"{matched_pdf['mid_peak_time_pct_d']:.1f} %" if pd.notna(matched_pdf["mid_peak_time_pct_d"]) else "N/A",
709
+ f"{matched_pdf['fore_peak_time_pct_g']:.1f} %" if pd.notna(matched_pdf["fore_peak_time_pct_g"]) else "N/A",
710
+ f"{matched_pdf['fore_peak_time_pct_d']:.1f} %" if pd.notna(matched_pdf["fore_peak_time_pct_d"]) else "N/A",
711
+ ],
712
+ }
713
+ )
714
+ st.dataframe(pdf_df, hide_index=True, use_container_width=True)
715
+
716
+ st.write(
717
+ "Le PDF apporte surtout des informations temporelles et zonales plus fines, "
718
+ "notamment pour l’estimation du type d’attaque."
719
+ )