kinemotion 0.76.2__py3-none-any.whl → 1.0.0__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (51) 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 +293 -184
  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-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/METADATA +26 -75
  33. kinemotion-1.0.0.dist-info/RECORD +49 -0
  34. kinemotion/core/overlay_constants.py +0 -61
  35. kinemotion/core/video_analysis_base.py +0 -132
  36. kinemotion/countermovement_jump/debug_overlay.py +0 -325
  37. kinemotion/drop_jump/debug_overlay.py +0 -241
  38. kinemotion/squat_jump/__init__.py +0 -5
  39. kinemotion/squat_jump/analysis.py +0 -377
  40. kinemotion/squat_jump/api.py +0 -610
  41. kinemotion/squat_jump/cli.py +0 -309
  42. kinemotion/squat_jump/debug_overlay.py +0 -163
  43. kinemotion/squat_jump/kinematics.py +0 -342
  44. kinemotion/squat_jump/metrics_validator.py +0 -438
  45. kinemotion/squat_jump/validation_bounds.py +0 -221
  46. kinemotion-0.76.2.dist-info/RECORD +0 -59
  47. /kinemotion/{countermovement_jump → cmj}/__init__.py +0 -0
  48. /kinemotion/{countermovement_jump → cmj}/kinematics.py +0 -0
  49. {kinemotion-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/WHEEL +0 -0
  50. {kinemotion-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/entry_points.txt +0 -0
  51. {kinemotion-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -9,6 +9,13 @@ of metric issues.
9
9
 
10
10
  from dataclasses import dataclass
11
11
 
12
+ from kinemotion.cmj.validation_bounds import (
13
+ CMJBounds,
14
+ MetricConsistency,
15
+ RSIBounds,
16
+ TripleExtensionBounds,
17
+ estimate_athlete_profile,
18
+ )
12
19
  from kinemotion.core.types import MetricsDict
13
20
  from kinemotion.core.validation import (
14
21
  AthleteProfile,
@@ -16,13 +23,6 @@ from kinemotion.core.validation import (
16
23
  MetricsValidator,
17
24
  ValidationResult,
18
25
  )
19
- from kinemotion.countermovement_jump.validation_bounds import (
20
- CMJBounds,
21
- MetricConsistency,
22
- RSIBounds,
23
- TripleExtensionBounds,
24
- estimate_athlete_profile,
25
- )
26
26
 
27
27
 
28
28
  @dataclass
@@ -81,23 +81,6 @@ class CMJMetricsValidator(MetricsValidator):
81
81
  """
82
82
  return data.get(key_with_suffix) or data.get(key_without_suffix)
83
83
 
84
- @staticmethod
85
- def _convert_raw_duration_to_seconds(value_raw: float) -> float:
86
- """Convert raw duration value to seconds.
87
-
88
- Handles legacy values that may be in seconds (<10) vs milliseconds (>10).
89
- This heuristic works because no CMJ duration metric is between 10ms and 10s.
90
-
91
- Args:
92
- value_raw: Raw duration value (may be seconds or milliseconds)
93
-
94
- Returns:
95
- Duration in seconds
96
- """
97
- if value_raw < 10: # Likely in seconds
98
- return value_raw
99
- return value_raw / 1000.0
100
-
101
84
  def validate(self, metrics: MetricsDict) -> CMJValidationResult:
102
85
  """Validate CMJ metrics comprehensively.
103
86
 
@@ -124,8 +107,8 @@ class CMJMetricsValidator(MetricsValidator):
124
107
  self._check_flight_time(data, result, profile)
125
108
  self._check_jump_height(data, result, profile)
126
109
  self._check_countermovement_depth(data, result, profile)
127
- self._check_concentric_duration(data, result)
128
- self._check_eccentric_duration(data, result)
110
+ self._check_concentric_duration(data, result, profile)
111
+ self._check_eccentric_duration(data, result, profile)
129
112
  self._check_peak_velocities(data, result, profile)
130
113
 
131
114
  # CROSS-VALIDATION CHECKS
@@ -153,23 +136,45 @@ class CMJMetricsValidator(MetricsValidator):
153
136
  if flight_time_raw is None:
154
137
  return
155
138
 
156
- 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):
@@ -247,16 +292,25 @@ class CMJMetricsValidator(MetricsValidator):
247
292
  value=duration,
248
293
  bounds=(bounds.absolute_min, bounds.absolute_max),
249
294
  )
250
- else:
251
- # NOTE: Downgraded from WARNING to INFO - standing end detection has
252
- # ~117ms offset causing misleading warnings. See issue #16.
295
+ elif bounds.contains(duration, profile):
253
296
  result.add_info(
254
297
  "concentric_duration",
255
- f"Concentric duration {duration:.3f}s",
298
+ f"Concentric duration {duration:.3f}s within expected range for {profile.value}",
299
+ value=duration,
300
+ )
301
+ else:
302
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
303
+ result.add_warning(
304
+ "concentric_duration",
305
+ f"Concentric duration {duration:.3f}s outside typical range "
306
+ f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
256
307
  value=duration,
308
+ bounds=(expected_min, expected_max),
257
309
  )
258
310
 
259
- def _check_eccentric_duration(self, metrics: MetricsDict, result: CMJValidationResult) -> None:
311
+ def _check_eccentric_duration(
312
+ self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
313
+ ) -> None:
260
314
  """Validate eccentric duration."""
261
315
  duration_raw = self._get_metric_value(
262
316
  metrics, "eccentric_duration_ms", "eccentric_duration"
@@ -264,7 +318,12 @@ class CMJMetricsValidator(MetricsValidator):
264
318
  if duration_raw is None:
265
319
  return
266
320
 
267
- duration = self._convert_raw_duration_to_seconds(duration_raw)
321
+ # If value is in seconds (legacy), use as-is; if in ms, convert
322
+ if duration_raw < 10: # Likely in seconds
323
+ duration = duration_raw
324
+ else: # In milliseconds
325
+ duration = duration_raw / 1000.0
326
+
268
327
  bounds = CMJBounds.ECCENTRIC_DURATION
269
328
 
270
329
  if not bounds.is_physically_possible(duration):
@@ -274,87 +333,91 @@ class CMJMetricsValidator(MetricsValidator):
274
333
  value=duration,
275
334
  bounds=(bounds.absolute_min, bounds.absolute_max),
276
335
  )
277
- else:
278
- # NOTE: Downgraded from WARNING to INFO - standing end detection has
279
- # ~117ms offset causing misleading warnings. See issue #16.
336
+ elif bounds.contains(duration, profile):
280
337
  result.add_info(
281
338
  "eccentric_duration",
282
- f"Eccentric duration {duration:.3f}s",
339
+ f"Eccentric duration {duration:.3f}s within expected range for {profile.value}",
283
340
  value=duration,
284
341
  )
342
+ else:
343
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
344
+ result.add_warning(
345
+ "eccentric_duration",
346
+ f"Eccentric duration {duration:.3f}s outside typical range "
347
+ f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
348
+ value=duration,
349
+ bounds=(expected_min, expected_max),
350
+ )
285
351
 
286
352
  def _check_peak_velocities(
287
353
  self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
288
354
  ) -> None:
289
355
  """Validate peak eccentric and concentric velocities."""
290
- 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
- )
356
+ # Eccentric
357
+ ecc_vel = self._get_metric_value(
358
+ metrics, "peak_eccentric_velocity_m_s", "peak_eccentric_velocity"
359
+ )
360
+ if ecc_vel is not None:
361
+ bounds = CMJBounds.PEAK_ECCENTRIC_VELOCITY
362
+ if not bounds.is_physically_possible(ecc_vel):
363
+ result.add_error(
364
+ "peak_eccentric_velocity",
365
+ f"Peak eccentric velocity {ecc_vel:.2f} m/s outside limits",
366
+ value=ecc_vel,
367
+ bounds=(bounds.absolute_min, bounds.absolute_max),
368
+ )
369
+ elif bounds.contains(ecc_vel, profile):
370
+ result.add_info(
371
+ "peak_eccentric_velocity",
372
+ f"Peak eccentric velocity {ecc_vel:.2f} m/s within range for {profile.value}",
373
+ value=ecc_vel,
374
+ )
375
+ else:
376
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
377
+ result.add_warning(
378
+ "peak_eccentric_velocity",
379
+ f"Peak eccentric velocity {ecc_vel:.2f} m/s outside typical range "
380
+ f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
381
+ value=ecc_vel,
382
+ bounds=(expected_min, expected_max),
383
+ )
313
384
 
314
- 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}"
385
+ # Concentric
386
+ con_vel = self._get_metric_value(
387
+ metrics, "peak_concentric_velocity_m_s", "peak_concentric_velocity"
388
+ )
389
+ if con_vel is not None:
390
+ bounds = CMJBounds.PEAK_CONCENTRIC_VELOCITY
391
+ if not bounds.is_physically_possible(con_vel):
392
+ if con_vel < bounds.absolute_min:
393
+ result.add_error(
394
+ "peak_concentric_velocity",
395
+ f"Peak concentric velocity {con_vel:.2f} m/s insufficient to leave ground",
396
+ value=con_vel,
397
+ bounds=(bounds.absolute_min, bounds.absolute_max),
398
+ )
399
+ else:
400
+ result.add_error(
401
+ "peak_concentric_velocity",
402
+ f"Peak concentric velocity {con_vel:.2f} m/s exceeds elite capability",
403
+ value=con_vel,
404
+ bounds=(bounds.absolute_min, bounds.absolute_max),
405
+ )
406
+ elif bounds.contains(con_vel, profile):
407
+ result.add_info(
408
+ "peak_concentric_velocity",
409
+ f"Peak concentric velocity {con_vel:.2f} m/s within range for {profile.value}",
410
+ value=con_vel,
328
411
  )
