kinemotion 0.29.3__py3-none-any.whl → 0.31.0__py3-none-any.whl

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

Potentially problematic release.


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

kinemotion/api.py CHANGED
@@ -18,6 +18,8 @@ from .core.auto_tuning import (
18
18
  analyze_video_sample,
19
19
  auto_tune_parameters,
20
20
  )
21
+ from .core.cmj_metrics_validator import CMJMetricsValidator
22
+ from .core.dropjump_metrics_validator import DropJumpMetricsValidator
21
23
  from .core.filtering import reject_outliers
22
24
  from .core.metadata import (
23
25
  AlgorithmConfig,
@@ -60,7 +62,8 @@ def _parse_quality_preset(quality: str) -> QualityPreset:
60
62
  return QualityPreset(quality.lower())
61
63
  except ValueError as e:
62
64
  raise ValueError(
63
- f"Invalid quality preset: {quality}. Must be 'fast', 'balanced', or 'accurate'"
65
+ f"Invalid quality preset: {quality}. "
66
+ "Must be 'fast', 'balanced', or 'accurate'"
64
67
  ) from e
65
68
 
66
69
 
@@ -499,7 +502,8 @@ def process_dropjump_video(
499
502
  if verbose:
500
503
  print("Assessing tracking quality...")
501
504
 
502
- # Detect outliers for quality scoring (doesn't affect results, just for assessment)
505
+ # Detect outliers for quality scoring (doesn't affect results, just
506
+ # for assessment)
503
507
  _, outlier_mask = reject_outliers(
504
508
  vertical_positions,
505
509
  use_ransac=True,
@@ -602,6 +606,16 @@ def process_dropjump_video(
602
606
  if verbose:
603
607
  print("Analysis complete!")
604
608
 
609
+ # Validate metrics against physiological bounds
610
+ validator = DropJumpMetricsValidator()
611
+ validation_result = validator.validate(metrics.to_dict()) # type: ignore[arg-type]
612
+ metrics.validation_result = validation_result
613
+
614
+ if verbose and validation_result.issues:
615
+ print("\n⚠️ Validation Results:")
616
+ for issue in validation_result.issues:
617
+ print(f" [{issue.severity.value}] {issue.metric}: {issue.message}")
618
+
605
619
  return metrics
606
620
 
607
621
 
@@ -614,9 +628,11 @@ def process_dropjump_videos_bulk(
614
628
  Process multiple drop jump videos in parallel using ProcessPoolExecutor.
615
629
 
616
630
  Args:
617
- configs: List of DropJumpVideoConfig objects specifying video paths and parameters
631
+ configs: List of DropJumpVideoConfig objects specifying video paths
632
+ and parameters
618
633
  max_workers: Maximum number of parallel workers (default: 4)
619
- progress_callback: Optional callback function called after each video completes.
634
+ progress_callback: Optional callback function called after each video
635
+ completes.
620
636
  Receives DropJumpVideoResult object.
621
637
 
622
638
  Returns:
@@ -941,7 +957,8 @@ def process_cmj_video(
941
957
  if verbose:
942
958
  print("Assessing tracking quality...")
943
959
 
944
- # Detect outliers for quality scoring (doesn't affect results, just for assessment)
960
+ # Detect outliers for quality scoring (doesn't affect results, just
961
+ # for assessment)
945
962
  _, outlier_mask = reject_outliers(
946
963
  vertical_positions,
947
964
  use_ransac=True,
@@ -1035,9 +1052,19 @@ def process_cmj_video(
1035
1052
 
1036
1053
  if verbose:
1037
1054
  print(f"\nJump height: {metrics.jump_height:.3f}m")
1038
- print(f"Flight time: {metrics.flight_time*1000:.1f}ms")
1055
+ print(f"Flight time: {metrics.flight_time * 1000:.1f}ms")
1039
1056
  print(f"Countermovement depth: {metrics.countermovement_depth:.3f}m")
1040
1057
 
1058
+ # Validate metrics against physiological bounds
1059
+ validator = CMJMetricsValidator()
1060
+ validation_result = validator.validate(metrics.to_dict()["data"]) # type: ignore[arg-type]
1061
+ metrics.validation_result = validation_result
1062
+
1063
+ if verbose and validation_result.issues:
1064
+ print("\n⚠️ Validation Results:")
1065
+ for issue in validation_result.issues:
1066
+ print(f" [{issue.severity.value}] {issue.metric}: {issue.message}")
1067
+
1041
1068
  return metrics
1042
1069
 
1043
1070
 
@@ -1104,7 +1131,8 @@ def process_cmj_videos_bulk(
1104
1131
 
1105
1132
  def _process_cmj_video_wrapper(config: CMJVideoConfig) -> CMJVideoResult:
1106
1133
  """
1107
- Wrapper function for parallel CMJ processing. Must be picklable (top-level function).
1134
+ Wrapper function for parallel CMJ processing. Must be picklable
1135
+ (top-level function).
1108
1136
 
1109
1137
  Args:
1110
1138
  config: CMJVideoConfig object with processing parameters
@@ -62,7 +62,8 @@ def find_standing_phase(
62
62
  """
63
63
  Find the end of standing phase (start of countermovement).
64
64
 
65
- Looks for a period of low velocity (standing) followed by consistent downward motion.
65
+ Looks for a period of low velocity (standing) followed by consistent
66
+ downward motion.
66
67
 
67
68
  Args:
68
69
  positions: Array of vertical positions (normalized 0-1)
@@ -109,11 +110,13 @@ def find_countermovement_start(
109
110
  """
110
111
  Find the start of countermovement (eccentric phase).
111
112
 
112
- Detects when velocity becomes consistently positive (downward motion in normalized coords).
113
+ Detects when velocity becomes consistently positive (downward motion in
114
+ normalized coords).
113
115
 
114
116
  Args:
115
117
  velocities: Array of SIGNED vertical velocities
116
- countermovement_threshold: Velocity threshold for detecting downward motion (POSITIVE)
118
+ countermovement_threshold: Velocity threshold for detecting downward
119
+ motion (POSITIVE)
117
120
  min_eccentric_frames: Minimum consecutive frames of downward motion
118
121
  standing_start: Optional frame where standing phase ended
119
122
 
kinemotion/cmj/cli.py CHANGED
@@ -177,7 +177,8 @@ def _process_batch_videos(
177
177
  default=None,
178
178
  help="[EXPERT] Override pose tracking confidence",
179
179
  )
180
- def cmj_analyze( # NOSONAR(S107) - Click CLI requires individual parameters for each option
180
+ def cmj_analyze( # NOSONAR(S107) - Click CLI requires individual parameters
181
+ # for each option
181
182
  video_path: tuple[str, ...],
182
183
  output: str | None,
183
184
  json_output: str | None,
@@ -197,10 +198,12 @@ def cmj_analyze( # NOSONAR(S107) - Click CLI requires individual parameters for
197
198
  tracking_confidence: float | None,
198
199
  ) -> None:
199
200
  """
200
- Analyze counter movement jump (CMJ) video(s) to estimate jump performance metrics.
201
+ Analyze counter movement jump (CMJ) video(s) to estimate jump performance
202
+ metrics.
201
203
 
202
- Uses intelligent auto-tuning to select optimal parameters based on video characteristics.
203
- Parameters are automatically adjusted for frame rate, tracking quality, and analysis preset.
204
+ Uses intelligent auto-tuning to select optimal parameters based on video
205
+ characteristics. Parameters are automatically adjusted for frame rate,
206
+ tracking quality, and analysis preset.
204
207
 
205
208
  VIDEO_PATH: Path(s) to video file(s). Supports glob patterns in batch mode.
206
209
 
@@ -341,11 +344,13 @@ def _output_results(metrics: Any, json_output: str | None) -> None:
341
344
  f"Total movement time: {metrics.total_movement_time * 1000:.1f} ms", err=True
342
345
  )
343
346
  click.echo(
344
- f"Peak eccentric velocity: {abs(metrics.peak_eccentric_velocity):.3f} m/s (downward)",
347
+ f"Peak eccentric velocity: {abs(metrics.peak_eccentric_velocity):.3f} "
348
+ "m/s (downward)",
345
349
  err=True,
346
350
  )
347
351
  click.echo(
348
- f"Peak concentric velocity: {metrics.peak_concentric_velocity:.3f} m/s (upward)",
352
+ f"Peak concentric velocity: {metrics.peak_concentric_velocity:.3f} "
353
+ "m/s (upward)",
349
354
  err=True,
350
355
  )
351
356
  if metrics.transition_time is not None:
@@ -370,10 +370,10 @@ class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
370
370
 
371
371
  metrics_text = [
372
372
  f"Jump Height: {metrics.jump_height:.3f}m",
373
- f"Flight Time: {metrics.flight_time*1000:.0f}ms",
373
+ f"Flight Time: {metrics.flight_time * 1000:.0f}ms",
374
374
  f"CM Depth: {metrics.countermovement_depth:.3f}m",
375
- f"Ecc Duration: {metrics.eccentric_duration*1000:.0f}ms",
376
- f"Con Duration: {metrics.concentric_duration*1000:.0f}ms",
375
+ f"Ecc Duration: {metrics.eccentric_duration * 1000:.0f}ms",
376
+ f"Con Duration: {metrics.concentric_duration * 1000:.0f}ms",
377
377
  ]
378
378
 
379
379
  # Draw background
@@ -9,6 +9,7 @@ from numpy.typing import NDArray
9
9
  from ..core.formatting import format_float_metric
10
10
 
11
11
  if TYPE_CHECKING:
12
+ from ..core.cmj_metrics_validator import ValidationResult
12
13
  from ..core.metadata import ResultMetadata
13
14
  from ..core.quality import QualityAssessment
14
15
 
@@ -32,11 +33,12 @@ class CMJDataDict(TypedDict, total=False):
32
33
  tracking_method: str
33
34
 
34
35
 
35
- class CMJResultDict(TypedDict):
36
+ class CMJResultDict(TypedDict, total=False):
36
37
  """Type-safe dictionary for complete CMJ result with data and metadata."""
37
38
 
38
39
  data: CMJDataDict
39
40
  metadata: dict # ResultMetadata.to_dict()
41
+ validation: dict # ValidationResult.to_dict()
40
42
 
41
43
 
42
44
  @dataclass
@@ -46,12 +48,17 @@ class CMJMetrics:
46
48
  Attributes:
47
49
  jump_height: Maximum jump height in meters
48
50
  flight_time: Time spent in the air in milliseconds
49
- countermovement_depth: Vertical distance traveled during eccentric phase in meters
50
- eccentric_duration: Time from countermovement start to lowest point in milliseconds
51
+ countermovement_depth: Vertical distance traveled during eccentric
52
+ phase in meters
53
+ eccentric_duration: Time from countermovement start to lowest point in
54
+ milliseconds
51
55
  concentric_duration: Time from lowest point to takeoff in milliseconds
52
- total_movement_time: Total time from countermovement start to takeoff in milliseconds
53
- peak_eccentric_velocity: Maximum downward velocity during countermovement in m/s
54
- peak_concentric_velocity: Maximum upward velocity during propulsion in m/s
56
+ total_movement_time: Total time from countermovement start to takeoff
57
+ in milliseconds
58
+ peak_eccentric_velocity: Maximum downward velocity during
59
+ countermovement in m/s
60
+ peak_concentric_velocity: Maximum upward velocity during propulsion in
61
+ m/s
55
62
  transition_time: Duration at lowest point (amortization phase) in milliseconds
56
63
  standing_start_frame: Frame where standing phase ends (countermovement begins)
57
64
  lowest_point_frame: Frame at lowest point of countermovement
@@ -60,6 +67,7 @@ class CMJMetrics:
60
67
  video_fps: Frames per second of the analyzed video
61
68
  tracking_method: Method used for tracking ("foot" or "com")
62
69
  quality_assessment: Optional quality assessment with confidence and warnings
70
+ validation_result: Optional validation result with physiological bounds checks
63
71
  """
64
72
 
65
73
  jump_height: float
@@ -79,6 +87,7 @@ class CMJMetrics:
79
87
  tracking_method: str
80
88
  quality_assessment: "QualityAssessment | None" = None
81
89
  result_metadata: "ResultMetadata | None" = None
90
+ validation_result: "ValidationResult | None" = None
82
91
 
83
92
  def to_dict(self) -> CMJResultDict:
84
93
  """Convert metrics to JSON-serializable dictionary with data/metadata structure.
@@ -129,7 +138,13 @@ class CMJMetrics:
129
138
  # No metadata available
130
139
  metadata = {}
131
140
 
132
- return {"data": data, "metadata": metadata}
141
+ result: CMJResultDict = {"data": data, "metadata": metadata}
142
+
143
+ # Include validation results if available
144
+ if self.validation_result is not None:
145
+ result["validation"] = self.validation_result.to_dict()
146
+
147
+ return result
133
148
 
134
149
 
135
150
  def calculate_cmj_metrics(
@@ -120,8 +120,10 @@ def print_auto_tuned_params(
120
120
  video: Video processor
121
121
  quality_preset: Quality preset
122
122
  params: Auto-tuned parameters
123
- characteristics: Optional video characteristics (for tracking quality display)
124
- extra_params: Optional extra parameters to display (e.g., countermovement_threshold)
123
+ characteristics: Optional video characteristics (for tracking quality
124
+ display)
125
+ extra_params: Optional extra parameters to display (e.g.,
126
+ countermovement_threshold)
125
127
  """
126
128
  click.echo("\n" + "=" * 60, err=True)
127
129
  click.echo("AUTO-TUNED PARAMETERS", err=True)
@@ -121,6 +121,36 @@ class ValidationResult:
121
121
  else:
122
122
  self.status = "PASS"
123
123
 
124
+ def to_dict(self) -> dict:
125
+ """Convert validation result to JSON-serializable dictionary.
126
+
127
+ Returns:
128
+ Dictionary with status, issues, and consistency metrics.
129
+ """
130
+ return {
131
+ "status": self.status,
132
+ "issues": [
133
+ {
134
+ "severity": issue.severity.value,
135
+ "metric": issue.metric,
136
+ "message": issue.message,
137
+ "value": issue.value,
138
+ "bounds": issue.bounds,
139
+ }
140
+ for issue in self.issues
141
+ ],
142
+ "athlete_profile": (
143
+ self.athlete_profile.value if self.athlete_profile else None
144
+ ),
145
+ "rsi": self.rsi,
146
+ "height_flight_time_consistency_percent": (
147
+ self.height_flight_time_consistency
148
+ ),
149
+ "velocity_height_consistency_percent": self.velocity_height_consistency,
150
+ "depth_height_ratio": self.depth_height_ratio,
151
+ "contact_depth_ratio": self.contact_depth_ratio,
152
+ }
153
+
124
154
 
125
155
  class CMJMetricsValidator:
126
156
  """Comprehensive CMJ metrics validator."""
@@ -206,7 +236,8 @@ class CMJMetricsValidator:
206
236
  elif bounds.contains(flight_time, profile):
207
237
  result.add_info(
208
238
  "flight_time",
209
- f"Flight time {flight_time:.3f}s within expected range for {profile.value}",
239
+ f"Flight time {flight_time:.3f}s within expected range for "
240
+ f"{profile.value}",
210
241
  value=flight_time,
211
242
  )
212
243
  else:
@@ -248,7 +279,8 @@ class CMJMetricsValidator:
248
279
  elif bounds.contains(jump_height, profile):
249
280
  result.add_info(
250
281
  "jump_height",
251
- f"Jump height {jump_height:.3f}m within expected range for {profile.value}",
282
+ f"Jump height {jump_height:.3f}m within expected range for "
283
+ f"{profile.value}",
252
284
  value=jump_height,
253
285
  )
254
286
  else:
@@ -289,7 +321,8 @@ class CMJMetricsValidator:
289
321
  elif bounds.contains(depth, profile):
290
322
  result.add_info(
291
323
  "countermovement_depth",
292
- f"Countermovement depth {depth:.3f}m within expected range for {profile.value}",
324
+ f"Countermovement depth {depth:.3f}m within expected range for "
325
+ f"{profile.value}",
293
326
  value=depth,
294
327
  )
295
328
  else:
@@ -323,14 +356,16 @@ class CMJMetricsValidator:
323
356
  else:
324
357
  result.add_error(
325
358
  "concentric_duration",
326
- f"Concentric duration {duration:.3f}s likely includes standing phase",
359
+ f"Concentric duration {duration:.3f}s likely includes "
360
+ "standing phase",
327
361
  value=duration,
328
362
  bounds=(bounds.absolute_min, bounds.absolute_max),
329
363
  )
330
364
  elif bounds.contains(duration, profile):
331
365
  result.add_info(
332
366
  "concentric_duration",
333
- f"Concentric duration {duration:.3f}s within expected range for {profile.value}",
367
+ f"Concentric duration {duration:.3f}s within expected range for "
368
+ f"{profile.value}",
334
369
  value=duration,
335
370
  )
336
371
  else:
@@ -363,7 +398,8 @@ class CMJMetricsValidator:
363
398
  elif bounds.contains(duration, profile):
364
399
  result.add_info(
365
400
  "eccentric_duration",
366
- f"Eccentric duration {duration:.3f}s within expected range for {profile.value}",
401
+ f"Eccentric duration {duration:.3f}s within expected range for "
402
+ f"{profile.value}",
367
403
  value=duration,
368
404
  )
369
405
  else:
@@ -394,7 +430,8 @@ class CMJMetricsValidator:
394
430
  elif bounds.contains(ecc_vel, profile):
395
431
  result.add_info(
396
432
  "peak_eccentric_velocity",
397
- f"Peak eccentric velocity {ecc_vel:.2f} m/s within range for {profile.value}",
433
+ f"Peak eccentric velocity {ecc_vel:.2f} m/s within range "
434
+ f"for {profile.value}",
398
435
  value=ecc_vel,
399
436
  )
400
437
  else:
@@ -415,21 +452,24 @@ class CMJMetricsValidator:
415
452
  if con_vel < bounds.absolute_min:
416
453
  result.add_error(
417
454
  "peak_concentric_velocity",
418
- f"Peak concentric velocity {con_vel:.2f} m/s insufficient to leave ground",
455
+ f"Peak concentric velocity {con_vel:.2f} m/s "
456
+ "insufficient to leave ground",
419
457
  value=con_vel,
420
458
  bounds=(bounds.absolute_min, bounds.absolute_max),
421
459
  )
422
460
  else:
423
461
  result.add_error(
424
462
  "peak_concentric_velocity",
425
- f"Peak concentric velocity {con_vel:.2f} m/s exceeds elite capability",
463
+ f"Peak concentric velocity {con_vel:.2f} m/s exceeds "
464
+ "elite capability",
426
465
  value=con_vel,
427
466
  bounds=(bounds.absolute_min, bounds.absolute_max),
428
467
  )
429
468
  elif bounds.contains(con_vel, profile):
430
469
  result.add_info(
431
470
  "peak_concentric_velocity",
432
- f"Peak concentric velocity {con_vel:.2f} m/s within range for {profile.value}",
471
+ f"Peak concentric velocity {con_vel:.2f} m/s within range "
472
+ f"for {profile.value}",
433
473
  value=con_vel,
434
474
  )
435
475
  else:
@@ -462,15 +502,17 @@ class CMJMetricsValidator:
462
502
  if error_pct > MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE:
463
503
  result.add_error(
464
504
  "height_flight_time_consistency",
465
- f"Jump height {jump_height:.3f}m inconsistent with flight time {flight_time:.3f}s "
466
- f"(expected {expected_height:.3f}m, error {error_pct*100:.1f}%)",
505
+ f"Jump height {jump_height:.3f}m inconsistent with flight "
506
+ f"time {flight_time:.3f}s (expected {expected_height:.3f}m, "
507
+ f"error {error_pct * 100:.1f}%)",
467
508
  value=error_pct,
468
509
  bounds=(0, MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE),
469
510
  )
470
511
  else:
471
512
  result.add_info(
472
513
  "height_flight_time_consistency",
473
- f"Jump height and flight time consistent (error {error_pct*100:.1f}%)",
514
+ f"Jump height and flight time consistent "
515
+ f"(error {error_pct * 100:.1f}%)",
474
516
  value=error_pct,
475
517
  )
476
518
 
@@ -495,7 +537,7 @@ class CMJMetricsValidator:
495
537
  error_msg = (
496
538
  f"Peak velocity {velocity:.2f} m/s inconsistent with "
497
539
  f"jump height {jump_height:.3f}m (expected {expected_velocity:.2f} "
498
- f"m/s, error {error_pct*100:.1f}%)"
540
+ f"m/s, error {error_pct * 100:.1f}%)"
499
541
  )
500
542
  result.add_warning(
501
543
  "velocity_height_consistency",
@@ -506,7 +548,8 @@ class CMJMetricsValidator:
506
548
  else:
507
549
  result.add_info(
508
550
  "velocity_height_consistency",
509
- f"Peak velocity and jump height consistent (error {error_pct*100:.1f}%)",
551
+ f"Peak velocity and jump height consistent "
552
+ f"(error {error_pct * 100:.1f}%)",
510
553
  value=error_pct,
511
554
  )
512
555
 
@@ -547,14 +590,16 @@ class CMJMetricsValidator:
547
590
  if expected_min <= rsi <= expected_max:
548
591
  result.add_info(
549
592
  "rsi",
550
- f"RSI {rsi:.2f} within expected range [{expected_min:.2f}-{expected_max:.2f}] "
593
+ f"RSI {rsi:.2f} within expected range "
594
+ f"[{expected_min:.2f}-{expected_max:.2f}] "
551
595
  f"for {profile.value}",
552
596
  value=rsi,
553
597
  )
554
598
  else:
555
599
  result.add_warning(
556
600
  "rsi",
557
- f"RSI {rsi:.2f} outside typical range [{expected_min:.2f}-{expected_max:.2f}] "
601
+ f"RSI {rsi:.2f} outside typical range "
602
+ f"[{expected_min:.2f}-{expected_max:.2f}] "
558
603
  f"for {profile.value}",
559
604
  value=rsi,
560
605
  bounds=(expected_min, expected_max),
@@ -620,7 +665,8 @@ class CMJMetricsValidator:
620
665
  if ratio < MetricConsistency.CONTACT_DEPTH_RATIO_MIN:
621
666
  result.add_warning(
622
667
  "contact_depth_ratio",
623
- f"Contact time {ratio:.2f}s/m to depth ratio: Very fast for depth traversed",
668
+ f"Contact time {ratio:.2f}s/m to depth ratio: Very fast for "
669
+ "depth traversed",
624
670
  value=ratio,
625
671
  bounds=(
626
672
  MetricConsistency.CONTACT_DEPTH_RATIO_MIN,
@@ -672,7 +718,8 @@ class CMJMetricsValidator:
672
718
  if not TripleExtensionBounds.knee_angle_valid(knee, profile):
673
719
  result.add_warning(
674
720
  "knee_angle",
675
- f"Knee angle {knee:.1f}° outside expected range for {profile.value}",
721
+ f"Knee angle {knee:.1f}° outside expected range for "
722
+ f"{profile.value}",
676
723
  value=knee,
677
724
  )
678
725
  else:
@@ -687,16 +734,82 @@ class CMJMetricsValidator:
687
734
  if not TripleExtensionBounds.ankle_angle_valid(ankle, profile):
688
735
  result.add_warning(
689
736
  "ankle_angle",
690
- f"Ankle angle {ankle:.1f}° outside expected range for {profile.value}",
737
+ f"Ankle angle {ankle:.1f}° outside expected range for "
738
+ f"{profile.value}",
691
739
  value=ankle,
692
740
  )
693
741
  else:
694
742
  result.add_info(
695
743
  "ankle_angle",
696
- f"Ankle angle {ankle:.1f}° within expected range for {profile.value}",
744
+ f"Ankle angle {ankle:.1f}° within expected range for "
745
+ f"{profile.value}",
697
746
  value=ankle,
698
747
  )
699
748
 
749
+ # Detect joint compensation patterns
750
+ self._check_joint_compensation_pattern(angles, result, profile)
751
+
752
+ def _check_joint_compensation_pattern(
753
+ self, angles: dict, result: ValidationResult, profile: AthleteProfile
754
+ ) -> None:
755
+ """Detect compensatory joint patterns in triple extension.
756
+
757
+ When one joint cannot achieve full extension, others may compensate.
758
+ Example: Limited hip extension (160°) with excessive knee extension (185°+)
759
+ suggests compensation rather than balanced movement quality.
760
+
761
+ This is a biomechanical quality indicator, not an error.
762
+ """
763
+ hip = angles.get("hip_angle")
764
+ knee = angles.get("knee_angle")
765
+ ankle = angles.get("ankle_angle")
766
+
767
+ if hip is None or knee is None or ankle is None:
768
+ return # Need all three to detect patterns
769
+
770
+ # Get profile-specific bounds
771
+ if profile == AthleteProfile.ELDERLY:
772
+ hip_min, hip_max = 150, 175
773
+ knee_min, knee_max = 155, 175
774
+ ankle_min, ankle_max = 100, 125
775
+ elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
776
+ hip_min, hip_max = 160, 180
777
+ knee_min, knee_max = 165, 182
778
+ ankle_min, ankle_max = 110, 140
779
+ elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
780
+ hip_min, hip_max = 170, 185
781
+ knee_min, knee_max = 173, 190
782
+ ankle_min, ankle_max = 125, 155
783
+ else:
784
+ return
785
+
786
+ # Count how many joints are near their boundaries
787
+ joints_at_boundary = 0
788
+ boundary_threshold = 3.0 # degrees from limit
789
+
790
+ if hip <= hip_min + boundary_threshold or hip >= hip_max - boundary_threshold:
791
+ joints_at_boundary += 1
792
+ if (
793
+ knee <= knee_min + boundary_threshold
794
+ or knee >= knee_max - boundary_threshold
795
+ ):
796
+ joints_at_boundary += 1
797
+ if (
798
+ ankle <= ankle_min + boundary_threshold
799
+ or ankle >= ankle_max - boundary_threshold
800
+ ):
801
+ joints_at_boundary += 1
802
+
803
+ # If 2+ joints at boundaries, likely compensation pattern
804
+ if joints_at_boundary >= 2:
805
+ result.add_info(
806
+ "joint_compensation",
807
+ f"Multiple joints near extension limits (hip={hip:.0f}°, "
808
+ f"knee={knee:.0f}°, ankle={ankle:.0f}°). "
809
+ f"May indicate compensatory movement pattern.",
810
+ value=float(joints_at_boundary),
811
+ )
812
+
700
813
  @staticmethod
701
814
  def _get_profile_range(
702
815
  profile: AthleteProfile, bounds: MetricBounds
@@ -356,7 +356,9 @@ ATHLETE_PROFILES = {
356
356
  }
357
357
 
358
358
 
359
- def estimate_athlete_profile(metrics_dict: dict) -> AthleteProfile:
359
+ def estimate_athlete_profile(
360
+ metrics_dict: dict, gender: str | None = None
361
+ ) -> AthleteProfile:
360
362
  """Estimate athlete profile from metrics.
361
363
 
362
364
  Uses jump height as primary classifier:
@@ -365,6 +367,18 @@ def estimate_athlete_profile(metrics_dict: dict) -> AthleteProfile:
365
367
  - 0.35-0.65m: Recreational
366
368
  - 0.65-0.85m: Trained
367
369
  - >0.85m: Elite
370
+
371
+ NOTE: Bounds are calibrated for adult males. Female athletes typically achieve
372
+ 60-70% of male heights due to lower muscle mass and strength. If analyzing
373
+ female athletes, interpret results one level lower than classification suggests.
374
+ Example: Female athlete with 0.45m jump = Recreational male = Trained female.
375
+
376
+ Args:
377
+ metrics_dict: Dictionary with CMJ metric values
378
+ gender: Optional gender for context ("M"/"F"). Currently informational only.
379
+
380
+ Returns:
381
+ Estimated AthleteProfile
368
382
  """
369
383
  jump_height = metrics_dict.get("jump_height", 0)
370
384