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.

Files changed (48) hide show
  1. kinemotion/__init__.py +31 -6
  2. kinemotion/api.py +39 -598
  3. kinemotion/cli.py +2 -0
  4. kinemotion/cmj/__init__.py +5 -0
  5. kinemotion/cmj/analysis.py +621 -0
  6. kinemotion/cmj/api.py +563 -0
  7. kinemotion/cmj/cli.py +324 -0
  8. kinemotion/cmj/debug_overlay.py +457 -0
  9. kinemotion/cmj/joint_angles.py +307 -0
  10. kinemotion/cmj/kinematics.py +360 -0
  11. kinemotion/cmj/metrics_validator.py +767 -0
  12. kinemotion/cmj/validation_bounds.py +341 -0
  13. kinemotion/core/__init__.py +28 -0
  14. kinemotion/core/auto_tuning.py +71 -37
  15. kinemotion/core/cli_utils.py +60 -0
  16. kinemotion/core/debug_overlay_utils.py +385 -0
  17. kinemotion/core/determinism.py +83 -0
  18. kinemotion/core/experimental.py +103 -0
  19. kinemotion/core/filtering.py +9 -6
  20. kinemotion/core/formatting.py +75 -0
  21. kinemotion/core/metadata.py +231 -0
  22. kinemotion/core/model_downloader.py +172 -0
  23. kinemotion/core/pipeline_utils.py +433 -0
  24. kinemotion/core/pose.py +298 -141
  25. kinemotion/core/pose_landmarks.py +67 -0
  26. kinemotion/core/quality.py +393 -0
  27. kinemotion/core/smoothing.py +250 -154
  28. kinemotion/core/timing.py +247 -0
  29. kinemotion/core/types.py +42 -0
  30. kinemotion/core/validation.py +201 -0
  31. kinemotion/core/video_io.py +135 -50
  32. kinemotion/dropjump/__init__.py +1 -1
  33. kinemotion/dropjump/analysis.py +367 -182
  34. kinemotion/dropjump/api.py +665 -0
  35. kinemotion/dropjump/cli.py +156 -466
  36. kinemotion/dropjump/debug_overlay.py +136 -206
  37. kinemotion/dropjump/kinematics.py +232 -255
  38. kinemotion/dropjump/metrics_validator.py +240 -0
  39. kinemotion/dropjump/validation_bounds.py +157 -0
  40. kinemotion/models/__init__.py +0 -0
  41. kinemotion/models/pose_landmarker_lite.task +0 -0
  42. kinemotion-0.67.0.dist-info/METADATA +726 -0
  43. kinemotion-0.67.0.dist-info/RECORD +47 -0
  44. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
  45. kinemotion-0.10.6.dist-info/METADATA +0 -561
  46. kinemotion-0.10.6.dist-info/RECORD +0 -20
  47. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
  48. {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)