329
412
  else:
330
- error_msg = (
331
- f"Peak {name.replace('peak_', '')} velocity {velocity:.2f} m/s outside limits"
413
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
414
+ result.add_warning(
415
+ "peak_concentric_velocity",
416
+ f"Peak concentric velocity {con_vel:.2f} m/s outside typical range "
417
+ f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
418
+ value=con_vel,
419
+ bounds=(expected_min, expected_max),
332
420
  )
333
- result.add_error(
334
- name,
335
- error_msg,
336
- value=velocity,
337
- bounds=(bounds.absolute_min, bounds.absolute_max),
338
- )
339
- elif bounds.contains(velocity, profile):
340
- velocity_type = name.replace("peak_", "").replace("_", " ")
341
- result.add_info(
342
- name,
343
- f"Peak {velocity_type} velocity {velocity:.2f} m/s "
344
- f"within range for {profile.value}",
345
- value=velocity,
346
- )
347
- else:
348
- expected_min, expected_max = self._get_profile_range(profile, bounds)
349
- velocity_type = name.replace("peak_", "").replace("_", " ")
350
- result.add_warning(
351
- name,
352
- f"Peak {velocity_type} velocity {velocity:.2f} m/s "
353
- f"outside typical range [{expected_min:.2f}-{expected_max:.2f}] "
354
- f"for {profile.value}",
355
- value=velocity,
356
- bounds=(expected_min, expected_max),
357
- )
358
421
 
359
422
  def _check_flight_time_height_consistency(
360
423
  self, metrics: MetricsDict, result: CMJValidationResult
@@ -444,8 +507,16 @@ class CMJMetricsValidator(MetricsValidator):
444
507
  ):
445
508
  return
446
509
 
447
- flight_time = self._convert_raw_duration_to_seconds(flight_time_raw)
448
- concentric_duration = self._convert_raw_duration_to_seconds(concentric_duration_raw)
510
+ # Convert to seconds if needed
511
+ if flight_time_raw < 10: # Likely in seconds
512
+ flight_time = flight_time_raw
513
+ else: # In milliseconds
514
+ flight_time = flight_time_raw / 1000.0
515
+
516
+ if concentric_duration_raw < 10: # Likely in seconds
517
+ concentric_duration = concentric_duration_raw
518
+ else: # In milliseconds
519
+ concentric_duration = concentric_duration_raw / 1000.0
449
520
 
450
521
  rsi = flight_time / concentric_duration
451
522
  result.rsi = rsi
@@ -575,28 +646,49 @@ class CMJMetricsValidator(MetricsValidator):
575
646
  if angles is None:
576
647
  return
577
648
 
578
- 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
- ]
649
+ hip = angles.get("hip_angle")
650
+ if hip is not None:
651
+ if not TripleExtensionBounds.hip_angle_valid(hip, profile):
652
+ result.add_warning(
653
+ "hip_angle",
654
+ f"Hip angle {hip:.1f}° outside expected range for {profile.value}",
655
+ value=hip,
656
+ )
657
+ else:
658
+ result.add_info(
659
+ "hip_angle",
660
+ f"Hip angle {hip:.1f}° within expected range for {profile.value}",
661
+ value=hip,
662
+ )
583
663
 
