kinemotion 0.10.6__py3-none-any.whl → 0.67.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 +31 -6
- kinemotion/api.py +39 -598
- kinemotion/cli.py +2 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +621 -0
- kinemotion/cmj/api.py +563 -0
- kinemotion/cmj/cli.py +324 -0
- kinemotion/cmj/debug_overlay.py +457 -0
- kinemotion/cmj/joint_angles.py +307 -0
- kinemotion/cmj/kinematics.py +360 -0
- kinemotion/cmj/metrics_validator.py +767 -0
- kinemotion/cmj/validation_bounds.py +341 -0
- kinemotion/core/__init__.py +28 -0
- kinemotion/core/auto_tuning.py +71 -37
- kinemotion/core/cli_utils.py +60 -0
- kinemotion/core/debug_overlay_utils.py +385 -0
- kinemotion/core/determinism.py +83 -0
- kinemotion/core/experimental.py +103 -0
- kinemotion/core/filtering.py +9 -6
- kinemotion/core/formatting.py +75 -0
- kinemotion/core/metadata.py +231 -0
- kinemotion/core/model_downloader.py +172 -0
- kinemotion/core/pipeline_utils.py +433 -0
- kinemotion/core/pose.py +298 -141
- kinemotion/core/pose_landmarks.py +67 -0
- kinemotion/core/quality.py +393 -0
- kinemotion/core/smoothing.py +250 -154
- kinemotion/core/timing.py +247 -0
- kinemotion/core/types.py +42 -0
- kinemotion/core/validation.py +201 -0
- kinemotion/core/video_io.py +135 -50
- kinemotion/dropjump/__init__.py +1 -1
- kinemotion/dropjump/analysis.py +367 -182
- kinemotion/dropjump/api.py +665 -0
- kinemotion/dropjump/cli.py +156 -466
- kinemotion/dropjump/debug_overlay.py +136 -206
- kinemotion/dropjump/kinematics.py +232 -255
- kinemotion/dropjump/metrics_validator.py +240 -0
- kinemotion/dropjump/validation_bounds.py +157 -0
- kinemotion/models/__init__.py +0 -0
- kinemotion/models/pose_landmarker_lite.task +0 -0
- kinemotion-0.67.0.dist-info/METADATA +726 -0
- kinemotion-0.67.0.dist-info/RECORD +47 -0
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
- kinemotion-0.10.6.dist-info/METADATA +0 -561
- kinemotion-0.10.6.dist-info/RECORD +0 -20
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,767 @@
|
|
|
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
|
|
11
|
+
|
|
12
|
+
from kinemotion.cmj.validation_bounds import (
|
|
13
|
+
CMJBounds,
|
|
14
|
+
MetricConsistency,
|
|
15
|
+
RSIBounds,
|
|
16
|
+
TripleExtensionBounds,
|
|
17
|
+
estimate_athlete_profile,
|
|
18
|
+
)
|
|
19
|
+
from kinemotion.core.types import MetricsDict
|
|
20
|
+
from kinemotion.core.validation import (
|
|
21
|
+
AthleteProfile,
|
|
22
|
+
MetricBounds,
|
|
23
|
+
MetricsValidator,
|
|
24
|
+
ValidationResult,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CMJValidationResult(ValidationResult):
|
|
30
|
+
"""CMJ-specific validation result."""
|
|
31
|
+
|
|
32
|
+
rsi: float | None = None
|
|
33
|
+
height_flight_time_consistency: float | None = None # % error
|
|
34
|
+
velocity_height_consistency: float | None = None # % error
|
|
35
|
+
depth_height_ratio: float | None = None
|
|
36
|
+
contact_depth_ratio: float | None = None
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
"""Convert validation result to JSON-serializable dictionary.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Dictionary with status, issues, and consistency metrics.
|
|
43
|
+
"""
|
|
44
|
+
return {
|
|
45
|
+
"status": self.status,
|
|
46
|
+
"issues": [
|
|
47
|
+
{
|
|
48
|
+
"severity": issue.severity.value,
|
|
49
|
+
"metric": issue.metric,
|
|
50
|
+
"message": issue.message,
|
|
51
|
+
"value": issue.value,
|
|
52
|
+
"bounds": issue.bounds,
|
|
53
|
+
}
|
|
54
|
+
for issue in self.issues
|
|
55
|
+
],
|
|
56
|
+
"athlete_profile": (self.athlete_profile.value if self.athlete_profile else None),
|
|
57
|
+
"rsi": self.rsi,
|
|
58
|
+
"height_flight_time_consistency_percent": (self.height_flight_time_consistency),
|
|
59
|
+
"velocity_height_consistency_percent": self.velocity_height_consistency,
|
|
60
|
+
"depth_height_ratio": self.depth_height_ratio,
|
|
61
|
+
"contact_depth_ratio": self.contact_depth_ratio,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CMJMetricsValidator(MetricsValidator):
|
|
66
|
+
"""Comprehensive CMJ metrics validator."""
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _get_metric_value(
|
|
70
|
+
data: dict, key_with_suffix: str, key_without_suffix: str
|
|
71
|
+
) -> float | None:
|
|
72
|
+
"""Get metric value, supporting both suffixed and legacy key formats.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
data: Dictionary containing metrics
|
|
76
|
+
key_with_suffix: Key with unit suffix (e.g., "flight_time_ms")
|
|
77
|
+
key_without_suffix: Legacy key without suffix (e.g., "flight_time")
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Metric value or None if not found
|
|
81
|
+
"""
|
|
82
|
+
return data.get(key_with_suffix) or data.get(key_without_suffix)
|
|
83
|
+
|
|
84
|
+
def validate(self, metrics: MetricsDict) -> CMJValidationResult:
|
|
85
|
+
"""Validate CMJ metrics comprehensively.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
metrics: Dictionary with CMJ metric values
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
CMJValidationResult with all issues and status
|
|
92
|
+
"""
|
|
93
|
+
result = CMJValidationResult()
|
|
94
|
+
|
|
95
|
+
# Estimate athlete profile if not provided
|
|
96
|
+
if self.assumed_profile:
|
|
97
|
+
result.athlete_profile = self.assumed_profile
|
|
98
|
+
else:
|
|
99
|
+
result.athlete_profile = estimate_athlete_profile(metrics)
|
|
100
|
+
|
|
101
|
+
profile = result.athlete_profile
|
|
102
|
+
|
|
103
|
+
# Extract metric values (handle nested "data" structure)
|
|
104
|
+
data = metrics.get("data", metrics) # Support both structures
|
|
105
|
+
|
|
106
|
+
# PRIMARY BOUNDS CHECKS
|
|
107
|
+
self._check_flight_time(data, result, profile)
|
|
108
|
+
self._check_jump_height(data, result, profile)
|
|
109
|
+
self._check_countermovement_depth(data, result, profile)
|
|
110
|
+
self._check_concentric_duration(data, result, profile)
|
|
111
|
+
self._check_eccentric_duration(data, result, profile)
|
|
112
|
+
self._check_peak_velocities(data, result, profile)
|
|
113
|
+
|
|
114
|
+
# CROSS-VALIDATION CHECKS
|
|
115
|
+
self._check_flight_time_height_consistency(data, result)
|
|
116
|
+
self._check_velocity_height_consistency(data, result)
|
|
117
|
+
self._check_rsi_validity(data, result, profile)
|
|
118
|
+
|
|
119
|
+
# CONSISTENCY CHECKS
|
|
120
|
+
self._check_depth_height_ratio(data, result)
|
|
121
|
+
self._check_contact_depth_ratio(data, result)
|
|
122
|
+
|
|
123
|
+
# TRIPLE EXTENSION ANGLES
|
|
124
|
+
self._check_triple_extension(data, result, profile)
|
|
125
|
+
|
|
126
|
+
# Finalize status
|
|
127
|
+
result.finalize_status()
|
|
128
|
+
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
def _check_flight_time(
|
|
132
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Validate flight time."""
|
|
135
|
+
flight_time_raw = self._get_metric_value(metrics, "flight_time_ms", "flight_time")
|
|
136
|
+
if flight_time_raw is None:
|
|
137
|
+
return
|
|
138
|
+
|
|
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
|
+
|
|
145
|
+
bounds = CMJBounds.FLIGHT_TIME
|
|
146
|
+
|
|
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
|
+
)
|
|
178
|
+
|
|
179
|
+
def _check_jump_height(
|
|
180
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Validate jump height."""
|
|
183
|
+
jump_height = self._get_metric_value(metrics, "jump_height_m", "jump_height")
|
|
184
|
+
if jump_height is None:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
bounds = CMJBounds.JUMP_HEIGHT
|
|
188
|
+
|
|
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
|
+
)
|
|
219
|
+
|
|
220
|
+
def _check_countermovement_depth(
|
|
221
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Validate countermovement depth."""
|
|
224
|
+
depth = self._get_metric_value(metrics, "countermovement_depth_m", "countermovement_depth")
|
|
225
|
+
if depth is None:
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
bounds = CMJBounds.COUNTERMOVEMENT_DEPTH
|
|
229
|
+
|
|
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
|
+
)
|
|
260
|
+
|
|
261
|
+
def _check_concentric_duration(
|
|
262
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Validate concentric duration (contact time)."""
|
|
265
|
+
duration_raw = self._get_metric_value(
|
|
266
|
+
metrics, "concentric_duration_ms", "concentric_duration"
|
|
267
|
+
)
|
|
268
|
+
if duration_raw is None:
|
|
269
|
+
return
|
|
270
|
+
|
|
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
|
+
|
|
278
|
+
bounds = CMJBounds.CONCENTRIC_DURATION
|
|
279
|
+
|
|
280
|
+
if not bounds.is_physically_possible(duration):
|
|
281
|
+
if duration < bounds.absolute_min:
|
|
282
|
+
result.add_error(
|
|
283
|
+
"concentric_duration",
|
|
284
|
+
f"Concentric duration {duration:.3f}s likely phase detection error",
|
|
285
|
+
value=duration,
|
|
286
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
result.add_error(
|
|
290
|
+
"concentric_duration",
|
|
291
|
+
f"Concentric duration {duration:.3f}s likely includes standing phase",
|
|
292
|
+
value=duration,
|
|
293
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
294
|
+
)
|
|
295
|
+
elif bounds.contains(duration, profile):
|
|
296
|
+
result.add_info(
|
|
297
|
+
"concentric_duration",
|
|
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}",
|
|
307
|
+
value=duration,
|
|
308
|
+
bounds=(expected_min, expected_max),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def _check_eccentric_duration(
|
|
312
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Validate eccentric duration."""
|
|
315
|
+
duration_raw = self._get_metric_value(
|
|
316
|
+
metrics, "eccentric_duration_ms", "eccentric_duration"
|
|
317
|
+
)
|
|
318
|
+
if duration_raw is None:
|
|
319
|
+
return
|
|
320
|
+
|
|
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
|
+
|
|
327
|
+
bounds = CMJBounds.ECCENTRIC_DURATION
|
|
328
|
+
|
|
329
|
+
if not bounds.is_physically_possible(duration):
|
|
330
|
+
result.add_error(
|
|
331
|
+
"eccentric_duration",
|
|
332
|
+
f"Eccentric duration {duration:.3f}s outside physical limits",
|
|
333
|
+
value=duration,
|
|
334
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
335
|
+
)
|
|
336
|
+
elif bounds.contains(duration, profile):
|
|
337
|
+
result.add_info(
|
|
338
|
+
"eccentric_duration",
|
|
339
|
+
f"Eccentric duration {duration:.3f}s within expected range for {profile.value}",
|
|
340
|
+
value=duration,
|
|
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
|
+
)
|
|
351
|
+
|
|
352
|
+
def _check_peak_velocities(
|
|
353
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Validate peak eccentric and concentric velocities."""
|
|
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
|
+
)
|
|
384
|
+
|
|
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,
|
|
411
|
+
)
|
|
412
|
+
else:
|
|
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),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def _check_flight_time_height_consistency(
|
|
423
|
+
self, metrics: MetricsDict, result: CMJValidationResult
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Verify jump height is consistent with flight time."""
|
|
426
|
+
flight_time_ms = metrics.get("flight_time_ms")
|
|
427
|
+
jump_height = metrics.get("jump_height_m")
|
|
428
|
+
|
|
429
|
+
if flight_time_ms is None or jump_height is None:
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
# Convert ms to seconds
|
|
433
|
+
flight_time = flight_time_ms / 1000.0
|
|
434
|
+
|
|
435
|
+
# Calculate expected height using kinematic formula: h = g*t²/8
|
|
436
|
+
g = 9.81
|
|
437
|
+
expected_height = (g * flight_time**2) / 8
|
|
438
|
+
error_pct = abs(jump_height - expected_height) / expected_height
|
|
439
|
+
|
|
440
|
+
result.height_flight_time_consistency = error_pct
|
|
441
|
+
|
|
442
|
+
if error_pct > MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE:
|
|
443
|
+
result.add_error(
|
|
444
|
+
"height_flight_time_consistency",
|
|
445
|
+
f"Jump height {jump_height:.3f}m inconsistent with flight "
|
|
446
|
+
f"time {flight_time:.3f}s (expected {expected_height:.3f}m, "
|
|
447
|
+
f"error {error_pct * 100:.1f}%)",
|
|
448
|
+
value=error_pct,
|
|
449
|
+
bounds=(0, MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE),
|
|
450
|
+
)
|
|
451
|
+
else:
|
|
452
|
+
result.add_info(
|
|
453
|
+
"height_flight_time_consistency",
|
|
454
|
+
f"Jump height and flight time consistent (error {error_pct * 100:.1f}%)",
|
|
455
|
+
value=error_pct,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def _check_velocity_height_consistency(
|
|
459
|
+
self, metrics: MetricsDict, result: CMJValidationResult
|
|
460
|
+
) -> None:
|
|
461
|
+
"""Verify peak velocity is consistent with jump height."""
|
|
462
|
+
velocity = metrics.get("peak_concentric_velocity_m_s")
|
|
463
|
+
jump_height = metrics.get("jump_height_m")
|
|
464
|
+
|
|
465
|
+
if velocity is None or jump_height is None:
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
# Calculate expected velocity using kinematic formula: v² = 2*g*h
|
|
469
|
+
g = 9.81
|
|
470
|
+
expected_velocity = (2 * g * jump_height) ** 0.5
|
|
471
|
+
error_pct = abs(velocity - expected_velocity) / expected_velocity
|
|
472
|
+
|
|
473
|
+
result.velocity_height_consistency = error_pct
|
|
474
|
+
|
|
475
|
+
if error_pct > MetricConsistency.VELOCITY_HEIGHT_TOLERANCE:
|
|
476
|
+
error_msg = (
|
|
477
|
+
f"Peak velocity {velocity:.2f} m/s inconsistent with "
|
|
478
|
+
f"jump height {jump_height:.3f}m (expected {expected_velocity:.2f} "
|
|
479
|
+
f"m/s, error {error_pct * 100:.1f}%)"
|
|
480
|
+
)
|
|
481
|
+
result.add_warning(
|
|
482
|
+
"velocity_height_consistency",
|
|
483
|
+
error_msg,
|
|
484
|
+
value=error_pct,
|
|
485
|
+
bounds=(0, MetricConsistency.VELOCITY_HEIGHT_TOLERANCE),
|
|
486
|
+
)
|
|
487
|
+
else:
|
|
488
|
+
result.add_info(
|
|
489
|
+
"velocity_height_consistency",
|
|
490
|
+
f"Peak velocity and jump height consistent (error {error_pct * 100:.1f}%)",
|
|
491
|
+
value=error_pct,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def _check_rsi_validity(
|
|
495
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
496
|
+
) -> None:
|
|
497
|
+
"""Validate Reactive Strength Index."""
|
|
498
|
+
flight_time_raw = self._get_metric_value(metrics, "flight_time_ms", "flight_time")
|
|
499
|
+
concentric_duration_raw = self._get_metric_value(
|
|
500
|
+
metrics, "concentric_duration_ms", "concentric_duration"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
if (
|
|
504
|
+
flight_time_raw is None
|
|
505
|
+
or concentric_duration_raw is None
|
|
506
|
+
or concentric_duration_raw == 0
|
|
507
|
+
):
|
|
508
|
+
return
|
|
509
|
+
|
|
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
|
|
520
|
+
|
|
521
|
+
rsi = flight_time / concentric_duration
|
|
522
|
+
result.rsi = rsi
|
|
523
|
+
|
|
524
|
+
if not RSIBounds.is_valid(rsi):
|
|
525
|
+
if rsi < RSIBounds.MIN_VALID:
|
|
526
|
+
result.add_error(
|
|
527
|
+
"rsi",
|
|
528
|
+
f"RSI {rsi:.2f} below physiological minimum (likely error)",
|
|
529
|
+
value=rsi,
|
|
530
|
+
bounds=(RSIBounds.MIN_VALID, RSIBounds.MAX_VALID),
|
|
531
|
+
)
|
|
532
|
+
else:
|
|
533
|
+
result.add_error(
|
|
534
|
+
"rsi",
|
|
535
|
+
f"RSI {rsi:.2f} exceeds physiological maximum (likely error)",
|
|
536
|
+
value=rsi,
|
|
537
|
+
bounds=(RSIBounds.MIN_VALID, RSIBounds.MAX_VALID),
|
|
538
|
+
)
|
|
539
|
+
else:
|
|
540
|
+
expected_min, expected_max = RSIBounds.get_rsi_range(profile)
|
|
541
|
+
if expected_min <= rsi <= expected_max:
|
|
542
|
+
result.add_info(
|
|
543
|
+
"rsi",
|
|
544
|
+
f"RSI {rsi:.2f} within expected range "
|
|
545
|
+
f"[{expected_min:.2f}-{expected_max:.2f}] "
|
|
546
|
+
f"for {profile.value}",
|
|
547
|
+
value=rsi,
|
|
548
|
+
)
|
|
549
|
+
else:
|
|
550
|
+
result.add_warning(
|
|
551
|
+
"rsi",
|
|
552
|
+
f"RSI {rsi:.2f} outside typical range "
|
|
553
|
+
f"[{expected_min:.2f}-{expected_max:.2f}] "
|
|
554
|
+
f"for {profile.value}",
|
|
555
|
+
value=rsi,
|
|
556
|
+
bounds=(expected_min, expected_max),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
def _check_depth_height_ratio(self, metrics: MetricsDict, result: CMJValidationResult) -> None:
|
|
560
|
+
"""Check countermovement depth to jump height ratio."""
|
|
561
|
+
depth = metrics.get("countermovement_depth_m")
|
|
562
|
+
jump_height = metrics.get("jump_height_m")
|
|
563
|
+
|
|
564
|
+
if depth is None or jump_height is None or depth < 0.05: # Skip if depth minimal
|
|
565
|
+
return
|
|
566
|
+
|
|
567
|
+
ratio = jump_height / depth
|
|
568
|
+
result.depth_height_ratio = ratio
|
|
569
|
+
|
|
570
|
+
if ratio < MetricConsistency.DEPTH_HEIGHT_RATIO_MIN:
|
|
571
|
+
result.add_warning(
|
|
572
|
+
"depth_height_ratio",
|
|
573
|
+
f"Jump height {ratio:.2f}x countermovement depth: "
|
|
574
|
+
f"May indicate incomplete squat or standing position detection error",
|
|
575
|
+
value=ratio,
|
|
576
|
+
bounds=(
|
|
577
|
+
MetricConsistency.DEPTH_HEIGHT_RATIO_MIN,
|
|
578
|
+
MetricConsistency.DEPTH_HEIGHT_RATIO_MAX,
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
elif ratio > MetricConsistency.DEPTH_HEIGHT_RATIO_MAX:
|
|
582
|
+
result.add_warning(
|
|
583
|
+
"depth_height_ratio",
|
|
584
|
+
f"Jump height only {ratio:.2f}x countermovement depth: "
|
|
585
|
+
f"Unusually inefficient (verify lowest point detection)",
|
|
586
|
+
value=ratio,
|
|
587
|
+
bounds=(
|
|
588
|
+
MetricConsistency.DEPTH_HEIGHT_RATIO_MIN,
|
|
589
|
+
MetricConsistency.DEPTH_HEIGHT_RATIO_MAX,
|
|
590
|
+
),
|
|
591
|
+
)
|
|
592
|
+
else:
|
|
593
|
+
result.add_info(
|
|
594
|
+
"depth_height_ratio",
|
|
595
|
+
f"Depth-to-height ratio {ratio:.2f} within expected range",
|
|
596
|
+
value=ratio,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
def _check_contact_depth_ratio(
|
|
600
|
+
self, metrics: MetricsDict, result: CMJValidationResult
|
|
601
|
+
) -> None:
|
|
602
|
+
"""Check contact time to countermovement depth ratio."""
|
|
603
|
+
contact_ms = metrics.get("concentric_duration_ms")
|
|
604
|
+
depth = metrics.get("countermovement_depth_m")
|
|
605
|
+
|
|
606
|
+
if contact_ms is None or depth is None or depth < 0.05:
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
# Convert ms to seconds for ratio calculation
|
|
610
|
+
contact = contact_ms / 1000.0
|
|
611
|
+
ratio = contact / depth
|
|
612
|
+
result.contact_depth_ratio = ratio
|
|
613
|
+
|
|
614
|
+
if ratio < MetricConsistency.CONTACT_DEPTH_RATIO_MIN:
|
|
615
|
+
result.add_warning(
|
|
616
|
+
"contact_depth_ratio",
|
|
617
|
+
f"Contact time {ratio:.2f}s/m to depth ratio: Very fast for depth traversed",
|
|
618
|
+
value=ratio,
|
|
619
|
+
bounds=(
|
|
620
|
+
MetricConsistency.CONTACT_DEPTH_RATIO_MIN,
|
|
621
|
+
MetricConsistency.CONTACT_DEPTH_RATIO_MAX,
|
|
622
|
+
),
|
|
623
|
+
)
|
|
624
|
+
elif ratio > MetricConsistency.CONTACT_DEPTH_RATIO_MAX:
|
|
625
|
+
result.add_warning(
|
|
626
|
+
"contact_depth_ratio",
|
|
627
|
+
f"Contact time {ratio:.2f}s/m to depth ratio: Slow for depth traversed",
|
|
628
|
+
value=ratio,
|
|
629
|
+
bounds=(
|
|
630
|
+
MetricConsistency.CONTACT_DEPTH_RATIO_MIN,
|
|
631
|
+
MetricConsistency.CONTACT_DEPTH_RATIO_MAX,
|
|
632
|
+
),
|
|
633
|
+
)
|
|
634
|
+
else:
|
|
635
|
+
result.add_info(
|
|
636
|
+
"contact_depth_ratio",
|
|
637
|
+
f"Contact-depth ratio {ratio:.2f} s/m within expected range",
|
|
638
|
+
value=ratio,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
def _check_triple_extension(
|
|
642
|
+
self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
|
|
643
|
+
) -> None:
|
|
644
|
+
"""Validate triple extension angles."""
|
|
645
|
+
angles = metrics.get("triple_extension")
|
|
646
|
+
if angles is None:
|
|
647
|
+
return
|
|
648
|
+
|
|
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
|
+
)
|
|
663
|
+
|
|
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
|
+
)
|
|
678
|
+
|
|
679
|
+
ankle = angles.get("ankle_angle")
|
|
680
|
+
if ankle is not None:
|
|
681
|
+
if not TripleExtensionBounds.ankle_angle_valid(ankle, profile):
|
|
682
|
+
result.add_warning(
|
|
683
|
+
"ankle_angle",
|
|
684
|
+
f"Ankle angle {ankle:.1f}° outside expected range for {profile.value}",
|
|
685
|
+
value=ankle,
|
|
686
|
+
)
|
|
687
|
+
else:
|
|
688
|
+
result.add_info(
|
|
689
|
+
"ankle_angle",
|
|
690
|
+
f"Ankle angle {ankle:.1f}° within expected range for {profile.value}",
|
|
691
|
+
value=ankle,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
# Detect joint compensation patterns
|
|
695
|
+
self._check_joint_compensation_pattern(angles, result, profile)
|
|
696
|
+
|
|
697
|
+
def _check_joint_compensation_pattern(
|
|
698
|
+
self, angles: dict, result: CMJValidationResult, profile: AthleteProfile
|
|
699
|
+
) -> None:
|
|
700
|
+
"""Detect compensatory joint patterns in triple extension.
|
|
701
|
+
|
|
702
|
+
When one joint cannot achieve full extension, others may compensate.
|
|
703
|
+
Example: Limited hip extension (160°) with excessive knee extension (185°+)
|
|
704
|
+
suggests compensation rather than balanced movement quality.
|
|
705
|
+
|
|
706
|
+
This is a biomechanical quality indicator, not an error.
|
|
707
|
+
"""
|
|
708
|
+
hip = angles.get("hip_angle")
|
|
709
|
+
knee = angles.get("knee_angle")
|
|
710
|
+
ankle = angles.get("ankle_angle")
|
|
711
|
+
|
|
712
|
+
if hip is None or knee is None or ankle is None:
|
|
713
|
+
return # Need all three to detect patterns
|
|
714
|
+
|
|
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:
|
|
729
|
+
return
|
|
730
|
+
|
|
731
|
+
# Count how many joints are near their boundaries
|
|
732
|
+
joints_at_boundary = 0
|
|
733
|
+
boundary_threshold = 3.0 # degrees from limit
|
|
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
|
|
741
|
+
|
|
742
|
+
# If 2+ joints at boundaries, likely compensation pattern
|
|
743
|
+
if joints_at_boundary >= 2:
|
|
744
|
+
result.add_info(
|
|
745
|
+
"joint_compensation",
|
|
746
|
+
f"Multiple joints near extension limits (hip={hip:.0f}°, "
|
|
747
|
+
f"knee={knee:.0f}°, ankle={ankle:.0f}°). "
|
|
748
|
+
f"May indicate compensatory movement pattern.",
|
|
749
|
+
value=float(joints_at_boundary),
|
|
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)
|