kinemotion 0.72.1__py3-none-any.whl → 0.74.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 (34) hide show
  1. kinemotion/__init__.py +2 -2
  2. kinemotion/api.py +6 -6
  3. kinemotion/cli.py +2 -2
  4. kinemotion/core/__init__.py +11 -0
  5. kinemotion/core/auto_tuning.py +74 -27
  6. kinemotion/core/cli_utils.py +74 -0
  7. kinemotion/core/pipeline_utils.py +2 -2
  8. kinemotion/core/quality.py +4 -6
  9. kinemotion/core/validation.py +70 -0
  10. kinemotion/core/video_analysis_base.py +132 -0
  11. kinemotion/core/video_io.py +27 -18
  12. kinemotion/{cmj → countermovement_jump}/analysis.py +0 -97
  13. kinemotion/{cmj → countermovement_jump}/api.py +37 -11
  14. kinemotion/{cmj → countermovement_jump}/cli.py +6 -46
  15. kinemotion/{cmj → countermovement_jump}/metrics_validator.py +150 -236
  16. kinemotion/{dj → drop_jump}/analysis.py +54 -29
  17. kinemotion/{dj → drop_jump}/api.py +46 -16
  18. kinemotion/{dj → drop_jump}/cli.py +8 -58
  19. kinemotion/{dj → drop_jump}/kinematics.py +98 -50
  20. kinemotion/{dj → drop_jump}/metrics_validator.py +25 -51
  21. {kinemotion-0.72.1.dist-info → kinemotion-0.74.0.dist-info}/METADATA +1 -1
  22. kinemotion-0.74.0.dist-info/RECORD +51 -0
  23. kinemotion-0.72.1.dist-info/RECORD +0 -50
  24. /kinemotion/{cmj → countermovement_jump}/__init__.py +0 -0
  25. /kinemotion/{cmj → countermovement_jump}/debug_overlay.py +0 -0
  26. /kinemotion/{cmj → countermovement_jump}/joint_angles.py +0 -0
  27. /kinemotion/{cmj → countermovement_jump}/kinematics.py +0 -0
  28. /kinemotion/{cmj → countermovement_jump}/validation_bounds.py +0 -0
  29. /kinemotion/{dj → drop_jump}/__init__.py +0 -0
  30. /kinemotion/{dj → drop_jump}/debug_overlay.py +0 -0
  31. /kinemotion/{dj → drop_jump}/validation_bounds.py +0 -0
  32. {kinemotion-0.72.1.dist-info → kinemotion-0.74.0.dist-info}/WHEEL +0 -0
  33. {kinemotion-0.72.1.dist-info → kinemotion-0.74.0.dist-info}/entry_points.txt +0 -0
  34. {kinemotion-0.72.1.dist-info → kinemotion-0.74.0.dist-info}/licenses/LICENSE +0 -0
@@ -393,6 +393,24 @@ class CMJVideoConfig:
393
393
  overrides: AnalysisOverrides | None = None
394
394
  detection_confidence: float | None = None
395
395
  tracking_confidence: float | None = None
396
+ verbose: bool = False
397
+ timer: Timer | None = None
398
+ pose_tracker: "MediaPipePoseTracker | None" = None
399
+
400
+ def to_kwargs(self) -> dict:
401
+ """Convert config to kwargs dict for process_cmj_video."""
402
+ return {
403
+ "video_path": self.video_path,
404
+ "quality": self.quality,
405
+ "output_video": self.output_video,
406
+ "json_output": self.json_output,
407
+ "overrides": self.overrides,
408
+ "detection_confidence": self.detection_confidence,
409
+ "tracking_confidence": self.tracking_confidence,
410
+ "verbose": self.verbose,
411
+ "timer": self.timer,
412
+ "pose_tracker": self.pose_tracker,
413
+ }
396
414
 
397
415
 
398
416
  @dataclass