584
- for metric_name, validator, joint_name in joint_definitions:
585
- angle = angles.get(metric_name)
586
- if angle is None:
587
- continue
664
+ knee = angles.get("knee_angle")
665
+ if knee is not None:
666
+ if not TripleExtensionBounds.knee_angle_valid(knee, profile):
667
+ result.add_warning(
668
+ "knee_angle",
669
+ f"Knee angle {knee:.1f}° outside expected range for {profile.value}",
670
+ value=knee,
671
+ )
672
+ else:
673
+ result.add_info(
674
+ "knee_angle",
675
+ f"Knee angle {knee:.1f}° within expected range for {profile.value}",
676
+ value=knee,
677
+ )
588
678
 
589
- if not validator(angle, profile):
679
+ ankle = angles.get("ankle_angle")
680
+ if ankle is not None:
681
+ if not TripleExtensionBounds.ankle_angle_valid(ankle, profile):
590
682
  result.add_warning(
591
- metric_name,
592
- f"{joint_name} angle {angle:.1f}° outside expected range for {profile.value}",
593
- value=angle,
683
+ "ankle_angle",
684
+ f"Ankle angle {ankle:.1f}° outside expected range for {profile.value}",
685
+ value=ankle,
594
686
  )
595
687
  else:
596
688
  result.add_info(
597
- metric_name,
598
- f"{joint_name} angle {angle:.1f}° within expected range for {profile.value}",
599
- value=angle,
689
+ "ankle_angle",
690
+ f"Ankle angle {ankle:.1f}° within expected range for {profile.value}",
691
+ value=ankle,
600
692
  )
