kinemotion 0.29.2__py3-none-any.whl → 0.30.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,
@@ -602,6 +604,16 @@ def process_dropjump_video(
602
604
  if verbose:
603
605
  print("Analysis complete!")
604
606
 
607
+ # Validate metrics against physiological bounds
608
+ validator = DropJumpMetricsValidator()
609
+ validation_result = validator.validate(metrics.to_dict()) # type: ignore[arg-type]
610
+ metrics.validation_result = validation_result
611
+
612
+ if verbose and validation_result.issues:
613
+ print("\n⚠️ Validation Results:")
614
+ for issue in validation_result.issues:
615
+ print(f" [{issue.severity.value}] {issue.metric}: {issue.message}")
616
+
605
617
  return metrics
606
618
 
607
619
 
@@ -1038,6 +1050,16 @@ def process_cmj_video(
1038
1050
  print(f"Flight time: {metrics.flight_time*1000:.1f}ms")
1039
1051
  print(f"Countermovement depth: {metrics.countermovement_depth:.3f}m")
1040
1052
 
1053
+ # Validate metrics against physiological bounds
1054
+ validator = CMJMetricsValidator()
1055
+ validation_result = validator.validate(metrics.to_dict()["data"]) # type: ignore[arg-type]
1056
+ metrics.validation_result = validation_result
1057
+
1058
+ if verbose and validation_result.issues:
1059
+ print("\n⚠️ Validation Results:")
1060
+ for issue in validation_result.issues:
1061
+ print(f" [{issue.severity.value}] {issue.metric}: {issue.message}")
1062
+
1041
1063
  return metrics
1042
1064
 
1043
1065
 
@@ -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
@@ -60,6 +62,7 @@ class CMJMetrics:
60
62
  video_fps: Frames per second of the analyzed video
61
63
  tracking_method: Method used for tracking ("foot" or "com")
62
64
  quality_assessment: Optional quality assessment with confidence and warnings
65
+ validation_result: Optional validation result with physiological bounds checks
63
66
  """
64
67
 
65
68
  jump_height: float
@@ -79,6 +82,7 @@ class CMJMetrics:
79
82
  tracking_method: str
80
83
  quality_assessment: "QualityAssessment | None" = None
81
84
  result_metadata: "ResultMetadata | None" = None
85
+ validation_result: "ValidationResult | None" = None
82
86
 
83
87
  def to_dict(self) -> CMJResultDict:
84
88
  """Convert metrics to JSON-serializable dictionary with data/metadata structure.
@@ -129,7 +133,13 @@ class CMJMetrics:
129
133
  # No metadata available
130
134
  metadata = {}
131
135
 
132
- return {"data": data, "metadata": metadata}
136
+ result: CMJResultDict = {"data": data, "metadata": metadata}
137
+
138
+ # Include validation results if available
139
+ if self.validation_result is not None:
140
+ result["validation"] = self.validation_result.to_dict()
141
+
142
+ return result
133
143
 
134
144
 
135
145
  def calculate_cmj_metrics(
@@ -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."""
@@ -697,6 +727,70 @@ class CMJMetricsValidator:
697
727
  value=ankle,
698
728
  )
699
729
 
730
+ # Detect joint compensation patterns
731
+ self._check_joint_compensation_pattern(angles, result, profile)
732
+
733
+ def _check_joint_compensation_pattern(
734
+ self, angles: dict, result: ValidationResult, profile: AthleteProfile
735
+ ) -> None:
736
+ """Detect compensatory joint patterns in triple extension.
737
+
738
+ When one joint cannot achieve full extension, others may compensate.
739
+ Example: Limited hip extension (160°) with excessive knee extension (185°+)
740
+ suggests compensation rather than balanced movement quality.
741
+
742
+ This is a biomechanical quality indicator, not an error.
743
+ """
744
+ hip = angles.get("hip_angle")
745
+ knee = angles.get("knee_angle")
746
+ ankle = angles.get("ankle_angle")
747
+
748
+ if hip is None or knee is None or ankle is None:
749
+ return # Need all three to detect patterns
750
+
751
+ # Get profile-specific bounds
752
+ if profile == AthleteProfile.ELDERLY:
753
+ hip_min, hip_max = 150, 175
754
+ knee_min, knee_max = 155, 175
755
+ ankle_min, ankle_max = 100, 125
756
+ elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
757
+ hip_min, hip_max = 160, 180
758
+ knee_min, knee_max = 165, 182
759
+ ankle_min, ankle_max = 110, 140
760
+ elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
761
+ hip_min, hip_max = 170, 185
762
+ knee_min, knee_max = 173, 190
763
+ ankle_min, ankle_max = 125, 155
764
+ else:
765
+ return
766
+
767
+ # Count how many joints are near their boundaries
768
+ joints_at_boundary = 0
769
+ boundary_threshold = 3.0 # degrees from limit
770
+
771
+ if hip <= hip_min + boundary_threshold or hip >= hip_max - boundary_threshold:
772
+ joints_at_boundary += 1
773
+ if (
774
+ knee <= knee_min + boundary_threshold
775
+ or knee >= knee_max - boundary_threshold
776
+ ):
777
+ joints_at_boundary += 1
778
+ if (
779
+ ankle <= ankle_min + boundary_threshold
780
+ or ankle >= ankle_max - boundary_threshold
781
+ ):
782
+ joints_at_boundary += 1
783
+
784
+ # If 2+ joints at boundaries, likely compensation pattern
785
+ if joints_at_boundary >= 2:
786
+ result.add_info(
787
+ "joint_compensation",
788
+ f"Multiple joints near extension limits (hip={hip:.0f}°, "
789
+ f"knee={knee:.0f}°, ankle={ankle:.0f}°). "
790
+ f"May indicate compensatory movement pattern.",
791
+ value=float(joints_at_boundary),
792
+ )
793
+
700
794
  @staticmethod
701
795
  def _get_profile_range(
702
796
  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
 
@@ -0,0 +1,343 @@
1
+ """Drop jump metrics validation using physiological bounds.
2
+
3
+ Comprehensive validation of Drop Jump metrics against biomechanical bounds,
4
+ consistency checks, and cross-validation of RSI calculation.
5
+
6
+ Provides severity levels (ERROR, WARNING, INFO) for different categories
7
+ of metric issues.
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+
13
+ from kinemotion.core.dropjump_validation_bounds import (
14
+ AthleteProfile,
15
+ DropJumpBounds,
16
+ estimate_athlete_profile,
17
+ )
18
+
19
+
20
+ class ValidationSeverity(Enum):
21
+ """Severity level for validation issues."""
22
+
23
+ ERROR = "ERROR" # Metrics invalid, likely data corruption
24
+ WARNING = "WARNING" # Metrics valid but unusual, needs review
25
+ INFO = "INFO" # Normal variation, informational only
26
+
27
+
28
+ @dataclass
29
+ class ValidationIssue:
30
+ """Single validation issue."""
31
+
32
+ severity: ValidationSeverity
33
+ metric: str
34
+ message: str
35
+ value: float | None = None
36
+ bounds: tuple[float, float] | None = None
37
+
38
+
39
+ @dataclass
40
+ class ValidationResult:
41
+ """Complete validation result for drop jump metrics."""
42
+
43
+ issues: list[ValidationIssue] = field(default_factory=list)
44
+ status: str = "PASS" # "PASS", "PASS_WITH_WARNINGS", "FAIL"
45
+ athlete_profile: AthleteProfile | None = None
46
+ rsi: float | None = None
47
+ contact_flight_ratio: float | None = None
48
+ height_kinematic_trajectory_consistency: float | None = None # % error
49
+
50
+ def add_error(
51
+ self,
52
+ metric: str,
53
+ message: str,
54
+ value: float | None = None,
55
+ bounds: tuple[float, float] | None = None,
56
+ ) -> None:
57
+ """Add error-level issue."""
58
+ self.issues.append(
59
+ ValidationIssue(
60
+ severity=ValidationSeverity.ERROR,
61
+ metric=metric,
62
+ message=message,
63
+ value=value,
64
+ bounds=bounds,
65
+ )
66
+ )
67
+
68
+ def add_warning(
69
+ self,
70
+ metric: str,
71
+ message: str,
72
+ value: float | None = None,
73
+ bounds: tuple[float, float] | None = None,
74
+ ) -> None:
75
+ """Add warning-level issue."""
76
+ self.issues.append(
77
+ ValidationIssue(
78
+ severity=ValidationSeverity.WARNING,
79
+ metric=metric,
80
+ message=message,
81
+ value=value,
82
+ bounds=bounds,
83
+ )
84
+ )
85
+
86
+ def add_info(
87
+ self,
88
+ metric: str,
89
+ message: str,
90
+ value: float | None = None,
91
+ ) -> None:
92
+ """Add info-level issue."""
93
+ self.issues.append(
94
+ ValidationIssue(
95
+ severity=ValidationSeverity.INFO,
96
+ metric=metric,
97
+ message=message,
98
+ value=value,
99
+ )
100
+ )
101
+
102
+ def finalize_status(self) -> None:
103
+ """Determine final pass/fail status based on issues."""
104
+ has_errors = any(
105
+ issue.severity == ValidationSeverity.ERROR for issue in self.issues
106
+ )
107
+ has_warnings = any(
108
+ issue.severity == ValidationSeverity.WARNING for issue in self.issues
109
+ )
110
+
111
+ if has_errors:
112
+ self.status = "FAIL"
113
+ elif has_warnings:
114
+ self.status = "PASS_WITH_WARNINGS"
115
+ else:
116
+ self.status = "PASS"
117
+
118
+ def to_dict(self) -> dict:
119
+ """Convert validation result to JSON-serializable dictionary.
120
+
121
+ Returns:
122
+ Dictionary with status, issues, and consistency metrics.
123
+ """
124
+ return {
125
+ "status": self.status,
126
+ "issues": [
127
+ {
128
+ "severity": issue.severity.value,
129
+ "metric": issue.metric,
130
+ "message": issue.message,
131
+ "value": issue.value,
132
+ "bounds": issue.bounds,
133
+ }
134
+ for issue in self.issues
135
+ ],
136
+ "athlete_profile": (
137
+ self.athlete_profile.value if self.athlete_profile else None
138
+ ),
139
+ "rsi": self.rsi,
140
+ "contact_flight_ratio": self.contact_flight_ratio,
141
+ "height_kinematic_trajectory_consistency_percent": (
142
+ self.height_kinematic_trajectory_consistency
143
+ ),
144
+ }
145
+
146
+
147
+ class DropJumpMetricsValidator:
148
+ """Comprehensive drop jump metrics validator."""
149
+
150
+ def __init__(self, assumed_profile: AthleteProfile | None = None):
151
+ """Initialize validator.
152
+
153
+ Args:
154
+ assumed_profile: If provided, validate against this specific profile.
155
+ Otherwise, estimate from metrics.
156
+ """
157
+ self.assumed_profile = assumed_profile
158
+
159
+ def validate(self, metrics: dict) -> ValidationResult:
160
+ """Validate drop jump metrics comprehensively.
161
+
162
+ Args:
163
+ metrics: Dictionary with drop jump metric values
164
+
165
+ Returns:
166
+ ValidationResult with all issues and status
167
+ """
168
+ result = ValidationResult()
169
+
170
+ # Estimate athlete profile if not provided
171
+ if self.assumed_profile:
172
+ result.athlete_profile = self.assumed_profile
173
+ else:
174
+ result.athlete_profile = estimate_athlete_profile(metrics)
175
+
176
+ # Extract metric values (handle nested "data" structure)
177
+ data = metrics.get("data", metrics) # Support both structures
178
+
179
+ contact_time_ms = data.get("ground_contact_time_ms")
180
+ flight_time_ms = data.get("flight_time_ms")
181
+ jump_height_m = data.get("jump_height_m")
182
+ jump_height_kinematic_m = data.get("jump_height_kinematic_m")
183
+ jump_height_trajectory_m = data.get("jump_height_trajectory_normalized")
184
+
185
+ # Validate individual metrics
186
+ if contact_time_ms is not None:
187
+ self._check_contact_time(contact_time_ms, result)
188
+
189
+ if flight_time_ms is not None:
190
+ self._check_flight_time(flight_time_ms, result)
191
+
192
+ if jump_height_m is not None:
193
+ self._check_jump_height(jump_height_m, result)
194
+
195
+ # Cross-validation
196
+ if contact_time_ms is not None and flight_time_ms is not None:
197
+ self._check_rsi(contact_time_ms, flight_time_ms, result)
198
+
199
+ # Dual height validation (kinematic vs trajectory)
200
+ if jump_height_kinematic_m is not None and jump_height_trajectory_m is not None:
201
+ self._check_dual_height_consistency(
202
+ jump_height_kinematic_m, jump_height_trajectory_m, result
203
+ )
204
+
205
+ # Finalize status
206
+ result.finalize_status()
207
+
208
+ return result
209
+
210
+ def _check_contact_time(
211
+ self, contact_time_ms: float, result: ValidationResult
212
+ ) -> None:
213
+ """Validate contact time."""
214
+ contact_time_s = contact_time_ms / 1000.0
215
+ bounds = DropJumpBounds.CONTACT_TIME
216
+
217
+ if not bounds.is_physically_possible(contact_time_s):
218
+ result.add_error(
219
+ "contact_time",
220
+ f"Contact time {contact_time_s:.3f}s physically impossible",
221
+ value=contact_time_s,
222
+ bounds=(bounds.absolute_min, bounds.absolute_max),
223
+ )
224
+ elif result.athlete_profile and not bounds.contains(
225
+ contact_time_s, result.athlete_profile
226
+ ):
227
+ profile_name = result.athlete_profile.value
228
+ result.add_warning(
229
+ "contact_time",
230
+ f"Contact time {contact_time_s:.3f}s unusual for {profile_name} athlete",
231
+ value=contact_time_s,
232
+ )
233
+
234
+ def _check_flight_time(
235
+ self, flight_time_ms: float, result: ValidationResult
236
+ ) -> None:
237
+ """Validate flight time."""
238
+ flight_time_s = flight_time_ms / 1000.0
239
+ bounds = DropJumpBounds.FLIGHT_TIME
240
+
241
+ if not bounds.is_physically_possible(flight_time_s):
242
+ result.add_error(
243
+ "flight_time",
244
+ f"Flight time {flight_time_s:.3f}s physically impossible",
245
+ value=flight_time_s,
246
+ bounds=(bounds.absolute_min, bounds.absolute_max),
247
+ )
248
+ elif result.athlete_profile and not bounds.contains(
249
+ flight_time_s, result.athlete_profile
250
+ ):
251
+ profile_name = result.athlete_profile.value
252
+ result.add_warning(
253
+ "flight_time",
254
+ f"Flight time {flight_time_s:.3f}s unusual for {profile_name} athlete",
255
+ value=flight_time_s,
256
+ )
257
+
258
+ def _check_jump_height(
259
+ self, jump_height_m: float, result: ValidationResult
260
+ ) -> None:
261
+ """Validate jump height."""
262
+ bounds = DropJumpBounds.JUMP_HEIGHT
263
+
264
+ if not bounds.is_physically_possible(jump_height_m):
265
+ result.add_error(
266
+ "jump_height",
267
+ f"Jump height {jump_height_m:.3f}m physically impossible",
268
+ value=jump_height_m,
269
+ bounds=(bounds.absolute_min, bounds.absolute_max),
270
+ )
271
+ elif result.athlete_profile and not bounds.contains(
272
+ jump_height_m, result.athlete_profile
273
+ ):
274
+ profile_name = result.athlete_profile.value
275
+ result.add_warning(
276
+ "jump_height",
277
+ f"Jump height {jump_height_m:.3f}m unusual for {profile_name} athlete",
278
+ value=jump_height_m,
279
+ )
280
+
281
+ def _check_rsi(
282
+ self, contact_time_ms: float, flight_time_ms: float, result: ValidationResult
283
+ ) -> None:
284
+ """Validate RSI and cross-check consistency."""
285
+ contact_time_s = contact_time_ms / 1000.0
286
+ flight_time_s = flight_time_ms / 1000.0
287
+
288
+ if contact_time_s > 0:
289
+ rsi = flight_time_s / contact_time_s
290
+ result.rsi = rsi
291
+ result.contact_flight_ratio = contact_time_s / flight_time_s
292
+
293
+ bounds = DropJumpBounds.RSI
294
+
295
+ if not bounds.is_physically_possible(rsi):
296
+ result.add_error(
297
+ "rsi",
298
+ f"RSI {rsi:.2f} physically impossible",
299
+ value=rsi,
300
+ bounds=(bounds.absolute_min, bounds.absolute_max),
301
+ )
302
+ elif result.athlete_profile and not bounds.contains(
303
+ rsi, result.athlete_profile
304
+ ):
305
+ result.add_warning(
306
+ "rsi",
307
+ f"RSI {rsi:.2f} unusual for {result.athlete_profile.value} athlete",
308
+ value=rsi,
309
+ )
310
+
311
+ def _check_dual_height_consistency(
312
+ self,
313
+ jump_height_kinematic_m: float,
314
+ jump_height_trajectory_m: float,
315
+ result: ValidationResult,
316
+ ) -> None:
317
+ """Validate consistency between kinematic and trajectory-based heights.
318
+
319
+ Kinematic height (h = g*t²/8) comes from flight time (objective).
320
+ Trajectory height comes from position tracking (subject to landmark detection noise).
321
+
322
+ Expected correlation: r > 0.95, absolute difference < 5% for quality video.
323
+ """
324
+ if jump_height_kinematic_m <= 0 or jump_height_trajectory_m <= 0:
325
+ return # Skip if either value is missing or invalid
326
+
327
+ # Calculate percentage difference
328
+ avg_height = (jump_height_kinematic_m + jump_height_trajectory_m) / 2.0
329
+ if avg_height > 0:
330
+ abs_diff = abs(jump_height_kinematic_m - jump_height_trajectory_m)
331
+ percent_error = (abs_diff / avg_height) * 100.0
332
+ result.height_kinematic_trajectory_consistency = percent_error
333
+
334
+ # Allow 10% tolerance for typical video processing noise
335
+ if percent_error > 10.0:
336
+ result.add_warning(
337
+ "height_consistency",
338
+ f"Kinematic ({jump_height_kinematic_m:.3f}m) and trajectory "
339
+ f"({jump_height_trajectory_m:.3f}m) heights differ by {percent_error:.1f}%. "
340
+ f"May indicate landmark detection issues or video quality problems.",
341
+ value=percent_error,
342
+ bounds=(0, 10),
343
+ )
@@ -0,0 +1,196 @@
1
+ """Drop jump metrics physiological bounds for validation testing.
2
+
3
+ This module defines realistic physiological bounds for Drop Jump metrics
4
+ based on biomechanical literature and real-world athlete performance.
5
+
6
+ Drop jump metrics differ from CMJ:
7
+ - Contact time (ground interaction during landing)
8
+ - Flight time (time in air after landing)
9
+ - RSI (Reactive Strength Index) = flight_time / contact_time
10
+ - Jump height (calculated from flight time)
11
+
12
+ References:
13
+ - Komi & Bosco (1978): Drop jump RSI and elastic properties
14
+ - Flanagan & Comyns (2008): RSI reliability and athlete assessment
15
+ - Covens et al. (2019): Drop jump kinetics across athletes
16
+ """
17
+
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+
21
+
22
+ class AthleteProfile(Enum):
23
+ """Athlete performance categories for metric bounds."""
24
+
25
+ ELDERLY = "elderly" # 70+, deconditioned
26
+ UNTRAINED = "untrained" # Sedentary, no training
27
+ RECREATIONAL = "recreational" # Fitness class, moderate activity
28
+ TRAINED = "trained" # Regular athlete, 3-5 years training
29
+ ELITE = "elite" # Competitive athlete, college/professional level
30
+
31
+
32
+ @dataclass
33
+ class MetricBounds:
34
+ """Physiological bounds for a single metric.
35
+
36
+ Attributes:
37
+ absolute_min: Absolute minimum value (error threshold)
38
+ practical_min: Practical minimum for weakest athletes
39
+ recreational_min: Minimum for recreational athletes
40
+ recreational_max: Maximum for recreational athletes
41
+ elite_min: Minimum for elite athletes
42
+ elite_max: Maximum for elite athletes
43
+ absolute_max: Absolute maximum value (error threshold)
44
+ unit: Unit of measurement (e.g., "s", "m", "ratio")
45
+ """
46
+
47
+ absolute_min: float
48
+ practical_min: float
49
+ recreational_min: float
50
+ recreational_max: float
51
+ elite_min: float
52
+ elite_max: float
53
+ absolute_max: float
54
+ unit: str
55
+
56
+ def contains(self, value: float, profile: AthleteProfile) -> bool:
57
+ """Check if value is within bounds for athlete profile."""
58
+ if profile == AthleteProfile.ELDERLY:
59
+ return self.practical_min <= value <= self.recreational_max
60
+ elif profile == AthleteProfile.UNTRAINED:
61
+ return self.practical_min <= value <= self.recreational_max
62
+ elif profile == AthleteProfile.RECREATIONAL:
63
+ return self.recreational_min <= value <= self.recreational_max
64
+ elif profile == AthleteProfile.TRAINED:
65
+ # Trained athletes: midpoint between recreational and elite
66
+ trained_min = (self.recreational_min + self.elite_min) / 2
67
+ trained_max = (self.recreational_max + self.elite_max) / 2
68
+ return trained_min <= value <= trained_max
69
+ elif profile == AthleteProfile.ELITE:
70
+ return self.elite_min <= value <= self.elite_max
71
+ return False
72
+
73
+ def is_physically_possible(self, value: float) -> bool:
74
+ """Check if value is within absolute physiological limits."""
75
+ return self.absolute_min <= value <= self.absolute_max
76
+
77
+
78
+ class DropJumpBounds:
79
+ """Collection of physiological bounds for all drop jump metrics."""
80
+
81
+ # GROUND CONTACT TIME (seconds, landing interaction)
82
+ CONTACT_TIME = MetricBounds(
83
+ absolute_min=0.08, # Physiological minimum: neural delay + deceleration
84
+ practical_min=0.15, # Extreme plyometric
85
+ recreational_min=0.35, # Typical landing
86
+ recreational_max=0.70, # Slower absorption
87
+ elite_min=0.20,
88
+ elite_max=0.50,
89
+ absolute_max=1.50,
90
+ unit="s",
91
+ )
92
+
93
+ # FLIGHT TIME (seconds, after landing)
94
+ FLIGHT_TIME = MetricBounds(
95
+ absolute_min=0.30,
96
+ practical_min=0.40, # Minimal jump
97
+ recreational_min=0.50,
98
+ recreational_max=0.85,
99
+ elite_min=0.65,
100
+ elite_max=1.10,
101
+ absolute_max=1.40,
102
+ unit="s",
103
+ )
104
+
105
+ # JUMP HEIGHT (meters, calculated from flight time)
106
+ JUMP_HEIGHT = MetricBounds(
107
+ absolute_min=0.05,
108
+ practical_min=0.10,
109
+ recreational_min=0.25,
110
+ recreational_max=0.65,
111
+ elite_min=0.50,
112
+ elite_max=1.00,
113
+ absolute_max=1.30,
114
+ unit="m",
115
+ )
116
+
117
+ # REACTIVE STRENGTH INDEX (RSI) = flight_time / contact_time (ratio, no unit)
118
+ RSI = MetricBounds(
119
+ absolute_min=0.30, # Very poor reactive ability
120
+ practical_min=0.50,
121
+ recreational_min=0.70,
122
+ recreational_max=1.80,
123
+ elite_min=1.50, # Elite: fast contact, long flight
124
+ elite_max=3.50,
125
+ absolute_max=5.00,
126
+ unit="ratio",
127
+ )
128
+
129
+
130
+ def estimate_athlete_profile(
131
+ metrics: dict, gender: str | None = None
132
+ ) -> AthleteProfile:
133
+ """Estimate athlete profile from drop jump metrics.
134
+
135
+ Uses jump_height and contact_time to classify athlete level.
136
+
137
+ NOTE: Bounds are calibrated for adult males. Female athletes typically achieve
138
+ 60-70% of male heights due to lower muscle mass and strength. If analyzing
139
+ female athletes, interpret results one level lower than classification suggests.
140
+
141
+ Args:
142
+ metrics: Dictionary with drop jump metric values
143
+ gender: Optional gender for context ("M"/"F"). Currently informational only.
144
+
145
+ Returns:
146
+ Estimated AthleteProfile (ELDERLY, UNTRAINED, RECREATIONAL, TRAINED, or ELITE)
147
+ """
148
+ jump_height = metrics.get("data", {}).get("jump_height_m")
149
+ contact_time = metrics.get("data", {}).get("ground_contact_time_ms")
150
+
151
+ if jump_height is None or contact_time is None:
152
+ return AthleteProfile.RECREATIONAL # Default
153
+
154
+ # Convert contact_time from ms to seconds
155
+ contact_time_s = contact_time / 1000.0
156
+
157
+ # Decision logic: Use weighted combination to avoid over-weighting single metrics
158
+ # Calculate profile scores based on each metric
159
+ height_score = 0.0
160
+ if jump_height < 0.25:
161
+ height_score = 0 # Elderly
162
+ elif jump_height < 0.35:
163
+ height_score = 1 # Untrained
164
+ elif jump_height < 0.50:
165
+ height_score = 2 # Recreational
166
+ elif jump_height < 0.70:
167
+ height_score = 3 # Trained
168
+ else:
169
+ height_score = 4 # Elite
170
+
171
+ contact_score = 0.0
172
+ if contact_time_s > 0.60:
173
+ contact_score = 0 # Elderly
174
+ elif contact_time_s > 0.50:
175
+ contact_score = 1 # Untrained
176
+ elif contact_time_s > 0.45:
177
+ contact_score = 2 # Recreational
178
+ elif contact_time_s > 0.40:
179
+ contact_score = 3 # Trained
180
+ else:
181
+ contact_score = 4 # Elite
182
+
183
+ # Weight height more heavily (70%) than contact time (30%)
184
+ # Height is more reliable indicator across populations
185
+ combined_score = (height_score * 0.70) + (contact_score * 0.30)
186
+
187
+ if combined_score < 1.0:
188
+ return AthleteProfile.ELDERLY
189
+ elif combined_score < 1.7:
190
+ return AthleteProfile.UNTRAINED
191
+ elif combined_score < 2.7:
192
+ return AthleteProfile.RECREATIONAL
193
+ elif combined_score < 3.7:
194
+ return AthleteProfile.TRAINED
195
+ else:
196
+ return AthleteProfile.ELITE
@@ -16,6 +16,7 @@ from .analysis import (
16
16
  )
17
17
 
18
18
  if TYPE_CHECKING:
19
+ from ..core.dropjump_metrics_validator import ValidationResult
19
20
  from ..core.metadata import ResultMetadata
20
21
  from ..core.quality import QualityAssessment
21
22
 
@@ -39,11 +40,12 @@ class DropJumpDataDict(TypedDict, total=False):
39
40
  flight_end_frame_precise: float | None
40
41
 
41
42
 
42
- class DropJumpResultDict(TypedDict):
43
+ class DropJumpResultDict(TypedDict, total=False):
43
44
  """Type-safe dictionary for complete drop jump result with data and metadata."""
44
45
 
45
46
  data: DropJumpDataDict
46
47
  metadata: dict # ResultMetadata.to_dict()
48
+ validation: dict # ValidationResult.to_dict()
47
49
 
48
50
 
49
51
  class DropJumpMetrics:
@@ -69,6 +71,8 @@ class DropJumpMetrics:
69
71
  self.quality_assessment: QualityAssessment | None = None
70
72
  # Complete metadata
71
73
  self.result_metadata: ResultMetadata | None = None
74
+ # Validation result
75
+ self.validation_result: ValidationResult | None = None
72
76
 
73
77
  def _build_data_dict(self) -> DropJumpDataDict:
74
78
  """Build the data portion of the result dictionary.
@@ -125,11 +129,17 @@ class DropJumpMetrics:
125
129
  Returns:
126
130
  Dictionary with nested data and metadata structure.
127
131
  """
128
- return {
132
+ result: DropJumpResultDict = {
129
133
  "data": self._build_data_dict(),
130
134
  "metadata": self._build_metadata_dict(),
131
135
  }
132
136
 
137
+ # Include validation results if available
138
+ if self.validation_result is not None:
139
+ result["validation"] = self.validation_result.to_dict()
140
+
141
+ return result
142
+
133
143
 
134
144
  def _determine_drop_start_frame(
135
145
  drop_start_frame: int | None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kinemotion
3
- Version: 0.29.2
3
+ Version: 0.30.0
4
4
  Summary: Video-based kinematic analysis for athletic performance
5
5
  Project-URL: Homepage, https://github.com/feniix/kinemotion
6
6
  Project-URL: Repository, https://github.com/feniix/kinemotion
@@ -36,6 +36,7 @@ Description-Content-Type: text/markdown
36
36
  [![Tests](https://github.com/feniix/kinemotion/workflows/Test%20%26%20Quality/badge.svg)](https://github.com/feniix/kinemotion/actions)
37
37
  [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=feniix_kinemotion&metric=alert_status)](https://sonarcloud.io/summary/overall?id=feniix_kinemotion)
38
38
  [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=feniix_kinemotion&metric=coverage)](https://sonarcloud.io/summary/overall?id=feniix_kinemotion)
39
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/11506/badge)](https://www.bestpractices.dev/projects/11506)
39
40
 
40
41
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
41
42
  [![Type checked with pyright](https://img.shields.io/badge/type%20checked-pyright-blue.svg)](https://github.com/microsoft/pyright)
@@ -695,7 +696,7 @@ Before committing code, ensure all checks pass:
695
696
  1. Ensure type safety with pyright
696
697
  1. Run all tests with pytest
697
698
 
698
- See [CLAUDE.md](CLAUDE.md) for detailed development guidelines.
699
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and requirements, or [CLAUDE.md](CLAUDE.md) for detailed development guidelines.
699
700
 
700
701
  ## Limitations
701
702
 
@@ -1,18 +1,20 @@
1
1
  kinemotion/__init__.py,sha256=sxdDOekOrIgjxm842gy-6zfq7OWmGl9ShJtXCm4JI7c,723
2
- kinemotion/api.py,sha256=tbkjXsfe0N0Bmik6XRIOYM7Nom4QqeQJSpDx7IoiuSA,38177
2
+ kinemotion/api.py,sha256=ozt6GLZtw2ZWki0tBlkpOQgcNH_M7GfrRPlc_Rg8ROw,39284
3
3
  kinemotion/cli.py,sha256=cqYV_7URH0JUDy1VQ_EDLv63FmNO4Ns20m6s1XAjiP4,464
4
4
  kinemotion/cmj/__init__.py,sha256=Ynv0-Oco4I3Y1Ubj25m3h9h2XFqeNwpAewXmAYOmwfU,127
5
5
  kinemotion/cmj/analysis.py,sha256=il7-sfM89ZetxLhmw9boViaP4E8Y3mlS_XI-B5txmMs,19795
6
6
  kinemotion/cmj/cli.py,sha256=12FEfWrseG4kCUbgHHdBPkWp6zzVQ0VAzfgNJotArmM,10792
7
7
  kinemotion/cmj/debug_overlay.py,sha256=D-y2FQKI01KY0WXFKTKg6p9Qj3AkXCE7xjau3Ais080,15886
8
8
  kinemotion/cmj/joint_angles.py,sha256=HmheIEiKcQz39cRezk4h-htorOhGNPsqKIR9RsAEKts,9960
9
- kinemotion/cmj/kinematics.py,sha256=-iBFg2AkQR4LaThCQzO09fx6qJed27ZfMDQJgE7Si4k,9772
9
+ kinemotion/cmj/kinematics.py,sha256=ax2RijtAWItZPNRmNr-CvC7bOSsZQw2qdCEnm5hUUpU,10247
10
10
  kinemotion/core/__init__.py,sha256=HsqolRa60cW3vrG8F9Lvr9WvWcs5hCmsTzSgo7imi-4,1278
11
11
  kinemotion/core/auto_tuning.py,sha256=j6cul_qC6k0XyryCG93C1AWH2MKPj3UBMzuX02xaqfI,11235
12
12
  kinemotion/core/cli_utils.py,sha256=Pq1JF7yvK1YbH0tOUWKjplthCbWsJQt4Lv7esPYH4FM,7254
13
- kinemotion/core/cmj_metrics_validator.py,sha256=Jfh8oxhxz5BCBIPdeMRHa60tZsliDkN10RiLQYkmck4,27262
14
- kinemotion/core/cmj_validation_bounds.py,sha256=WBMuJx6ewb-rYan3xmQu32m7bs9h8J5isa4LduZuZkI,13507
13
+ kinemotion/core/cmj_metrics_validator.py,sha256=bbOPTbFqDEZv3lDA8qejjCcMqXE7TYvHizCcWzHRW9Y,30902
14
+ kinemotion/core/cmj_validation_bounds.py,sha256=NXW0d4S8hDqSkzAiyX9UyWDnKsWf61ygEKTKE2a1uNc,14069
15
15
  kinemotion/core/debug_overlay_utils.py,sha256=TyUb5okv5qw8oeaX3jsUO_kpwf1NnaHEAOTm-8LwTno,4587
16
+ kinemotion/core/dropjump_metrics_validator.py,sha256=yGcg8ub6Z791qj5BxCn9mHin608tfxvxxfIeTql8HcY,11967
17
+ kinemotion/core/dropjump_validation_bounds.py,sha256=Ow7T-0IK_qy0k9UZdiCuT_tttxklWAkuXFalQkF8Jbs,6927
16
18
  kinemotion/core/filtering.py,sha256=f-m-aA59e4WqE6u-9MA51wssu7rI-Y_7n1cG8IWdeRQ,11241
17
19
  kinemotion/core/formatting.py,sha256=G_3eqgOtym9RFOZVEwCxye4A2cyrmgvtQ214vIshowU,2480
18
20
  kinemotion/core/metadata.py,sha256=PyGHL6sx7Hj21lyorg2VsWP9BGTj_y_-wWU6eKCEfJo,6817
@@ -24,10 +26,10 @@ kinemotion/dropjump/__init__.py,sha256=yc1XiZ9vfo5h_n7PKVSiX2TTgaIfGL7Y7SkQtiDZj
24
26
  kinemotion/dropjump/analysis.py,sha256=BQ5NqSPNJjFQOb-W4bXSLvjCgWd-nvqx5NElyeqZJC4,29067
25
27
  kinemotion/dropjump/cli.py,sha256=ZyroaYPwz8TgfL39Wcaj6m68Awl6lYXC75ttaflU-c0,16236
26
28
  kinemotion/dropjump/debug_overlay.py,sha256=LkPw6ucb7beoYWS4L-Lvjs1KLCm5wAWDAfiznUeV2IQ,5668
27
- kinemotion/dropjump/kinematics.py,sha256=Yr3G7AQwtYy1dxyeOAYfqqgd4pzoZwWQAhZzxI5RbnE,16658
29
+ kinemotion/dropjump/kinematics.py,sha256=AfqIS8kaI3B8olPX9EY1QxQsuNmuJA5GRsI1EL4NHHg,17091
28
30
  kinemotion/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
- kinemotion-0.29.2.dist-info/METADATA,sha256=ISixUGRnj_7NlQFCRm7BvHG3LWQXjGUS5LiOBHHtfeE,25810
30
- kinemotion-0.29.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
31
- kinemotion-0.29.2.dist-info/entry_points.txt,sha256=zaqnAnjLvcdrk1Qvj5nvXZCZ2gp0prS7it1zTJygcIY,50
32
- kinemotion-0.29.2.dist-info/licenses/LICENSE,sha256=KZajvqsHw0NoOHOi2q0FZ4NBe9HdV6oey-IPYAtHXfg,1088
33
- kinemotion-0.29.2.dist-info/RECORD,,
31
+ kinemotion-0.30.0.dist-info/METADATA,sha256=AUhSzQW2siu-R4KfUyS5YRDy1i9eYzzYxOMdWZXF2ww,26020
32
+ kinemotion-0.30.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
33
+ kinemotion-0.30.0.dist-info/entry_points.txt,sha256=zaqnAnjLvcdrk1Qvj5nvXZCZ2gp0prS7it1zTJygcIY,50
34
+ kinemotion-0.30.0.dist-info/licenses/LICENSE,sha256=KZajvqsHw0NoOHOi2q0FZ4NBe9HdV6oey-IPYAtHXfg,1088
35
+ kinemotion-0.30.0.dist-info/RECORD,,