kinemotion 0.28.0__py3-none-any.whl → 0.29.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 +1 -1
- kinemotion/cmj/analysis.py +43 -10
- kinemotion/cmj/joint_angles.py +23 -6
- kinemotion/core/cmj_metrics_validator.py +717 -0
- kinemotion/core/cmj_validation_bounds.py +380 -0
- {kinemotion-0.28.0.dist-info → kinemotion-0.29.0.dist-info}/METADATA +111 -49
- {kinemotion-0.28.0.dist-info → kinemotion-0.29.0.dist-info}/RECORD +10 -8
- {kinemotion-0.28.0.dist-info → kinemotion-0.29.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.28.0.dist-info → kinemotion-0.29.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.28.0.dist-info → kinemotion-0.29.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
"""CMJ metrics validation using physiological bounds.
|
|
2
|
+
|
|
3
|
+
Comprehensive validation of Counter Movement Jump metrics against
|
|
4
|
+
biomechanical bounds, cross-validation checks, and consistency tests.
|
|
5
|
+
|
|
6
|
+
Provides severity levels (ERROR, WARNING, INFO) for different categories
|
|
7
|
+
of metric issues.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from kinemotion.core.cmj_validation_bounds import (
|
|
14
|
+
AthleteProfile,
|
|
15
|
+
CMJBounds,
|
|
16
|
+
MetricBounds,
|
|
17
|
+
MetricConsistency,
|
|
18
|
+
RSIBounds,
|
|
19
|
+
TripleExtensionBounds,
|
|
20
|
+
estimate_athlete_profile,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidationSeverity(Enum):
|
|
25
|
+
"""Severity level for validation issues."""
|
|
26
|
+
|
|
27
|
+
ERROR = "ERROR" # Metrics invalid, likely data corruption
|
|
28
|
+
WARNING = "WARNING" # Metrics valid but unusual, needs review
|
|
29
|
+
INFO = "INFO" # Normal variation, informational only
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ValidationIssue:
|
|
34
|
+
"""Single validation issue."""
|
|
35
|
+
|
|
36
|
+
severity: ValidationSeverity
|
|
37
|
+
metric: str
|
|
38
|
+
message: str
|
|
39
|
+
value: float | None = None
|
|
40
|
+
bounds: tuple[float, float] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ValidationResult:
|
|
45
|
+
"""Complete validation result for CMJ metrics."""
|
|
46
|
+
|
|
47
|
+
issues: list[ValidationIssue] = field(default_factory=list)
|
|
48
|
+
status: str = "PASS" # "PASS", "PASS_WITH_WARNINGS", "FAIL"
|
|
49
|
+
athlete_profile: AthleteProfile | None = None
|
|
50
|
+
rsi: float | None = None
|
|
51
|
+
height_flight_time_consistency: float | None = None # % error
|
|
52
|
+
velocity_height_consistency: float | None = None # % error
|
|
53
|
+
depth_height_ratio: float | None = None
|
|
54
|
+
contact_depth_ratio: float | None = None
|
|
55
|
+
|
|
56
|
+
def add_error(
|
|
57
|
+
self,
|
|
58
|
+
metric: str,
|
|
59
|
+
message: str,
|
|
60
|
+
value: float | None = None,
|
|
61
|
+
bounds: tuple[float, float] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Add error-level issue."""
|
|
64
|
+
self.issues.append(
|
|
65
|
+
ValidationIssue(
|
|
66
|
+
severity=ValidationSeverity.ERROR,
|
|
67
|
+
metric=metric,
|
|
68
|
+
message=message,
|
|
69
|
+
value=value,
|
|
70
|
+
bounds=bounds,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def add_warning(
|
|
75
|
+
self,
|
|
76
|
+
metric: str,
|
|
77
|
+
message: str,
|
|
78
|
+
value: float | None = None,
|
|
79
|
+
bounds: tuple[float, float] | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Add warning-level issue."""
|
|
82
|
+
self.issues.append(
|
|
83
|
+
ValidationIssue(
|
|
84
|
+
severity=ValidationSeverity.WARNING,
|
|
85
|
+
metric=metric,
|
|
86
|
+
message=message,
|
|
87
|
+
value=value,
|
|
88
|
+
bounds=bounds,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def add_info(
|
|
93
|
+
self,
|
|
94
|
+
metric: str,
|
|
95
|
+
message: str,
|
|
96
|
+
value: float | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Add info-level issue."""
|
|
99
|
+
self.issues.append(
|
|
100
|
+
ValidationIssue(
|
|
101
|
+
severity=ValidationSeverity.INFO,
|
|
102
|
+
metric=metric,
|
|
103
|
+
message=message,
|
|
104
|
+
value=value,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def finalize_status(self) -> None:
|
|
109
|
+
"""Determine final pass/fail status based on issues."""
|
|
110
|
+
has_errors = any(
|
|
111
|
+
issue.severity == ValidationSeverity.ERROR for issue in self.issues
|
|
112
|
+
)
|
|
113
|
+
has_warnings = any(
|
|
114
|
+
issue.severity == ValidationSeverity.WARNING for issue in self.issues
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if has_errors:
|
|
118
|
+
self.status = "FAIL"
|
|
119
|
+
elif has_warnings:
|
|
120
|
+
self.status = "PASS_WITH_WARNINGS"
|
|
121
|
+
else:
|
|
122
|
+
self.status = "PASS"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class CMJMetricsValidator:
|
|
126
|
+
"""Comprehensive CMJ metrics validator."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, assumed_profile: AthleteProfile | None = None):
|
|
129
|
+
"""Initialize validator.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
assumed_profile: If provided, validate against this specific profile.
|
|
133
|
+
Otherwise, estimate from metrics.
|
|
134
|
+
"""
|
|
135
|
+
self.assumed_profile = assumed_profile
|
|
136
|
+
|
|
137
|
+
def validate(self, metrics: dict) -> ValidationResult:
|
|
138
|
+
"""Validate CMJ metrics comprehensively.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
metrics: Dictionary with CMJ metric values
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
ValidationResult with all issues and status
|
|
145
|
+
"""
|
|
146
|
+
result = ValidationResult()
|
|
147
|
+
|
|
148
|
+
# Estimate athlete profile if not provided
|
|
149
|
+
if self.assumed_profile:
|
|
150
|
+
result.athlete_profile = self.assumed_profile
|
|
151
|
+
else:
|
|
152
|
+
result.athlete_profile = estimate_athlete_profile(metrics)
|
|
153
|
+
|
|
154
|
+
profile = result.athlete_profile
|
|
155
|
+
|
|
156
|
+
# PRIMARY BOUNDS CHECKS
|
|
157
|
+
self._check_flight_time(metrics, result, profile)
|
|
158
|
+
self._check_jump_height(metrics, result, profile)
|
|
159
|
+
self._check_countermovement_depth(metrics, result, profile)
|
|
160
|
+
self._check_concentric_duration(metrics, result, profile)
|
|
161
|
+
self._check_eccentric_duration(metrics, result, profile)
|
|
162
|
+
self._check_peak_velocities(metrics, result, profile)
|
|
163
|
+
|
|
164
|
+
# CROSS-VALIDATION CHECKS
|
|
165
|
+
self._check_flight_time_height_consistency(metrics, result)
|
|
166
|
+
self._check_velocity_height_consistency(metrics, result)
|
|
167
|
+
self._check_rsi_validity(metrics, result, profile)
|
|
168
|
+
|
|
169
|
+
# CONSISTENCY CHECKS
|
|
170
|
+
self._check_depth_height_ratio(metrics, result)
|
|
171
|
+
self._check_contact_depth_ratio(metrics, result)
|
|
172
|
+
|
|
173
|
+
# TRIPLE EXTENSION ANGLES
|
|
174
|
+
self._check_triple_extension(metrics, result, profile)
|
|
175
|
+
|
|
176
|
+
# Finalize status
|
|
177
|
+
result.finalize_status()
|
|
178
|
+
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
def _check_flight_time(
|
|
182
|
+
self, metrics: dict, result: ValidationResult, profile: AthleteProfile
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Validate flight time."""
|
|
185
|
+
flight_time = metrics.get("flight_time")
|
|
186
|
+
if flight_time is None:
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
bounds = CMJBounds.FLIGHT_TIME
|
|
190
|
+
|
|
191
|
+
if not bounds.is_physically_possible(flight_time):
|
|
192
|
+
if flight_time < bounds.absolute_min:
|
|
193
|
+
result.add_error(
|
|
194
|
+
"flight_time",
|
|
195
|
+
f"Flight time {flight_time:.3f}s below frame rate resolution limit",
|
|
196
|
+
value=flight_time,
|
|
197
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
result.add_error(
|
|
201
|
+
"flight_time",
|
|
202
|
+
f"Flight time {flight_time:.3f}s exceeds elite human capability",
|
|
203
|
+
value=flight_time,
|
|
204
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
205
|
+
)
|
|
206
|
+
elif bounds.contains(flight_time, profile):
|
|
207
|
+
result.add_info(
|
|
208
|
+
"flight_time",
|
|
209
|
+
f"Flight time {flight_time:.3f}s within expected range for {profile.value}",
|
|
210
|
+
value=flight_time,
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
# Outside expected range but physically possible
|
|
214
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
215
|
+
result.add_warning(
|
|
216
|
+
"flight_time",
|
|
217
|
+
f"Flight time {flight_time:.3f}s outside typical range "
|
|
218
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
|
|
219
|
+
value=flight_time,
|
|
220
|
+
bounds=(expected_min, expected_max),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _check_jump_height(
|
|
224
|
+
self, metrics: dict, result: ValidationResult, profile: AthleteProfile
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Validate jump height."""
|
|
227
|
+
jump_height = metrics.get("jump_height")
|
|
228
|
+
if jump_height is None:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
bounds = CMJBounds.JUMP_HEIGHT
|
|
232
|
+
|
|
233
|
+
if not bounds.is_physically_possible(jump_height):
|
|
234
|
+
if jump_height < bounds.absolute_min:
|
|
235
|
+
result.add_error(
|
|
236
|
+
"jump_height",
|
|
237
|
+
f"Jump height {jump_height:.3f}m essentially no jump (noise)",
|
|
238
|
+
value=jump_height,
|
|
239
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
result.add_error(
|
|
243
|
+
"jump_height",
|
|
244
|
+
f"Jump height {jump_height:.3f}m exceeds human capability",
|
|
245
|
+
value=jump_height,
|
|
246
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
247
|
+
)
|
|
248
|
+
elif bounds.contains(jump_height, profile):
|
|
249
|
+
result.add_info(
|
|
250
|
+
"jump_height",
|
|
251
|
+
f"Jump height {jump_height:.3f}m within expected range for {profile.value}",
|
|
252
|
+
value=jump_height,
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
256
|
+
result.add_warning(
|
|
257
|
+
"jump_height",
|
|
258
|
+
f"Jump height {jump_height:.3f}m outside typical range "
|
|
259
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
|
|
260
|
+
value=jump_height,
|
|
261
|
+
bounds=(expected_min, expected_max),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _check_countermovement_depth(
|
|
265
|
+
self, metrics: dict, result: ValidationResult, profile: AthleteProfile
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Validate countermovement depth."""
|
|
268
|
+
depth = metrics.get("countermovement_depth")
|
|
269
|
+
if depth is None:
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
bounds = CMJBounds.COUNTERMOVEMENT_DEPTH
|
|
273
|
+
|
|
274
|
+
if not bounds.is_physically_possible(depth):
|
|
275
|
+
if depth < bounds.absolute_min:
|
|
276
|
+
result.add_error(
|
|
277
|
+
"countermovement_depth",
|
|
278
|
+
f"Countermovement depth {depth:.3f}m essentially no squat",
|
|
279
|
+
value=depth,
|
|
280
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
result.add_error(
|
|
284
|
+
"countermovement_depth",
|
|
285
|
+
f"Countermovement depth {depth:.3f}m exceeds physical limit",
|
|
286
|
+
value=depth,
|
|
287
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
288
|
+
)
|
|
289
|
+
elif bounds.contains(depth, profile):
|
|
290
|
+
result.add_info(
|
|
291
|
+
"countermovement_depth",
|
|
292
|
+
f"Countermovement depth {depth:.3f}m within expected range for {profile.value}",
|
|
293
|
+
value=depth,
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
297
|
+
result.add_warning(
|
|
298
|
+
"countermovement_depth",
|
|
299
|
+
f"Countermovement depth {depth:.3f}m outside typical range "
|
|
300
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
|
|
301
|
+
value=depth,
|
|
302
|
+
bounds=(expected_min, expected_max),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def _check_concentric_duration(
|
|
306
|
+
self, metrics: dict, result: ValidationResult, profile: AthleteProfile
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Validate concentric duration (contact time)."""
|
|
309
|
+
duration = metrics.get("concentric_duration")
|
|
310
|
+
if duration is None:
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
bounds = CMJBounds.CONCENTRIC_DURATION
|
|
314
|
+
|
|
315
|
+
if not bounds.is_physically_possible(duration):
|
|
316
|
+
if duration < bounds.absolute_min:
|
|
317
|
+
result.add_error(
|
|
318
|
+
"concentric_duration",
|
|
319
|
+
f"Concentric duration {duration:.3f}s likely phase detection error",
|
|
320
|
+
value=duration,
|
|
321
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
result.add_error(
|
|
325
|
+
"concentric_duration",
|
|
326
|
+
f"Concentric duration {duration:.3f}s likely includes standing phase",
|
|
327
|
+
value=duration,
|
|
328
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
329
|
+
)
|
|
330
|
+
elif bounds.contains(duration, profile):
|
|
331
|
+
result.add_info(
|
|
332
|
+
"concentric_duration",
|
|
333
|
+
f"Concentric duration {duration:.3f}s within expected range for {profile.value}",
|
|
334
|
+
value=duration,
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
338
|
+
result.add_warning(
|
|
339
|
+
"concentric_duration",
|
|
340
|
+
f"Concentric duration {duration:.3f}s outside typical range "
|
|
341
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
|
|
342
|
+
value=duration,
|
|
343
|
+
bounds=(expected_min, expected_max),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def _check_eccentric_duration(
|
|
347
|
+
self, metrics: dict, result: ValidationResult, profile: AthleteProfile
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Validate eccentric duration."""
|
|
350
|
+
duration = metrics.get("eccentric_duration")
|
|
351
|
+
if duration is None:
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
bounds = CMJBounds.ECCENTRIC_DURATION
|
|
355
|
+
|
|
356
|
+
if not bounds.is_physically_possible(duration):
|
|
357
|
+
result.add_error(
|
|
358
|
+
"eccentric_duration",
|
|
359
|
+
f"Eccentric duration {duration:.3f}s outside physical limits",
|
|
360
|
+
value=duration,
|
|
361
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
362
|
+
)
|
|
363
|
+
elif bounds.contains(duration, profile):
|
|
364
|
+
result.add_info(
|
|
365
|
+
"eccentric_duration",
|
|
366
|
+
f"Eccentric duration {duration:.3f}s within expected range for {profile.value}",
|
|
367
|
+
value=duration,
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
371
|
+
result.add_warning(
|
|
372
|
+
"eccentric_duration",
|
|
373
|
+
f"Eccentric duration {duration:.3f}s outside typical range "
|
|
374
|
+
f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
|
|
375
|
+
value=duration,
|
|
376
|
+
bounds=(expected_min, expected_max),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def _check_peak_velocities(
|
|
380
|
+
self, metrics: dict, result: ValidationResult, profile: AthleteProfile
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Validate peak eccentric and concentric velocities."""
|
|
383
|
+
# Eccentric
|
|
384
|
+
ecc_vel = metrics.get("peak_eccentric_velocity")
|
|
385
|
+
if ecc_vel is not None:
|
|
386
|
+
bounds = CMJBounds.PEAK_ECCENTRIC_VELOCITY
|
|
387
|
+
if not bounds.is_physically_possible(ecc_vel):
|
|
388
|
+
result.add_error(
|
|
389
|
+
"peak_eccentric_velocity",
|
|
390
|
+
f"Peak eccentric velocity {ecc_vel:.2f} m/s outside limits",
|
|
391
|
+
value=ecc_vel,
|
|
392
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
393
|
+
)
|
|
394
|
+
elif bounds.contains(ecc_vel, profile):
|
|
395
|
+
result.add_info(
|
|
396
|
+
"peak_eccentric_velocity",
|
|
397
|
+
f"Peak eccentric velocity {ecc_vel:.2f} m/s within range for {profile.value}",
|
|
398
|
+
value=ecc_vel,
|
|
399
|
+
)
|
|
400
|
+
else:
|
|
401
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
402
|
+
result.add_warning(
|
|
403
|
+
"peak_eccentric_velocity",
|
|
404
|
+
f"Peak eccentric velocity {ecc_vel:.2f} m/s outside typical range "
|
|
405
|
+
f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
|
|
406
|
+
value=ecc_vel,
|
|
407
|
+
bounds=(expected_min, expected_max),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Concentric
|
|
411
|
+
con_vel = metrics.get("peak_concentric_velocity")
|
|
412
|
+
if con_vel is not None:
|
|
413
|
+
bounds = CMJBounds.PEAK_CONCENTRIC_VELOCITY
|
|
414
|
+
if not bounds.is_physically_possible(con_vel):
|
|
415
|
+
if con_vel < bounds.absolute_min:
|
|
416
|
+
result.add_error(
|
|
417
|
+
"peak_concentric_velocity",
|
|
418
|
+
f"Peak concentric velocity {con_vel:.2f} m/s insufficient to leave ground",
|
|
419
|
+
value=con_vel,
|
|
420
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
421
|
+
)
|
|
422
|
+
else:
|
|
423
|
+
result.add_error(
|
|
424
|
+
"peak_concentric_velocity",
|
|
425
|
+
f"Peak concentric velocity {con_vel:.2f} m/s exceeds elite capability",
|
|
426
|
+
value=con_vel,
|
|
427
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
428
|
+
)
|
|
429
|
+
elif bounds.contains(con_vel, profile):
|
|
430
|
+
result.add_info(
|
|
431
|
+
"peak_concentric_velocity",
|
|
432
|
+
f"Peak concentric velocity {con_vel:.2f} m/s within range for {profile.value}",
|
|
433
|
+
value=con_vel,
|
|
434
|
+
)
|
|
435
|
+
else:
|
|
436
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
437
|
+
result.add_warning(
|
|
438
|
+
"peak_concentric_velocity",
|
|
439
|
+
f"Peak concentric velocity {con_vel:.2f} m/s outside typical range "
|
|
440
|
+
f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
|
|
441
|
+
value=con_vel,
|
|
442
|
+
bounds=(expected_min, expected_max),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def _check_flight_time_height_consistency(
|
|
446
|
+
self, metrics: dict, result: ValidationResult
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Verify jump height is consistent with flight time."""
|
|
449
|
+
flight_time = metrics.get("flight_time")
|
|
450
|
+
jump_height = metrics.get("jump_height")
|
|
451
|
+
|
|
452
|
+
if flight_time is None or jump_height is None:
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
# h = g * t^2 / 8
|
|
456
|
+
g = 9.81
|
|
457
|
+
expected_height = (g * flight_time**2) / 8
|
|
458
|
+
error_pct = abs(jump_height - expected_height) / expected_height
|
|
459
|
+
|
|
460
|
+
result.height_flight_time_consistency = error_pct
|
|
461
|
+
|
|
462
|
+
if error_pct > MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE:
|
|
463
|
+
result.add_error(
|
|
464
|
+
"height_flight_time_consistency",
|
|
465
|
+
f"Jump height {jump_height:.3f}m inconsistent with flight time {flight_time:.3f}s "
|
|
466
|
+
f"(expected {expected_height:.3f}m, error {error_pct*100:.1f}%)",
|
|
467
|
+
value=error_pct,
|
|
468
|
+
bounds=(0, MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE),
|
|
469
|
+
)
|
|
470
|
+
else:
|
|
471
|
+
result.add_info(
|
|
472
|
+
"height_flight_time_consistency",
|
|
473
|
+
f"Jump height and flight time consistent (error {error_pct*100:.1f}%)",
|
|
474
|
+
value=error_pct,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def _check_velocity_height_consistency(
|
|
478
|
+
self, metrics: dict, result: ValidationResult
|
|
479
|
+
) -> None:
|
|
480
|
+
"""Verify peak velocity is consistent with jump height."""
|
|
481
|
+
velocity = metrics.get("peak_concentric_velocity")
|
|
482
|
+
jump_height = metrics.get("jump_height")
|
|
483
|
+
|
|
484
|
+
if velocity is None or jump_height is None:
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
# h = v^2 / (2*g)
|
|
488
|
+
g = 9.81
|
|
489
|
+
expected_velocity = (2 * g * jump_height) ** 0.5
|
|
490
|
+
error_pct = abs(velocity - expected_velocity) / expected_velocity
|
|
491
|
+
|
|
492
|
+
result.velocity_height_consistency = error_pct
|
|
493
|
+
|
|
494
|
+
if error_pct > MetricConsistency.VELOCITY_HEIGHT_TOLERANCE:
|
|
495
|
+
error_msg = (
|
|
496
|
+
f"Peak velocity {velocity:.2f} m/s inconsistent with "
|
|
497
|
+
f"jump height {jump_height:.3f}m (expected {expected_velocity:.2f} "
|
|
498
|
+
f"m/s, error {error_pct*100:.1f}%)"
|
|
499
|
+
)
|
|
500
|
+
result.add_warning(
|
|
501
|
+
"velocity_height_consistency",
|
|
502
|
+
error_msg,
|
|
503
|
+
value=error_pct,
|
|
504
|
+
bounds=(0, MetricConsistency.VELOCITY_HEIGHT_TOLERANCE),
|
|
505
|
+
)
|
|
506
|
+
else:
|
|
507
|
+
result.add_info(
|
|
508
|
+
"velocity_height_consistency",
|
|
509
|
+
f"Peak velocity and jump height consistent (error {error_pct*100:.1f}%)",
|
|
510
|
+
value=error_pct,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def _check_rsi_validity(
|
|
514
|
+
self, metrics: dict, result: ValidationResult, profile: AthleteProfile
|
|
515
|
+
) -> None:
|
|
516
|
+
"""Validate Reactive Strength Index."""
|
|
517
|
+
flight_time = metrics.get("flight_time")
|
|
518
|
+
concentric_duration = metrics.get("concentric_duration")
|
|
519
|
+
|
|
520
|
+
if (
|
|
521
|
+
flight_time is None
|
|
522
|
+
or concentric_duration is None
|
|
523
|
+
or concentric_duration == 0
|
|
524
|
+
):
|
|
525
|
+
return
|
|
526
|
+
|
|
527
|
+
rsi = flight_time / concentric_duration
|
|
528
|
+
result.rsi = rsi
|
|
529
|
+
|
|
530
|
+
if not RSIBounds.is_valid(rsi):
|
|
531
|
+
if rsi < RSIBounds.MIN_VALID:
|
|
532
|
+
result.add_error(
|
|
533
|
+
"rsi",
|
|
534
|
+
f"RSI {rsi:.2f} below physiological minimum (likely error)",
|
|
535
|
+
value=rsi,
|
|
536
|
+
bounds=(RSIBounds.MIN_VALID, RSIBounds.MAX_VALID),
|
|
537
|
+
)
|
|
538
|
+
else:
|
|
539
|
+
result.add_error(
|
|
540
|
+
"rsi",
|
|
541
|
+
f"RSI {rsi:.2f} exceeds physiological maximum (likely error)",
|
|
542
|
+
value=rsi,
|
|
543
|
+
bounds=(RSIBounds.MIN_VALID, RSIBounds.MAX_VALID),
|
|
544
|
+
)
|
|
545
|
+
else:
|
|
546
|
+
expected_min, expected_max = RSIBounds.get_rsi_range(profile)
|
|
547
|
+
if expected_min <= rsi <= expected_max:
|
|
548
|
+
result.add_info(
|
|
549
|
+
"rsi",
|
|
550
|
+
f"RSI {rsi:.2f} within expected range [{expected_min:.2f}-{expected_max:.2f}] "
|
|
551
|
+
f"for {profile.value}",
|
|
552
|
+
value=rsi,
|
|
553
|
+
)
|
|
554
|
+
else:
|
|
555
|
+
result.add_warning(
|
|
556
|
+
"rsi",
|
|
557
|
+
f"RSI {rsi:.2f} outside typical range [{expected_min:.2f}-{expected_max:.2f}] "
|
|
558
|
+
f"for {profile.value}",
|
|
559
|
+
value=rsi,
|
|
560
|
+
bounds=(expected_min, expected_max),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
def _check_depth_height_ratio(
|
|
564
|
+
self, metrics: dict, result: ValidationResult
|
|
565
|
+
) -> None:
|
|
566
|
+
"""Check countermovement depth to jump height ratio."""
|
|
567
|
+
depth = metrics.get("countermovement_depth")
|
|
568
|
+
jump_height = metrics.get("jump_height")
|
|
569
|
+
|
|
570
|
+
if (
|
|
571
|
+
depth is None or jump_height is None or depth < 0.05
|
|
572
|
+
): # Skip if depth minimal
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
ratio = jump_height / depth
|
|
576
|
+
result.depth_height_ratio = ratio
|
|
577
|
+
|
|
578
|
+
if ratio < MetricConsistency.DEPTH_HEIGHT_RATIO_MIN:
|
|
579
|
+
result.add_warning(
|
|
580
|
+
"depth_height_ratio",
|
|
581
|
+
f"Jump height {ratio:.2f}x countermovement depth: "
|
|
582
|
+
f"May indicate incomplete squat or standing position detection error",
|
|
583
|
+
value=ratio,
|
|
584
|
+
bounds=(
|
|
585
|
+
MetricConsistency.DEPTH_HEIGHT_RATIO_MIN,
|
|
586
|
+
MetricConsistency.DEPTH_HEIGHT_RATIO_MAX,
|
|
587
|
+
),
|
|
588
|
+
)
|
|
589
|
+
elif ratio > MetricConsistency.DEPTH_HEIGHT_RATIO_MAX:
|
|
590
|
+
result.add_warning(
|
|
591
|
+
"depth_height_ratio",
|
|
592
|
+
f"Jump height only {ratio:.2f}x countermovement depth: "
|
|
593
|
+
f"Unusually inefficient (verify lowest point detection)",
|
|
594
|
+
value=ratio,
|
|
595
|
+
bounds=(
|
|
596
|
+
MetricConsistency.DEPTH_HEIGHT_RATIO_MIN,
|
|
597
|
+
MetricConsistency.DEPTH_HEIGHT_RATIO_MAX,
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
else:
|
|
601
|
+
result.add_info(
|
|
602
|
+
"depth_height_ratio",
|
|
603
|
+
f"Depth-to-height ratio {ratio:.2f} within expected range",
|
|
604
|
+
value=ratio,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def _check_contact_depth_ratio(
|
|
608
|
+
self, metrics: dict, result: ValidationResult
|
|
609
|
+
) -> None:
|
|
610
|
+
"""Check contact time to countermovement depth ratio."""
|
|
611
|
+
contact = metrics.get("concentric_duration")
|
|
612
|
+
depth = metrics.get("countermovement_depth")
|
|
613
|
+
|
|
614
|
+
if contact is None or depth is None or depth < 0.05:
|
|
615
|
+
return
|
|
616
|
+
|
|
617
|
+
ratio = contact / depth
|
|
618
|
+
result.contact_depth_ratio = ratio
|
|
619
|
+
|
|
620
|
+
if ratio < MetricConsistency.CONTACT_DEPTH_RATIO_MIN:
|
|
621
|
+
result.add_warning(
|
|
622
|
+
"contact_depth_ratio",
|
|
623
|
+
f"Contact time {ratio:.2f}s/m to depth ratio: Very fast for depth traversed",
|
|
624
|
+
value=ratio,
|
|
625
|
+
bounds=(
|
|
626
|
+
MetricConsistency.CONTACT_DEPTH_RATIO_MIN,
|
|
627
|
+
MetricConsistency.CONTACT_DEPTH_RATIO_MAX,
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
elif ratio > MetricConsistency.CONTACT_DEPTH_RATIO_MAX:
|
|
631
|
+
result.add_warning(
|
|
632
|
+
"contact_depth_ratio",
|
|
633
|
+
f"Contact time {ratio:.2f}s/m to depth ratio: Slow for depth traversed",
|
|
634
|
+
value=ratio,
|
|
635
|
+
bounds=(
|
|
636
|
+
MetricConsistency.CONTACT_DEPTH_RATIO_MIN,
|
|
637
|
+
MetricConsistency.CONTACT_DEPTH_RATIO_MAX,
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
else:
|
|
641
|
+
result.add_info(
|
|
642
|
+
"contact_depth_ratio",
|
|
643
|
+
f"Contact-depth ratio {ratio:.2f} s/m within expected range",
|
|
644
|
+
value=ratio,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
def _check_triple_extension(
|
|
648
|
+
self, metrics: dict, result: ValidationResult, profile: AthleteProfile
|
|
649
|
+
) -> None:
|
|
650
|
+
"""Validate triple extension angles."""
|
|
651
|
+
angles = metrics.get("triple_extension")
|
|
652
|
+
if angles is None:
|
|
653
|
+
return
|
|
654
|
+
|
|
655
|
+
hip = angles.get("hip_angle")
|
|
656
|
+
if hip is not None:
|
|
657
|
+
if not TripleExtensionBounds.hip_angle_valid(hip, profile):
|
|
658
|
+
result.add_warning(
|
|
659
|
+
"hip_angle",
|
|
660
|
+
f"Hip angle {hip:.1f}° outside expected range for {profile.value}",
|
|
661
|
+
value=hip,
|
|
662
|
+
)
|
|
663
|
+
else:
|
|
664
|
+
result.add_info(
|
|
665
|
+
"hip_angle",
|
|
666
|
+
f"Hip angle {hip:.1f}° within expected range for {profile.value}",
|
|
667
|
+
value=hip,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
knee = angles.get("knee_angle")
|
|
671
|
+
if knee is not None:
|
|
672
|
+
if not TripleExtensionBounds.knee_angle_valid(knee, profile):
|
|
673
|
+
result.add_warning(
|
|
674
|
+
"knee_angle",
|
|
675
|
+
f"Knee angle {knee:.1f}° outside expected range for {profile.value}",
|
|
676
|
+
value=knee,
|
|
677
|
+
)
|
|
678
|
+
else:
|
|
679
|
+
result.add_info(
|
|
680
|
+
"knee_angle",
|
|
681
|
+
f"Knee angle {knee:.1f}° within expected range for {profile.value}",
|
|
682
|
+
value=knee,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
ankle = angles.get("ankle_angle")
|
|
686
|
+
if ankle is not None:
|
|
687
|
+
if not TripleExtensionBounds.ankle_angle_valid(ankle, profile):
|
|
688
|
+
result.add_warning(
|
|
689
|
+
"ankle_angle",
|
|
690
|
+
f"Ankle angle {ankle:.1f}° outside expected range for {profile.value}",
|
|
691
|
+
value=ankle,
|
|
692
|
+
)
|
|
693
|
+
else:
|
|
694
|
+
result.add_info(
|
|
695
|
+
"ankle_angle",
|
|
696
|
+
f"Ankle angle {ankle:.1f}° within expected range for {profile.value}",
|
|
697
|
+
value=ankle,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
@staticmethod
|
|
701
|
+
def _get_profile_range(
|
|
702
|
+
profile: AthleteProfile, bounds: MetricBounds
|
|
703
|
+
) -> tuple[float, float]:
|
|
704
|
+
"""Get min/max bounds for specific profile."""
|
|
705
|
+
if profile == AthleteProfile.ELDERLY:
|
|
706
|
+
return (bounds.practical_min, bounds.recreational_max)
|
|
707
|
+
elif profile == AthleteProfile.UNTRAINED:
|
|
708
|
+
return (bounds.practical_min, bounds.recreational_max)
|
|
709
|
+
elif profile == AthleteProfile.RECREATIONAL:
|
|
710
|
+
return (bounds.recreational_min, bounds.recreational_max)
|
|
711
|
+
elif profile == AthleteProfile.TRAINED:
|
|
712
|
+
trained_min = (bounds.recreational_min + bounds.elite_min) / 2
|
|
713
|
+
trained_max = (bounds.recreational_max + bounds.elite_max) / 2
|
|
714
|
+
return (trained_min, trained_max)
|
|
715
|
+
elif profile == AthleteProfile.ELITE:
|
|
716
|
+
return (bounds.elite_min, bounds.elite_max)
|
|
717
|
+
return (bounds.absolute_min, bounds.absolute_max)
|