@@ -511,6 +529,23 @@ def process_cmj_video(
511
529
  return metrics
512
530
 
513
531
 
532
+ def process_cmj_video_from_config(
533
+ config: CMJVideoConfig,
534
+ ) -> CMJMetrics:
535
+ """Process a CMJ video using a configuration object.
536
+
537
+ This is a convenience wrapper around process_cmj_video that
538
+ accepts a CMJVideoConfig instead of individual parameters.
539
+
540
+ Args:
541
+ config: Configuration object containing all analysis parameters
542
+
543
+ Returns:
544
+ CMJMetrics object containing analysis results
545
+ """
546
+ return process_cmj_video(**config.to_kwargs())
547
+
548
+
514
549
  def process_cmj_videos_bulk(
515
550
  configs: list[CMJVideoConfig],
516
551
  max_workers: int = 4,
@@ -537,17 +572,8 @@ def _process_cmj_video_wrapper(config: CMJVideoConfig) -> CMJVideoResult:
537
572
  start_time = time.perf_counter()
538
573
 
539
574
  try:
540
- metrics = process_cmj_video(
541
- video_path=config.video_path,
542
- quality=config.quality,
543
- output_video=config.output_video,
544
- json_output=config.json_output,
545
- overrides=config.overrides,
546
- detection_confidence=config.detection_confidence,
547
- tracking_confidence=config.tracking_confidence,
548
- verbose=False,
549
- )
550
-
575
+ # Use convenience wrapper to avoid parameter unpacking
576
+ metrics = process_cmj_video_from_config(config)
551
577
  processing_time = time.perf_counter() - start_time
552
578
 
553
579
  return CMJVideoResult(
@@ -8,9 +8,12 @@ import click
8
8
 
9
9
  from ..core.auto_tuning import QualityPreset
10
10
  from ..core.cli_utils import (
11
+ batch_processing_options,
11
12
  collect_video_files,
12
13
  common_output_options,
13
14
  generate_batch_output_paths,
15
+ quality_option,
16
+ verbose_option,
14
17
  )
15
18
  from .api import AnalysisOverrides, process_cmj_video
16
19
  from .kinematics import CMJMetrics
@@ -59,52 +62,9 @@ def _process_batch_videos(
59
62
  @click.command(name="cmj-analyze")
60
63
  @click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
61
64
  @common_output_options
62
- @click.option(
63
- "--quality",
64
- type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
65
- default="balanced",
66
- help=(
67
- "Analysis quality preset: "
68
- "fast (quick, less precise), "
69
- "balanced (default, good for most cases), "
70
- "accurate (research-grade, slower)"
71
- ),
72
- show_default=True,
73
- )
74
- @click.option(
75
- "--verbose",
76
- "-v",
77
- is_flag=True,
78
- help="Show auto-selected parameters and analysis details",
79
- )
80
- # Batch processing options
81
- @click.option(
82
- "--batch",
83
- is_flag=True,
84
- help="Enable batch processing mode for multiple videos",
85
- )
86
- @click.option(
87
- "--workers",
88
- type=int,
89
- default=4,
90
- help="Number of parallel workers for batch processing (default: 4)",
91
- show_default=True,
92
- )
93
- @click.option(
94
- "--output-dir",
95
- type=click.Path(),
96
- help="Directory for debug video outputs (batch mode only)",
97
- )
98
- @click.option(
99
- "--json-output-dir",
100
- type=click.Path(),
101
- help="Directory for JSON metrics outputs (batch mode only)",
102
- )
103
- @click.option(
104
- "--csv-summary",
105
- type=click.Path(),
106
- help="Path for CSV summary export (batch mode only)",
107
- )
65
+ @quality_option
66
+ @verbose_option
67
+ @batch_processing_options
108
68
  # Expert parameters (hidden in help, but always available for advanced users)
109
69
  @click.option(
110
70
  "--smoothing-window",
@@ -9,13 +9,6 @@ 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
- )
19
12
  from kinemotion.core.types import MetricsDict
20
13
  from kinemotion.core.validation import (
21
14
  AthleteProfile,
@@ -23,6 +16,13 @@ from kinemotion.core.validation import (
23
16
  MetricsValidator,
24
17
  ValidationResult,
25
18
  )
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
@@ -155,38 +155,21 @@ class CMJMetricsValidator(MetricsValidator):
155
155
 
156
156
  flight_time = self._convert_raw_duration_to_seconds(flight_time_raw)
157
157
  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
+ )
158
163
 
159
- if not bounds.is_physically_possible(flight_time):
160
- if flight_time < bounds.absolute_min:
161
- result.add_error(
162
- "flight_time",
163
- f"Flight time {flight_time:.3f}s below frame rate resolution limit",
164
- value=flight_time,
165
- bounds=(bounds.absolute_min, bounds.absolute_max),
166
- )
167
- else:
168
- result.add_error(
169
- "flight_time",
170
- f"Flight time {flight_time:.3f}s exceeds elite human capability",
171
- value=flight_time,
172
- bounds=(bounds.absolute_min, bounds.absolute_max),
173
- )
174
- elif bounds.contains(flight_time, profile):
175
- result.add_info(
176
- "flight_time",
177
- f"Flight time {flight_time:.3f}s within expected range for {profile.value}",
178
- value=flight_time,
179
- )
180
- else:
181
- # Outside expected range but physically possible
182
- expected_min, expected_max = self._get_profile_range(profile, bounds)
183
- result.add_warning(
184
- "flight_time",
185
- f"Flight time {flight_time:.3f}s outside typical range "
186
- f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
187
- value=flight_time,
188
- bounds=(expected_min, expected_max),
189
- )
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
+ )
190
173
 
191
174
  def _check_jump_height(
192
175
  self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
@@ -197,37 +180,21 @@ class CMJMetricsValidator(MetricsValidator):
197
180
  return
198
181
 
199
182
  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
+ )
200
188
 
201
- if not bounds.is_physically_possible(jump_height):
202
- if jump_height < bounds.absolute_min:
203
- result.add_error(
204
- "jump_height",
205
- f"Jump height {jump_height:.3f}m essentially no jump (noise)",
206
- value=jump_height,
207
- bounds=(bounds.absolute_min, bounds.absolute_max),
208
- )
209
- else:
210
- result.add_error(
211
- "jump_height",
212
- f"Jump height {jump_height:.3f}m exceeds human capability",
213
- value=jump_height,
214
- bounds=(bounds.absolute_min, bounds.absolute_max),
215
- )
216
- elif bounds.contains(jump_height, profile):
217
- result.add_info(
218
- "jump_height",
219
- f"Jump height {jump_height:.3f}m within expected range for {profile.value}",
220
- value=jump_height,
221
- )
222
- else:
223
- expected_min, expected_max = self._get_profile_range(profile, bounds)
224
- result.add_warning(
225
- "jump_height",
226
- f"Jump height {jump_height:.3f}m outside typical range "
227
- f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
228
- value=jump_height,
229
- bounds=(expected_min, expected_max),
230
- )
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
+ )
231
198
 
232
199
  def _check_countermovement_depth(
233
200
  self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
@@ -238,37 +205,19 @@ class CMJMetricsValidator(MetricsValidator):
238
205
  return
239
206
 
240
207
  bounds = CMJBounds.COUNTERMOVEMENT_DEPTH
208
+ error_label = (
209
+ "essentially no squat" if depth < bounds.absolute_min else "exceeds physical limit"
210
+ )
241
211
 
242
- if not bounds.is_physically_possible(depth):
243
- if depth < bounds.absolute_min:
244
- result.add_error(
245
- "countermovement_depth",
246
- f"Countermovement depth {depth:.3f}m essentially no squat",
247
- value=depth,
248
- bounds=(bounds.absolute_min, bounds.absolute_max),
249
- )
250
- else:
251
- result.add_error(
252
- "countermovement_depth",
253
- f"Countermovement depth {depth:.3f}m exceeds physical limit",
254
- value=depth,
255
- bounds=(bounds.absolute_min, bounds.absolute_max),
256
- )
257
- elif bounds.contains(depth, profile):
258
- result.add_info(
259
- "countermovement_depth",
260
- f"Countermovement depth {depth:.3f}m within expected range for {profile.value}",
261
- value=depth,
262
- )
263
- else:
264
- expected_min, expected_max = self._get_profile_range(profile, bounds)
265
- result.add_warning(
266
- "countermovement_depth",
267
- f"Countermovement depth {depth:.3f}m outside typical range "
268
- f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
269
- value=depth,
270
- bounds=(expected_min, expected_max),
271
- )
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
+ )
272
221
 
273
222
  def _check_concentric_duration(
274
223
  self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
@@ -340,71 +289,74 @@ class CMJMetricsValidator(MetricsValidator):
340
289
  self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
341
290
  ) -> None:
342
291
  """Validate peak eccentric and concentric velocities."""
343
- # Eccentric
344
- ecc_vel = self._get_metric_value(
345
- metrics, "peak_eccentric_velocity_m_s", "peak_eccentric_velocity"
346
- )
347
- if ecc_vel is not None:
348
- bounds = CMJBounds.PEAK_ECCENTRIC_VELOCITY
349
- if not bounds.is_physically_possible(ecc_vel):
350
- result.add_error(
351
- "peak_eccentric_velocity",
352
- f"Peak eccentric velocity {ecc_vel:.2f} m/s outside limits",
353
- value=ecc_vel,
354
- bounds=(bounds.absolute_min, bounds.absolute_max),
355
- )
356
- elif bounds.contains(ecc_vel, profile):
357
- result.add_info(
358
- "peak_eccentric_velocity",
359
- f"Peak eccentric velocity {ecc_vel:.2f} m/s within range for {profile.value}",
360
- value=ecc_vel,
361
- )
362
- else:
363
- expected_min, expected_max = self._get_profile_range(profile, bounds)
364
- result.add_warning(
365
- "peak_eccentric_velocity",
366
- f"Peak eccentric velocity {ecc_vel:.2f} m/s outside typical range "
367
- f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
368
- value=ecc_vel,
369
- bounds=(expected_min, expected_max),
370
- )
292
+ velocity_checks = [
293
+ (
294
+ "peak_eccentric_velocity",
295
+ "peak_eccentric_velocity_m_s",
296
+ CMJBounds.PEAK_ECCENTRIC_VELOCITY,
297
+ "",
298
+ ),
299
+ (
300
+ "peak_concentric_velocity",
301
+ "peak_concentric_velocity_m_s",
302
+ CMJBounds.PEAK_CONCENTRIC_VELOCITY,
303
+ "insufficient to leave ground",
304
+ ),
305
+ ]
306
+
307
+ for metric_name, key_name, bounds, error_suffix in velocity_checks:
308
+ velocity = self._get_metric_value(metrics, key_name, metric_name)
309
+ if velocity is None:
310
+ continue
311
+
312
+ self._validate_velocity_metric(
313
+ metric_name, velocity, bounds, profile, result, error_suffix
314
+ )
371
315
 
372
- # Concentric
373
- con_vel = self._get_metric_value(
374
- metrics, "peak_concentric_velocity_m_s", "peak_concentric_velocity"
375
- )
376
- if con_vel is not None:
377
- bounds = CMJBounds.PEAK_CONCENTRIC_VELOCITY
378
- if not bounds.is_physically_possible(con_vel):
379
- if con_vel < bounds.absolute_min:
380
- result.add_error(
381
- "peak_concentric_velocity",
382
- f"Peak concentric velocity {con_vel:.2f} m/s insufficient to leave ground",
383
- value=con_vel,
384
- bounds=(bounds.absolute_min, bounds.absolute_max),
385
- )
386
- else:
387
- result.add_error(
388
- "peak_concentric_velocity",
389
- f"Peak concentric velocity {con_vel:.2f} m/s exceeds elite capability",
390
- value=con_vel,
391
- bounds=(bounds.absolute_min, bounds.absolute_max),
392
- )
393
- elif bounds.contains(con_vel, profile):
394
- result.add_info(
395
- "peak_concentric_velocity",
396
- f"Peak concentric velocity {con_vel:.2f} m/s within range for {profile.value}",
397
- value=con_vel,
316
+ def _validate_velocity_metric(
317
+ self,
318
+ name: str,
319
+ velocity: float,
320
+ bounds: MetricBounds,
321
+ profile: AthleteProfile,
322
+ result: CMJValidationResult,
323
+ error_suffix: str,
324
+ ) -> None:
325
+ """Validate a velocity metric against bounds."""
326
+ if not bounds.is_physically_possible(velocity):
327
+ if velocity < bounds.absolute_min and error_suffix:
328
+ error_msg = (
329
+ f"Peak {name.replace('peak_', '')} velocity {velocity:.2f} m/s {error_suffix}"
398
330
  )
399
331
  else:
400
- expected_min, expected_max = self._get_profile_range(profile, bounds)
401
- result.add_warning(
402
- "peak_concentric_velocity",
403
- f"Peak concentric velocity {con_vel:.2f} m/s outside typical range "
404
- f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
405
- value=con_vel,
406
- bounds=(expected_min, expected_max),
332
+ error_msg = (
333
+ f"Peak {name.replace('peak_', '')} velocity {velocity:.2f} m/s outside limits"
407
334
  )
335
+ result.add_error(
336
+ name,
337
+ error_msg,
338
+ value=velocity,
339
+ bounds=(bounds.absolute_min, bounds.absolute_max),
340
+ )
341
+ elif bounds.contains(velocity, profile):
342
+ velocity_type = name.replace("peak_", "").replace("_", " ")
343
+ result.add_info(
344
+ name,
345
+ f"Peak {velocity_type} velocity {velocity:.2f} m/s "
346
+ f"within range for {profile.value}",
347
+ value=velocity,
348
+ )
349
+ else:
350
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
351
+ velocity_type = name.replace("peak_", "").replace("_", " ")
352
+ result.add_warning(
353
+ name,
354
+ f"Peak {velocity_type} velocity {velocity:.2f} m/s "
355
+ f"outside typical range [{expected_min:.2f}-{expected_max:.2f}] "
356
+ f"for {profile.value}",
357
+ value=velocity,
358
+ bounds=(expected_min, expected_max),
359
+ )
408
360
 
409
361
  def _check_flight_time_height_consistency(
410
362
  self, metrics: MetricsDict, result: CMJValidationResult
@@ -625,49 +577,28 @@ class CMJMetricsValidator(MetricsValidator):
625
577
  if angles is None:
626
578
  return
627
579
 
628
- hip = angles.get("hip_angle")
629
- if hip is not None:
630
- if not TripleExtensionBounds.hip_angle_valid(hip, profile):
631
- result.add_warning(
632
- "hip_angle",
633
- f"Hip angle {hip:.1f}° outside expected range for {profile.value}",
634
- value=hip,
635
- )
636
- else:
637
- result.add_info(
638
- "hip_angle",
639
- f"Hip angle {hip:.1f}° within expected range for {profile.value}",
640
- value=hip,
641
- )
580
+ joint_definitions = [
581
+ ("hip_angle", TripleExtensionBounds.hip_angle_valid, "Hip"),
582
+ ("knee_angle", TripleExtensionBounds.knee_angle_valid, "Knee"),
583
+ ("ankle_angle", TripleExtensionBounds.ankle_angle_valid, "Ankle"),
584
+ ]
642
585
 
643
- knee = angles.get("knee_angle")
644
- if knee is not None:
645
- if not TripleExtensionBounds.knee_angle_valid(knee, profile):
646
- result.add_warning(
647
- "knee_angle",
648
- f"Knee angle {knee:.1f}° outside expected range for {profile.value}",
649
- value=knee,
650
- )
651
- else:
652
- result.add_info(
653
- "knee_angle",
654
- f"Knee angle {knee:.1f}° within expected range for {profile.value}",
655
- value=knee,
656
- )
586
+ for metric_name, validator, joint_name in joint_definitions:
587
+ angle = angles.get(metric_name)
588
+ if angle is None:
589
+ continue
657
590
 
658
- ankle = angles.get("ankle_angle")
659
- if ankle is not None:
660
- if not TripleExtensionBounds.ankle_angle_valid(ankle, profile):
591
+ if not validator(angle, profile):
661
592
  result.add_warning(
662
- "ankle_angle",
663
- f"Ankle angle {ankle:.1f}° outside expected range for {profile.value}",
664
- value=ankle,
593
+ metric_name,
594
+ f"{joint_name} angle {angle:.1f}° outside expected range for {profile.value}",
595
+ value=angle,
665
596
  )
666
597
  else:
667
598
  result.add_info(
668
- "ankle_angle",
669
- f"Ankle angle {ankle:.1f}° within expected range for {profile.value}",
670
- value=ankle,
599
+ metric_name,
600
+ f"{joint_name} angle {angle:.1f}° within expected range for {profile.value}",
601
+ value=angle,
671
602
  )
672
603
 
673
604
  # Detect joint compensation patterns
@@ -691,32 +622,32 @@ class CMJMetricsValidator(MetricsValidator):
691
622
  if hip is None or knee is None or ankle is None:
692
623
  return # Need all three to detect patterns
693
624
 
694
- # Get profile-specific bounds
695
- if profile == AthleteProfile.ELDERLY:
696
- hip_min, hip_max = 150, 175
697
- knee_min, knee_max = 155, 175
698
- ankle_min, ankle_max = 100, 125
699
- elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
700
- hip_min, hip_max = 160, 180
701
- knee_min, knee_max = 165, 182
702
- ankle_min, ankle_max = 110, 140
703
- elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
704
- hip_min, hip_max = 170, 185
705
- knee_min, knee_max = 173, 190
706
- ankle_min, ankle_max = 125, 155
707
- else:
625
+ # Profile-specific bounds lookup
626
+ profile_bounds = {
627
+ AthleteProfile.ELDERLY: (150, 175, 155, 175, 100, 125),
628
+ AthleteProfile.UNTRAINED: (160, 180, 165, 182, 110, 140),
629
+ AthleteProfile.RECREATIONAL: (160, 180, 165, 182, 110, 140),
630
+ AthleteProfile.TRAINED: (170, 185, 173, 190, 125, 155),
631
+ AthleteProfile.ELITE: (170, 185, 173, 190, 125, 155),
632
+ }
633
+
634
+ bounds_tuple = profile_bounds.get(profile)
635
+ if not bounds_tuple:
708
636
  return
709
637
 
710
- # Count how many joints are near their boundaries
711
- joints_at_boundary = 0
712
- boundary_threshold = 3.0 # degrees from limit
638
+ hip_min, hip_max, knee_min, knee_max, ankle_min, ankle_max = bounds_tuple
713
639
 
714
- if hip <= hip_min + boundary_threshold or hip >= hip_max - boundary_threshold:
715
- joints_at_boundary += 1
716
- if knee <= knee_min + boundary_threshold or knee >= knee_max - boundary_threshold:
717
- joints_at_boundary += 1
718
- if ankle <= ankle_min + boundary_threshold or ankle >= ankle_max - boundary_threshold:
719
- joints_at_boundary += 1
640
+ # Count joints at boundaries
641
+ boundary_threshold = 3.0 # degrees from limit
642
+ joints_at_boundary = sum(
643
+ 1
644
+ for val, min_val, max_val in [
645
+ (hip, hip_min, hip_max),
646
+ (knee, knee_min, knee_max),
647
+ (ankle, ankle_min, ankle_max),
648
+ ]
649
+ if val <= min_val + boundary_threshold or val >= max_val - boundary_threshold
650
+ )
720
651
 
721
652
  # If 2+ joints at boundaries, likely compensation pattern
722
653
  if joints_at_boundary >= 2:
@@ -727,20 +658,3 @@ class CMJMetricsValidator(MetricsValidator):
727
658
  f"May indicate compensatory movement pattern.",
728
659
  value=float(joints_at_boundary),
729
660
  )
730
-
731
- @staticmethod
732
- def _get_profile_range(profile: AthleteProfile, bounds: MetricBounds) -> tuple[float, float]:
733
- """Get min/max bounds for specific profile."""
734
- if profile == AthleteProfile.ELDERLY:
735
- return (bounds.practical_min, bounds.recreational_max)
736
- elif profile == AthleteProfile.UNTRAINED:
737
- return (bounds.practical_min, bounds.recreational_max)
738
- elif profile == AthleteProfile.RECREATIONAL:
739
- return (bounds.recreational_min, bounds.recreational_max)
740
- elif profile == AthleteProfile.TRAINED:
741
- trained_min = (bounds.recreational_min + bounds.elite_min) / 2
742
- trained_max = (bounds.recreational_max + bounds.elite_max) / 2
743
- return (trained_min, trained_max)
744
- elif profile == AthleteProfile.ELITE:
745
- return (bounds.elite_min, bounds.elite_max)
746
- return (bounds.absolute_min, bounds.absolute_max)