601
693
 
602
694
  # Detect joint compensation patterns
@@ -620,32 +712,32 @@ class CMJMetricsValidator(MetricsValidator):
620
712
  if hip is None or knee is None or ankle is None:
621
713
  return # Need all three to detect patterns
622
714
 
623
- # 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:
715
+ # Get profile-specific bounds
716
+ if profile == AthleteProfile.ELDERLY:
717
+ hip_min, hip_max = 150, 175
718
+ knee_min, knee_max = 155, 175
719
+ ankle_min, ankle_max = 100, 125
720
+ elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
721
+ hip_min, hip_max = 160, 180
722
+ knee_min, knee_max = 165, 182
723
+ ankle_min, ankle_max = 110, 140
724
+ elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
725
+ hip_min, hip_max = 170, 185
726
+ knee_min, knee_max = 173, 190
727
+ ankle_min, ankle_max = 125, 155
728
+ else:
634
729
  return
635
730
 
636
- hip_min, hip_max, knee_min, knee_max, ankle_min, ankle_max = bounds_tuple
637
-
638
- # Count joints at boundaries
731
+ # Count how many joints are near their boundaries
732
+ joints_at_boundary = 0
639
733
  boundary_threshold = 3.0 # degrees from limit
640
- 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
- )
734
+
735
+ if hip <= hip_min + boundary_threshold or hip >= hip_max - boundary_threshold:
736
+ joints_at_boundary += 1
737
+ if knee <= knee_min + boundary_threshold or knee >= knee_max - boundary_threshold:
738
+ joints_at_boundary += 1
739
+ if ankle <= ankle_min + boundary_threshold or ankle >= ankle_max - boundary_threshold:
740
+ joints_at_boundary += 1
649
741
 
