kinemotion 0.76.3__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of kinemotion might be problematic. Click here for more details.
- kinemotion/__init__.py +3 -18
- kinemotion/api.py +7 -27
- kinemotion/cli.py +2 -4
- kinemotion/{countermovement_jump → cmj}/analysis.py +158 -16
- kinemotion/{countermovement_jump → cmj}/api.py +18 -46
- kinemotion/{countermovement_jump → cmj}/cli.py +46 -6
- kinemotion/cmj/debug_overlay.py +457 -0
- kinemotion/{countermovement_jump → cmj}/joint_angles.py +31 -96
- kinemotion/{countermovement_jump → cmj}/metrics_validator.py +293 -184
- kinemotion/{countermovement_jump → cmj}/validation_bounds.py +18 -1
- kinemotion/core/__init__.py +2 -11
- kinemotion/core/auto_tuning.py +107 -149
- kinemotion/core/cli_utils.py +0 -74
- kinemotion/core/debug_overlay_utils.py +15 -142
- kinemotion/core/experimental.py +51 -55
- kinemotion/core/filtering.py +56 -116
- kinemotion/core/pipeline_utils.py +2 -2
- kinemotion/core/pose.py +98 -47
- kinemotion/core/quality.py +6 -4
- kinemotion/core/smoothing.py +51 -65
- kinemotion/core/types.py +0 -15
- kinemotion/core/validation.py +7 -76
- kinemotion/core/video_io.py +27 -41
- kinemotion/{drop_jump → dropjump}/__init__.py +8 -2
- kinemotion/{drop_jump → dropjump}/analysis.py +120 -282
- kinemotion/{drop_jump → dropjump}/api.py +33 -59
- kinemotion/{drop_jump → dropjump}/cli.py +136 -70
- kinemotion/dropjump/debug_overlay.py +182 -0
- kinemotion/{drop_jump → dropjump}/kinematics.py +65 -175
- kinemotion/{drop_jump → dropjump}/metrics_validator.py +51 -25
- kinemotion/{drop_jump → dropjump}/validation_bounds.py +1 -1
- kinemotion/models/rtmpose-s_simcc-body7_pt-body7-halpe26_700e-256x192-7f134165_20230605.onnx +3 -0
- kinemotion/models/yolox_tiny_8xb8-300e_humanart-6f3252f9.onnx +3 -0
- {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/METADATA +26 -75
- kinemotion-1.0.0.dist-info/RECORD +49 -0
- kinemotion/core/overlay_constants.py +0 -61
- kinemotion/core/video_analysis_base.py +0 -132
- kinemotion/countermovement_jump/debug_overlay.py +0 -325
- kinemotion/drop_jump/debug_overlay.py +0 -241
- kinemotion/squat_jump/__init__.py +0 -5
- kinemotion/squat_jump/analysis.py +0 -377
- kinemotion/squat_jump/api.py +0 -610
- kinemotion/squat_jump/cli.py +0 -309
- kinemotion/squat_jump/debug_overlay.py +0 -163
- kinemotion/squat_jump/kinematics.py +0 -342
- kinemotion/squat_jump/metrics_validator.py +0 -438
- kinemotion/squat_jump/validation_bounds.py +0 -221
- kinemotion-0.76.3.dist-info/RECORD +0 -57
- /kinemotion/{countermovement_jump → cmj}/__init__.py +0 -0
- /kinemotion/{countermovement_jump → cmj}/kinematics.py +0 -0
- {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -9,6 +9,13 @@ of metric issues.
|
|
|
9
9
|
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
|
|
12
|
+
from kinemotion.cmj.validation_bounds import (
|
|
13
|
+
CMJBounds,
|
|
14
|
+
MetricConsistency,
|
|
15
|
+
RSIBounds,
|
|
16
|
+
TripleExtensionBounds,
|
|
17
|
+
estimate_athlete_profile,
|
|
18
|
+
)
|
|
12
19
|
from kinemotion.core.types import MetricsDict
|
|
13
20
|
from kinemotion.core.validation import (
|
|
14
21
|
AthleteProfile,
|
|
@@ -16,13 +23,6 @@ from kinemotion.core.validation import (
|
|
|
16
23
|
MetricsValidator,
|
|
17
24
|
ValidationResult,
|
|
18
25
|
)
|
|
19
|
-
from kinemotion.countermovement_jump.validation_bounds import (
|
|
20
|
-
CMJBounds,
|
|
21
|
-
MetricConsistency,
|
|
22
|
-
RSIBounds,
|
|
23
|
-
TripleExtensionBounds,
|
|
24
|
-
estimate_athlete_profile,
|
|
25
|
-
)
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
@dataclass
|
|
@@ -81,23 +81,6 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
81
81
|
"""
|
|
82
82
|
return data.get(key_with_suffix) or data.get(key_without_suffix)
|
|
83
83
|
|
|
84
|
-
@staticmethod
|
|
85
|
-
def _convert_raw_duration_to_seconds(value_raw: float) -> float:
|
|
86
|
-
"""Convert raw duration value to seconds.
|
|
87
|
-
|
|
88
|
-
Handles legacy values that may be in seconds (<10) vs milliseconds (>10).
|
|
89
|
-
This heuristic works because no CMJ duration metric is between 10ms and 10s.
|
|
90
|
-
|
|
91
|
-
Args:
|
|
92
|
-
value_raw: Raw duration value (may be seconds or milliseconds)
|
|
93
|
-
|
|
94
|
-
Returns:
|
|
95
|
-
Duration in seconds
|
|
96
|
-
"""
|
|
97
|
-
if value_raw < 10: # Likely in seconds
|
|
98
|
-
return value_raw
|
|
99
|
-
return value_raw / 1000.0
|
|
100
|
-
|
|
101
84
|
def validate(self, metrics: MetricsDict) -> CMJValidationResult:
|
|
102
85
|
"""Validate CMJ metrics comprehensively.
|
|
103
86
|
|
|
@@ -124,8 +107,8 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
124
107
|
self._check_flight_time(data, result, profile)
|
|
125
108
|
self._check_jump_height(data, result, profile)
|
|
126
109
|
self._check_countermovement_depth(data, result, profile)
|
|
127
|
-
self._check_concentric_duration(data, result)
|
|
128
|
-
self._check_eccentric_duration(data, result)
|
|
110
|
+
self._check_concentric_duration(data, result, profile)
|
|
111
|
+
self._check_eccentric_duration(data, result, profile)
|
|
129
112
|
self._check_peak_velocities(data, result, profile)
|
|
130
113
|
|
|
131
114
|
# CROSS-VALIDATION CHECKS
|
|
@@ -153,23 +136,45 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
153
136
|
if flight_time_raw is None:
|
|
154
137
|
return
|
|
155
138
|
|
|
156
|
-
|
|
139
|
+
# If value is in seconds (legacy), use as-is; if in ms, convert
|
|
140
|
+
if flight_time_raw < 10: # Likely in seconds
|
|
141
|
+
flight_time = flight_time_raw
|
|
142
|
+
else: # In milliseconds
|
|
143
|
+
flight_time = flight_time_raw / 1000.0
|
|
144
|
+
|
|
157
145
|
bounds = CMJBounds.FLIGHT_TIME
|
|
158
|
-
error_label = (
|
|
159
|
-
"below frame rate resolution limit"
|
|
160
|
-
if flight_time < bounds.absolute_min
|
|
161
|
-
else "exceeds elite human capability"
|
|
162
|
-
)
|
|
163
146
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
147
|
+
if not bounds.is_physically_possible(flight_time):
|
|
148
|
+
if flight_time < bounds.absolute_min:
|
|
149
|
+
result.add_error(
|
|
150
|
+
"flight_time",
|
|
151
|
+
f"Flight time {flight_time:.3f}s below frame rate resolution limit",
|
|
152
|
+
value=flight_time,
|
|
153
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
result.add_error(
|
|
157
|
+
"flight_time",
|
|
158
|
+
f"Flight time {flight_time:.3f}s exceeds elite human capability",
|
|
159
|
+
value=flight_time,
|
|
160
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
161
|
+
)
|
|
162
|
+
elif bounds.contains(flight_time, profile):
|
|
163
|
+
result.add_info(
|
|
164
|
+
"flight_time",
|
|
165
|
+
f"Flight time {flight_time:.3f}s within expected range for {profile.value}",
|
|
166
|
+
value=flight_time,
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
# Outside expected range but physically possible
|
|
170
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
171
|
+
result.add_warning(
|
|
172
|
+
"flight_time",
|
|
173
|
+
f"Flight time {flight_time:.3f}s outside typical range "
|
|
174
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
|
|
175
|
+
value=flight_time,
|
|
176
|
+
bounds=(expected_min, expected_max),
|
|
177
|
+
)
|
|
173
178
|
|
|
174
179
|
def _check_jump_height(
|
|
175
180
|
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
@@ -180,21 +185,37 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
180
185
|
return
|
|
181
186
|
|
|
182
187
|
bounds = CMJBounds.JUMP_HEIGHT
|
|
183
|
-
error_label = (
|
|
184
|
-
"essentially no jump (noise)"
|
|
185
|
-
if jump_height < bounds.absolute_min
|
|
186
|
-
else "exceeds human capability"
|
|
187
|
-
)
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
189
|
+
if not bounds.is_physically_possible(jump_height):
|
|
190
|
+
if jump_height < bounds.absolute_min:
|
|
191
|
+
result.add_error(
|
|
192
|
+
"jump_height",
|
|
193
|
+
f"Jump height {jump_height:.3f}m essentially no jump (noise)",
|
|
194
|
+
value=jump_height,
|
|
195
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
result.add_error(
|
|
199
|
+
"jump_height",
|
|
200
|
+
f"Jump height {jump_height:.3f}m exceeds human capability",
|
|
201
|
+
value=jump_height,
|
|
202
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
203
|
+
)
|
|
204
|
+
elif bounds.contains(jump_height, profile):
|
|
205
|
+
result.add_info(
|
|
206
|
+
"jump_height",
|
|
207
|
+
f"Jump height {jump_height:.3f}m within expected range for {profile.value}",
|
|
208
|
+
value=jump_height,
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
212
|
+
result.add_warning(
|
|
213
|
+
"jump_height",
|
|
214
|
+
f"Jump height {jump_height:.3f}m outside typical range "
|
|
215
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
|
|
216
|
+
value=jump_height,
|
|
217
|
+
bounds=(expected_min, expected_max),
|
|
218
|
+
)
|
|
198
219
|
|
|
199
220
|
def _check_countermovement_depth(
|
|
200
221
|
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
@@ -205,22 +226,40 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
205
226
|
return
|
|
206
227
|
|
|
207
228
|
bounds = CMJBounds.COUNTERMOVEMENT_DEPTH
|
|
208
|
-
error_label = (
|
|
209
|
-
"essentially no squat" if depth < bounds.absolute_min else "exceeds physical limit"
|
|
210
|
-
)
|
|
211
229
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
230
|
+
if not bounds.is_physically_possible(depth):
|
|
231
|
+
if depth < bounds.absolute_min:
|
|
232
|
+
result.add_error(
|
|
233
|
+
"countermovement_depth",
|
|
234
|
+
f"Countermovement depth {depth:.3f}m essentially no squat",
|
|
235
|
+
value=depth,
|
|
236
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
result.add_error(
|
|
240
|
+
"countermovement_depth",
|
|
241
|
+
f"Countermovement depth {depth:.3f}m exceeds physical limit",
|
|
242
|
+
value=depth,
|
|
243
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
244
|
+
)
|
|
245
|
+
elif bounds.contains(depth, profile):
|
|
246
|
+
result.add_info(
|
|
247
|
+
"countermovement_depth",
|
|
248
|
+
f"Countermovement depth {depth:.3f}m within expected range for {profile.value}",
|
|
249
|
+
value=depth,
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
253
|
+
result.add_warning(
|
|
254
|
+
"countermovement_depth",
|
|
255
|
+
f"Countermovement depth {depth:.3f}m outside typical range "
|
|
256
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
|
|
257
|
+
value=depth,
|
|
258
|
+
bounds=(expected_min, expected_max),
|
|
259
|
+
)
|
|
221
260
|
|
|
222
261
|
def _check_concentric_duration(
|
|
223
|
-
self, metrics: MetricsDict, result: CMJValidationResult
|
|
262
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
224
263
|
) -> None:
|
|
225
264
|
"""Validate concentric duration (contact time)."""
|
|
226
265
|
duration_raw = self._get_metric_value(
|
|
@@ -229,7 +268,13 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
229
268
|
if duration_raw is None:
|
|
230
269
|
return
|
|
231
270
|
|
|
232
|
-
|
|
271
|
+
# If value is in seconds (legacy), convert to ms first
|
|
272
|
+
# Values >10 are assumed to be in ms, <10 assumed to be in seconds
|
|
273
|
+
if duration_raw < 10: # Likely in seconds
|
|
274
|
+
duration = duration_raw
|
|
275
|
+
else: # In milliseconds
|
|
276
|
+
duration = duration_raw / 1000.0
|
|
277
|
+
|
|
233
278
|
bounds = CMJBounds.CONCENTRIC_DURATION
|
|
234
279
|
|
|
235
280
|
if not bounds.is_physically_possible(duration):
|
|
@@ -247,16 +292,25 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
247
292
|
value=duration,
|
|
248
293
|
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
249
294
|
)
|
|
250
|
-
|
|
251
|
-
# NOTE: Downgraded from WARNING to INFO - standing end detection has
|
|
252
|
-
# ~117ms offset causing misleading warnings. See issue #16.
|
|
295
|
+
elif bounds.contains(duration, profile):
|
|
253
296
|
result.add_info(
|
|
254
297
|
"concentric_duration",
|
|
255
|
-
f"Concentric duration {duration:.3f}s",
|
|
298
|
+
f"Concentric duration {duration:.3f}s within expected range for {profile.value}",
|
|
299
|
+
value=duration,
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
303
|
+
result.add_warning(
|
|
304
|
+
"concentric_duration",
|
|
305
|
+
f"Concentric duration {duration:.3f}s outside typical range "
|
|
306
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
|
|
256
307
|
value=duration,
|
|
308
|
+
bounds=(expected_min, expected_max),
|
|
257
309
|
)
|
|
258
310
|
|
|
259
|
-
def _check_eccentric_duration(
|
|
311
|
+
def _check_eccentric_duration(
|
|
312
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
313
|
+
) -> None:
|
|
260
314
|
"""Validate eccentric duration."""
|
|
261
315
|
duration_raw = self._get_metric_value(
|
|
262
316
|
metrics, "eccentric_duration_ms", "eccentric_duration"
|
|
@@ -264,7 +318,12 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
264
318
|
if duration_raw is None:
|
|
265
319
|
return
|
|
266
320
|
|
|
267
|
-
|
|
321
|
+
# If value is in seconds (legacy), use as-is; if in ms, convert
|
|
322
|
+
if duration_raw < 10: # Likely in seconds
|
|
323
|
+
duration = duration_raw
|
|
324
|
+
else: # In milliseconds
|
|
325
|
+
duration = duration_raw / 1000.0
|
|
326
|
+
|
|
268
327
|
bounds = CMJBounds.ECCENTRIC_DURATION
|
|
269
328
|
|
|
270
329
|
if not bounds.is_physically_possible(duration):
|
|
@@ -274,87 +333,91 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
274
333
|
value=duration,
|
|
275
334
|
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
276
335
|
)
|
|
277
|
-
|
|
278
|
-
# NOTE: Downgraded from WARNING to INFO - standing end detection has
|
|
279
|
-
# ~117ms offset causing misleading warnings. See issue #16.
|
|
336
|
+
elif bounds.contains(duration, profile):
|
|
280
337
|
result.add_info(
|
|
281
338
|
"eccentric_duration",
|
|
282
|
-
f"Eccentric duration {duration:.3f}s",
|
|
339
|
+
f"Eccentric duration {duration:.3f}s within expected range for {profile.value}",
|
|
283
340
|
value=duration,
|
|
284
341
|
)
|
|
342
|
+
else:
|
|
343
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
344
|
+
result.add_warning(
|
|
345
|
+
"eccentric_duration",
|
|
346
|
+
f"Eccentric duration {duration:.3f}s outside typical range "
|
|
347
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
|
|
348
|
+
value=duration,
|
|
349
|
+
bounds=(expected_min, expected_max),
|
|
350
|
+
)
|
|
285
351
|
|
|
286
352
|
def _check_peak_velocities(
|
|
287
353
|
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
288
354
|
) -> None:
|
|
289
355
|
"""Validate peak eccentric and concentric velocities."""
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
356
|
+
# Eccentric
|
|
357
|
+
ecc_vel = self._get_metric_value(
|
|
358
|
+
metrics, "peak_eccentric_velocity_m_s", "peak_eccentric_velocity"
|
|
359
|
+
)
|
|
360
|
+
if ecc_vel is not None:
|
|
361
|
+
bounds = CMJBounds.PEAK_ECCENTRIC_VELOCITY
|
|
362
|
+
if not bounds.is_physically_possible(ecc_vel):
|
|
363
|
+
result.add_error(
|
|
364
|
+
"peak_eccentric_velocity",
|
|
365
|
+
f"Peak eccentric velocity {ecc_vel:.2f} m/s outside limits",
|
|
366
|
+
value=ecc_vel,
|
|
367
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
368
|
+
)
|
|
369
|
+
elif bounds.contains(ecc_vel, profile):
|
|
370
|
+
result.add_info(
|
|
371
|
+
"peak_eccentric_velocity",
|
|
372
|
+
f"Peak eccentric velocity {ecc_vel:.2f} m/s within range for {profile.value}",
|
|
373
|
+
value=ecc_vel,
|
|
374
|
+
)
|
|
375
|
+
else:
|
|
376
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
377
|
+
result.add_warning(
|
|
378
|
+
"peak_eccentric_velocity",
|
|
379
|
+
f"Peak eccentric velocity {ecc_vel:.2f} m/s outside typical range "
|
|
380
|
+
f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
|
|
381
|
+
value=ecc_vel,
|
|
382
|
+
bounds=(expected_min, expected_max),
|
|
383
|
+
)
|
|
313
384
|
|
|
314
|
-
|
|
315
|
-
self
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
385
|
+
# Concentric
|
|
386
|
+
con_vel = self._get_metric_value(
|
|
387
|
+
metrics, "peak_concentric_velocity_m_s", "peak_concentric_velocity"
|
|
388
|
+
)
|
|
389
|
+
if con_vel is not None:
|
|
390
|
+
bounds = CMJBounds.PEAK_CONCENTRIC_VELOCITY
|
|
391
|
+
if not bounds.is_physically_possible(con_vel):
|
|
392
|
+
if con_vel < bounds.absolute_min:
|
|
393
|
+
result.add_error(
|
|
394
|
+
"peak_concentric_velocity",
|
|
395
|
+
f"Peak concentric velocity {con_vel:.2f} m/s insufficient to leave ground",
|
|
396
|
+
value=con_vel,
|
|
397
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
result.add_error(
|
|
401
|
+
"peak_concentric_velocity",
|
|
402
|
+
f"Peak concentric velocity {con_vel:.2f} m/s exceeds elite capability",
|
|
403
|
+
value=con_vel,
|
|
404
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
405
|
+
)
|
|
406
|
+
elif bounds.contains(con_vel, profile):
|
|
407
|
+
result.add_info(
|
|
408
|
+
"peak_concentric_velocity",
|
|
409
|
+
f"Peak concentric velocity {con_vel:.2f} m/s within range for {profile.value}",
|
|
410
|
+
value=con_vel,
|
|
328
411
|
)
|
|
329
412
|
else:
|
|
330
|
-
|
|
331
|
-
|
|
413
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
414
|
+
result.add_warning(
|
|
415
|
+
"peak_concentric_velocity",
|
|
416
|
+
f"Peak concentric velocity {con_vel:.2f} m/s outside typical range "
|
|
417
|
+
f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
|
|
418
|
+
value=con_vel,
|
|
419
|
+
bounds=(expected_min, expected_max),
|
|
332
420
|
)
|
|
333
|
-
result.add_error(
|
|
334
|
-
name,
|
|
335
|
-
error_msg,
|
|
336
|
-
value=velocity,
|
|
337
|
-
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
338
|
-
)
|
|
339
|
-
elif bounds.contains(velocity, profile):
|
|
340
|
-
velocity_type = name.replace("peak_", "").replace("_", " ")
|
|
341
|
-
result.add_info(
|
|
342
|
-
name,
|
|
343
|
-
f"Peak {velocity_type} velocity {velocity:.2f} m/s "
|
|
344
|
-
f"within range for {profile.value}",
|
|
345
|
-
value=velocity,
|
|
346
|
-
)
|
|
347
|
-
else:
|
|
348
|
-
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
349
|
-
velocity_type = name.replace("peak_", "").replace("_", " ")
|
|
350
|
-
result.add_warning(
|
|
351
|
-
name,
|
|
352
|
-
f"Peak {velocity_type} velocity {velocity:.2f} m/s "
|
|
353
|
-
f"outside typical range [{expected_min:.2f}-{expected_max:.2f}] "
|
|
354
|
-
f"for {profile.value}",
|
|
355
|
-
value=velocity,
|
|
356
|
-
bounds=(expected_min, expected_max),
|
|
357
|
-
)
|
|
358
421
|
|
|
359
422
|
def _check_flight_time_height_consistency(
|
|
360
423
|
self, metrics: MetricsDict, result: CMJValidationResult
|
|
@@ -444,8 +507,16 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
444
507
|
):
|
|
445
508
|
return
|
|
446
509
|
|
|
447
|
-
|
|
448
|
-
|
|
510
|
+
# Convert to seconds if needed
|
|
511
|
+
if flight_time_raw < 10: # Likely in seconds
|
|
512
|
+
flight_time = flight_time_raw
|
|
513
|
+
else: # In milliseconds
|
|
514
|
+
flight_time = flight_time_raw / 1000.0
|
|
515
|
+
|
|
516
|
+
if concentric_duration_raw < 10: # Likely in seconds
|
|
517
|
+
concentric_duration = concentric_duration_raw
|
|
518
|
+
else: # In milliseconds
|
|
519
|
+
concentric_duration = concentric_duration_raw / 1000.0
|
|
449
520
|
|
|
450
521
|
rsi = flight_time / concentric_duration
|
|
451
522
|
result.rsi = rsi
|
|
@@ -575,28 +646,49 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
575
646
|
if angles is None:
|
|
576
647
|
return
|
|
577
648
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
649
|
+
hip = angles.get("hip_angle")
|
|
650
|
+
if hip is not None:
|
|
651
|
+
if not TripleExtensionBounds.hip_angle_valid(hip, profile):
|
|
652
|
+
result.add_warning(
|
|
653
|
+
"hip_angle",
|
|
654
|
+
f"Hip angle {hip:.1f}° outside expected range for {profile.value}",
|
|
655
|
+
value=hip,
|
|
656
|
+
)
|
|
657
|
+
else:
|
|
658
|
+
result.add_info(
|
|
659
|
+
"hip_angle",
|
|
660
|
+
f"Hip angle {hip:.1f}° within expected range for {profile.value}",
|
|
661
|
+
value=hip,
|
|
662
|
+
)
|
|
583
663
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if
|
|
587
|
-
|
|
664
|
+
knee = angles.get("knee_angle")
|
|
665
|
+
if knee is not None:
|
|
666
|
+
if not TripleExtensionBounds.knee_angle_valid(knee, profile):
|
|
667
|
+
result.add_warning(
|
|
668
|
+
"knee_angle",
|
|
669
|
+
f"Knee angle {knee:.1f}° outside expected range for {profile.value}",
|
|
670
|
+
value=knee,
|
|
671
|
+
)
|
|
672
|
+
else:
|
|
673
|
+
result.add_info(
|
|
674
|
+
"knee_angle",
|
|
675
|
+
f"Knee angle {knee:.1f}° within expected range for {profile.value}",
|
|
676
|
+
value=knee,
|
|
677
|
+
)
|
|
588
678
|
|
|
589
|
-
|
|
679
|
+
ankle = angles.get("ankle_angle")
|
|
680
|
+
if ankle is not None:
|
|
681
|
+
if not TripleExtensionBounds.ankle_angle_valid(ankle, profile):
|
|
590
682
|
result.add_warning(
|
|
591
|
-
|
|
592
|
-
f"
|
|
593
|
-
value=
|
|
683
|
+
"ankle_angle",
|
|
684
|
+
f"Ankle angle {ankle:.1f}° outside expected range for {profile.value}",
|
|
685
|
+
value=ankle,
|
|
594
686
|
)
|
|
595
687
|
else:
|
|
596
688
|
result.add_info(
|
|
597
|
-
|
|
598
|
-
f"
|
|
599
|
-
value=
|
|
689
|
+
"ankle_angle",
|
|
690
|
+
f"Ankle angle {ankle:.1f}° within expected range for {profile.value}",
|
|
691
|
+
value=ankle,
|
|
600
692
|
)
|
|
601
693
|
|
|
602
694
|
# Detect joint compensation patterns
|
|
@@ -620,32 +712,32 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
620
712
|
if hip is None or knee is None or ankle is None:
|
|
621
713
|
return # Need all three to detect patterns
|
|
622
714
|
|
|
623
|
-
#
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
715
|
+
# Get profile-specific bounds
|
|
716
|
+
if profile == AthleteProfile.ELDERLY:
|
|
717
|
+
hip_min, hip_max = 150, 175
|
|
718
|
+
knee_min, knee_max = 155, 175
|
|
719
|
+
ankle_min, ankle_max = 100, 125
|
|
720
|
+
elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
|
|
721
|
+
hip_min, hip_max = 160, 180
|
|
722
|
+
knee_min, knee_max = 165, 182
|
|
723
|
+
ankle_min, ankle_max = 110, 140
|
|
724
|
+
elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
|
|
725
|
+
hip_min, hip_max = 170, 185
|
|
726
|
+
knee_min, knee_max = 173, 190
|
|
727
|
+
ankle_min, ankle_max = 125, 155
|
|
728
|
+
else:
|
|
634
729
|
return
|
|
635
730
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
# Count joints at boundaries
|
|
731
|
+
# Count how many joints are near their boundaries
|
|
732
|
+
joints_at_boundary = 0
|
|
639
733
|
boundary_threshold = 3.0 # degrees from limit
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
if val <= min_val + boundary_threshold or val >= max_val - boundary_threshold
|
|
648
|
-
)
|
|
734
|
+
|
|
735
|
+
if hip <= hip_min + boundary_threshold or hip >= hip_max - boundary_threshold:
|
|
736
|
+
joints_at_boundary += 1
|
|
737
|
+
if knee <= knee_min + boundary_threshold or knee >= knee_max - boundary_threshold:
|
|
738
|
+
joints_at_boundary += 1
|
|
739
|
+
if ankle <= ankle_min + boundary_threshold or ankle >= ankle_max - boundary_threshold:
|
|
740
|
+
joints_at_boundary += 1
|
|
649
741
|
|
|
650
742
|
# If 2+ joints at boundaries, likely compensation pattern
|
|
651
743
|
if joints_at_boundary >= 2:
|
|
@@ -656,3 +748,20 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
656
748
|
f"May indicate compensatory movement pattern.",
|
|
657
749
|
value=float(joints_at_boundary),
|
|
658
750
|
)
|
|
751
|
+
|
|
752
|
+
@staticmethod
|
|
753
|
+
def _get_profile_range(profile: AthleteProfile, bounds: MetricBounds) -> tuple[float, float]:
|
|
754
|
+
"""Get min/max bounds for specific profile."""
|
|
755
|
+
if profile == AthleteProfile.ELDERLY:
|
|
756
|
+
return (bounds.practical_min, bounds.recreational_max)
|
|
757
|
+
elif profile == AthleteProfile.UNTRAINED:
|
|
758
|
+
return (bounds.practical_min, bounds.recreational_max)
|
|
759
|
+
elif profile == AthleteProfile.RECREATIONAL:
|
|
760
|
+
return (bounds.recreational_min, bounds.recreational_max)
|
|
761
|
+
elif profile == AthleteProfile.TRAINED:
|
|
762
|
+
trained_min = (bounds.recreational_min + bounds.elite_min) / 2
|
|
763
|
+
trained_max = (bounds.recreational_max + bounds.elite_max) / 2
|
|
764
|
+
return (trained_min, trained_max)
|
|
765
|
+
elif profile == AthleteProfile.ELITE:
|
|
766
|
+
return (bounds.elite_min, bounds.elite_max)
|
|
767
|
+
return (bounds.absolute_min, bounds.absolute_max)
|
|
@@ -170,6 +170,17 @@ class TripleExtensionBounds:
|
|
|
170
170
|
return 125 <= angle <= 155
|
|
171
171
|
return True
|
|
172
172
|
|
|
173
|
+
# TRUNK TILT (forward lean from vertical, degrees)
|
|
174
|
+
@staticmethod
|
|
175
|
+
def trunk_tilt_valid(angle: float | None, profile: AthleteProfile) -> bool:
|
|
176
|
+
"""Check if trunk tilt is valid for profile."""
|
|
177
|
+
if angle is None:
|
|
178
|
+
return True
|
|
179
|
+
if angle < -15 or angle > 60:
|
|
180
|
+
return False # Outside reasonable range
|
|
181
|
+
# Most athletes show 10-30° forward lean during takeoff
|
|
182
|
+
return -10 <= angle <= 45
|
|
183
|
+
|
|
173
184
|
|
|
174
185
|
class RSIBounds:
|
|
175
186
|
"""Reactive Strength Index bounds."""
|
|
@@ -204,6 +215,12 @@ class RSIBounds:
|
|
|
204
215
|
"""Check if RSI is within physiological bounds."""
|
|
205
216
|
return RSIBounds.MIN_VALID <= rsi <= RSIBounds.MAX_VALID
|
|
206
217
|
|
|
218
|
+
@staticmethod
|
|
219
|
+
def in_range_for_profile(rsi: float, profile: AthleteProfile) -> bool:
|
|
220
|
+
"""Check if RSI is in expected range for profile."""
|
|
221
|
+
min_rsi, max_rsi = RSIBounds.get_rsi_range(profile)
|
|
222
|
+
return min_rsi <= rsi <= max_rsi
|
|
223
|
+
|
|
207
224
|
|
|
208
225
|
class MetricConsistency:
|
|
209
226
|
"""Cross-validation tolerance for metric consistency checks."""
|
|
@@ -284,7 +301,7 @@ ATHLETE_PROFILES = {
|
|
|
284
301
|
|
|
285
302
|
|
|
286
303
|
def estimate_athlete_profile(
|
|
287
|
-
metrics_dict: MetricsDict,
|
|
304
|
+
metrics_dict: MetricsDict, gender: str | None = None
|
|
288
305
|
) -> AthleteProfile:
|
|
289
306
|
"""Estimate athlete profile from metrics.
|
|
290
307
|
|