kinemotion 0.76.3__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kinemotion might be problematic. Click here for more details.

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