650
742
  # If 2+ joints at boundaries, likely compensation pattern
651
743
  if joints_at_boundary >= 2:
@@ -656,3 +748,20 @@ class CMJMetricsValidator(MetricsValidator):
656
748
  f"May indicate compensatory movement pattern.",
657
749
  value=float(joints_at_boundary),
658
750
  )
751
+
752
+ @staticmethod
753
+ def _get_profile_range(profile: AthleteProfile, bounds: MetricBounds) -> tuple[float, float]:
754
+ """Get min/max bounds for specific profile."""
755
+ if profile == AthleteProfile.ELDERLY:
756
+ return (bounds.practical_min, bounds.recreational_max)
757
+ elif profile == AthleteProfile.UNTRAINED:
758
+ return (bounds.practical_min, bounds.recreational_max)
759
+ elif profile == AthleteProfile.RECREATIONAL:
760
+ return (bounds.recreational_min, bounds.recreational_max)
761
+ elif profile == AthleteProfile.TRAINED:
762
+ trained_min = (bounds.recreational_min + bounds.elite_min) / 2
763
+ trained_max = (bounds.recreational_max + bounds.elite_max) / 2
764
+ return (trained_min, trained_max)
765
+ elif profile == AthleteProfile.ELITE:
766
+ return (bounds.elite_min, bounds.elite_max)
767
+ return (bounds.absolute_min, bounds.absolute_max)
@@ -170,6 +170,17 @@ class TripleExtensionBounds:
170
170
  return 125 <= angle <= 155
171
171
  return True
172
172
 
173
+ # TRUNK TILT (forward lean from vertical, degrees)
174
+ @staticmethod
175
+ def trunk_tilt_valid(angle: float | None, profile: AthleteProfile) -> bool:
176
+ """Check if trunk tilt is valid for profile."""
177
+ if angle is None:
178
+ return True
179
+ if angle < -15 or angle > 60:
180
+ return False # Outside reasonable range
181
+ # Most athletes show 10-30° forward lean during takeoff
182
+ return -10 <= angle <= 45
183
+
173
184
 
174
185
  class RSIBounds:
175
186
  """Reactive Strength Index bounds."""
@@ -204,6 +215,12 @@ class RSIBounds:
204
215
  """Check if RSI is within physiological bounds."""
205
216
  return RSIBounds.MIN_VALID <= rsi <= RSIBounds.MAX_VALID
206
217
 
218
+ @staticmethod
219
+ def in_range_for_profile(rsi: float, profile: AthleteProfile) -> bool:
220
+ """Check if RSI is in expected range for profile."""
221
+ min_rsi, max_rsi = RSIBounds.get_rsi_range(profile)
222
+ return min_rsi <= rsi <= max_rsi
223
+
207
224
 
208
225
  class MetricConsistency:
209
226
  """Cross-validation tolerance for metric consistency checks."""
@@ -284,7 +301,7 @@ ATHLETE_PROFILES = {
284
301
 
285
302
 
286
303
  def estimate_athlete_profile(
287
- metrics_dict: MetricsDict, _gender: str | None = None
304
+ metrics_dict: MetricsDict, gender: str | None = None
288
305
  ) -> AthleteProfile:
289
306
  """Estimate athlete profile from metrics.
290
307