kinemotion 0.27.0__py3-none-any.whl → 0.29.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.

@@ -0,0 +1,380 @@
1
+ """CMJ metrics physiological bounds for validation testing.
2
+
3
+ This module defines realistic physiological bounds for Counter Movement Jump (CMJ)
4
+ metrics based on biomechanical literature and real-world athlete performance.
5
+
6
+ These bounds are used to:
7
+ 1. Prevent false positives from measurement noise
8
+ 2. Catch real errors in video processing and phase detection
9
+ 3. Provide athlete-profile-appropriate validation
10
+ 4. Enable cross-validation of metric consistency
11
+
12
+ References:
13
+ - Nordez et al. (2009): CMJ depth-height relationships
14
+ - Cormie et al. (2011): Power generation characteristics
15
+ - Bogdanis (2012): Plyometric training effects
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., "m", "s", "m/s", "degrees")
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 CMJBounds:
79
+ """Collection of physiological bounds for all CMJ metrics."""
80
+
81
+ # FLIGHT TIME (seconds)
82
+ FLIGHT_TIME = MetricBounds(
83
+ absolute_min=0.08, # Frame rate resolution limit
84
+ practical_min=0.15, # Minimum effort jump
85
+ recreational_min=0.25, # Untrained ~10cm
86
+ recreational_max=0.70, # Recreational ~30-50cm
87
+ elite_min=0.65, # Trained ~60cm
88
+ elite_max=1.10, # Elite >100cm
89
+ absolute_max=1.30,
90
+ unit="s",
91
+ )
92
+
93
+ # JUMP HEIGHT (meters)
94
+ JUMP_HEIGHT = MetricBounds(
95
+ absolute_min=0.02,
96
+ practical_min=0.05,
97
+ recreational_min=0.15, # Untrained with effort
98
+ recreational_max=0.60, # Good recreational
99
+ elite_min=0.65,
100
+ elite_max=1.00,
101
+ absolute_max=1.30,
102
+ unit="m",
103
+ )
104
+
105
+ # COUNTERMOVEMENT DEPTH (meters)
106
+ COUNTERMOVEMENT_DEPTH = MetricBounds(
107
+ absolute_min=0.05,
108
+ practical_min=0.08, # Minimal squat
109
+ recreational_min=0.20, # Shallow to parallel
110
+ recreational_max=0.55, # Normal to deep squat
111
+ elite_min=0.40,
112
+ elite_max=0.75,
113
+ absolute_max=1.10, # Only extreme tall athletes
114
+ unit="m",
115
+ )
116
+
117
+ # CONCENTRIC DURATION / CONTACT TIME (seconds)
118
+ CONCENTRIC_DURATION = MetricBounds(
119
+ absolute_min=0.08,
120
+ practical_min=0.10, # Extreme plyometric
121
+ recreational_min=0.40, # Moderate propulsion
122
+ recreational_max=0.90, # Slow push-off
123
+ elite_min=0.25,
124
+ elite_max=0.50,
125
+ absolute_max=1.80,
126
+ unit="s",
127
+ )
128
+
129
+ # ECCENTRIC DURATION (seconds)
130
+ ECCENTRIC_DURATION = MetricBounds(
131
+ absolute_min=0.15,
132
+ practical_min=0.25,
133
+ recreational_min=0.35,
134
+ recreational_max=0.75,
135
+ elite_min=0.30,
136
+ elite_max=0.65,
137
+ absolute_max=1.30,
138
+ unit="s",
139
+ )
140
+
141
+ # TOTAL MOVEMENT TIME (seconds)
142
+ TOTAL_MOVEMENT_TIME = MetricBounds(
143
+ absolute_min=0.25,
144
+ practical_min=0.35,
145
+ recreational_min=0.75,
146
+ recreational_max=1.50,
147
+ elite_min=0.55,
148
+ elite_max=1.10,
149
+ absolute_max=2.20,
150
+ unit="s",
151
+ )
152
+
153
+ # PEAK ECCENTRIC VELOCITY (m/s, downward)
154
+ PEAK_ECCENTRIC_VELOCITY = MetricBounds(
155
+ absolute_min=0.10,
156
+ practical_min=0.20,
157
+ recreational_min=0.80,
158
+ recreational_max=2.00,
159
+ elite_min=2.00,
160
+ elite_max=3.50,
161
+ absolute_max=4.50,
162
+ unit="m/s",
163
+ )
164
+
165
+ # PEAK CONCENTRIC VELOCITY (m/s, upward)
166
+ PEAK_CONCENTRIC_VELOCITY = MetricBounds(
167
+ absolute_min=0.30,
168
+ practical_min=0.50,
169
+ recreational_min=1.80,
170
+ recreational_max=2.80,
171
+ elite_min=3.00,
172
+ elite_max=4.20,
173
+ absolute_max=5.00,
174
+ unit="m/s",
175
+ )
176
+
177
+
178
+ class TripleExtensionBounds:
179
+ """Physiological bounds for triple extension angles (degrees)."""
180
+
181
+ # HIP ANGLE at takeoff (close to 180° = full extension)
182
+ @staticmethod
183
+ def hip_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
184
+ """Check if hip angle is valid for profile."""
185
+ if angle is None:
186
+ return True # May not be detectable
187
+ if angle < 120 or angle > 195:
188
+ return False # Outside physiological limits
189
+ if profile == AthleteProfile.ELDERLY:
190
+ return 150 <= angle <= 175
191
+ elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
192
+ return 160 <= angle <= 180
193
+ elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
194
+ return 170 <= angle <= 185
195
+ return True
196
+
197
+ # KNEE ANGLE at takeoff (close to 180° = full extension)
198
+ @staticmethod
199
+ def knee_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
200
+ """Check if knee angle is valid for profile."""
201
+ if angle is None:
202
+ return True
203
+ if angle < 130 or angle > 200:
204
+ return False # Outside physiological limits
205
+ if profile == AthleteProfile.ELDERLY:
206
+ return 155 <= angle <= 175
207
+ elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
208
+ return 165 <= angle <= 182
209
+ elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
210
+ return 173 <= angle <= 190
211
+ return True
212
+
213
+ # ANKLE ANGLE at takeoff (120-155° = plantarflexion, 90° = neutral)
214
+ @staticmethod
215
+ def ankle_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
216
+ """Check if ankle angle is valid for profile."""
217
+ if angle is None:
218
+ return True # Often not detectable in side view
219
+ if angle < 90 or angle > 165:
220
+ return False # Outside physiological limits
221
+ if profile == AthleteProfile.ELDERLY:
222
+ return 100 <= angle <= 125
223
+ elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
224
+ return 110 <= angle <= 140
225
+ elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
226
+ return 125 <= angle <= 155
227
+ return True
228
+
229
+ # TRUNK TILT (forward lean from vertical, degrees)
230
+ @staticmethod
231
+ def trunk_tilt_valid(angle: float | None, profile: AthleteProfile) -> bool:
232
+ """Check if trunk tilt is valid for profile."""
233
+ if angle is None:
234
+ return True
235
+ if angle < -15 or angle > 60:
236
+ return False # Outside reasonable range
237
+ # Most athletes show 10-30° forward lean during takeoff
238
+ return -10 <= angle <= 45
239
+
240
+
241
+ class RSIBounds:
242
+ """Reactive Strength Index bounds."""
243
+
244
+ # Calculated as: RSI = flight_time / contact_time
245
+ MIN_VALID = 0.30 # Below this: invalid metrics
246
+ MAX_VALID = 4.00 # Above this: invalid metrics
247
+
248
+ ELDERLY_RANGE = (0.15, 0.30)
249
+ UNTRAINED_RANGE = (0.30, 0.80)
250
+ RECREATIONAL_RANGE = (0.80, 1.50)
251
+ TRAINED_RANGE = (1.50, 2.40)
252
+ ELITE_RANGE = (2.20, 3.50)
253
+
254
+ @staticmethod
255
+ def get_rsi_range(profile: AthleteProfile) -> tuple[float, float]:
256
+ """Get expected RSI range for athlete profile."""
257
+ if profile == AthleteProfile.ELDERLY:
258
+ return RSIBounds.ELDERLY_RANGE
259
+ elif profile == AthleteProfile.UNTRAINED:
260
+ return RSIBounds.UNTRAINED_RANGE
261
+ elif profile == AthleteProfile.RECREATIONAL:
262
+ return RSIBounds.RECREATIONAL_RANGE
263
+ elif profile == AthleteProfile.TRAINED:
264
+ return RSIBounds.TRAINED_RANGE
265
+ elif profile == AthleteProfile.ELITE:
266
+ return RSIBounds.ELITE_RANGE
267
+ return (RSIBounds.MIN_VALID, RSIBounds.MAX_VALID)
268
+
269
+ @staticmethod
270
+ def is_valid(rsi: float) -> bool:
271
+ """Check if RSI is within physiological bounds."""
272
+ return RSIBounds.MIN_VALID <= rsi <= RSIBounds.MAX_VALID
273
+
274
+ @staticmethod
275
+ def in_range_for_profile(rsi: float, profile: AthleteProfile) -> bool:
276
+ """Check if RSI is in expected range for profile."""
277
+ min_rsi, max_rsi = RSIBounds.get_rsi_range(profile)
278
+ return min_rsi <= rsi <= max_rsi
279
+
280
+
281
+ class MetricConsistency:
282
+ """Cross-validation tolerance for metric consistency checks."""
283
+
284
+ # Jump height from flight time: h = g*t²/8
285
+ # Allow 10% deviation for measurement noise
286
+ HEIGHT_FLIGHT_TIME_TOLERANCE = 0.10
287
+
288
+ # Peak velocity from jump height: v = sqrt(2*g*h)
289
+ # Allow 15% deviation (velocity harder to detect precisely)
290
+ VELOCITY_HEIGHT_TOLERANCE = 0.15
291
+
292
+ # Countermovement depth to jump height ratio
293
+ # Typically 0.5-1.2, flag if outside 0.3-1.5
294
+ DEPTH_HEIGHT_RATIO_MIN = 0.30
295
+ DEPTH_HEIGHT_RATIO_MAX = 1.50
296
+
297
+ # Contact time to countermovement depth ratio
298
+ # Should be roughly 1.0-1.5 s/m
299
+ CONTACT_DEPTH_RATIO_MIN = 0.50
300
+ CONTACT_DEPTH_RATIO_MAX = 2.50
301
+
302
+
303
+ # Athlete profile examples with expected metric ranges
304
+ ATHLETE_PROFILES = {
305
+ "elderly_deconditioned": {
306
+ "label": "Elderly/Deconditioned (70+, sedentary)",
307
+ "profile": AthleteProfile.ELDERLY,
308
+ "expected": {
309
+ "jump_height_m": (0.10, 0.18),
310
+ "flight_time_s": (0.14, 0.19),
311
+ "countermovement_depth_m": (0.12, 0.20),
312
+ "concentric_duration_s": (0.80, 1.20),
313
+ "eccentric_duration_s": (0.60, 0.95),
314
+ "peak_eccentric_velocity_ms": (0.4, 0.7),
315
+ "peak_concentric_velocity_ms": (1.0, 1.5),
316
+ "rsi": (0.15, 0.25),
317
+ "hip_angle_deg": (150, 165),
318
+ "knee_angle_deg": (155, 170),
319
+ "ankle_angle_deg": (105, 125),
320
+ },
321
+ },
322
+ "recreational": {
323
+ "label": "Recreational Athlete (fitness participant, 30-45 yrs)",
324
+ "profile": AthleteProfile.RECREATIONAL,
325
+ "expected": {
326
+ "jump_height_m": (0.35, 0.55),
327
+ "flight_time_s": (0.53, 0.67),
328
+ "countermovement_depth_m": (0.28, 0.45),
329
+ "concentric_duration_s": (0.45, 0.65),
330
+ "eccentric_duration_s": (0.40, 0.65),
331
+ "peak_eccentric_velocity_ms": (1.3, 1.9),
332
+ "peak_concentric_velocity_ms": (2.6, 3.3),
333
+ "rsi": (0.85, 1.25),
334
+ "hip_angle_deg": (168, 178),
335
+ "knee_angle_deg": (170, 182),
336
+ "ankle_angle_deg": (120, 138),
337
+ },
338
+ },
339
+ "elite_male": {
340
+ "label": "Elite Male Athlete (college/pro volleyball/basketball)",
341
+ "profile": AthleteProfile.ELITE,
342
+ "expected": {
343
+ "jump_height_m": (0.68, 0.88),
344
+ "flight_time_s": (0.74, 0.84),
345
+ "countermovement_depth_m": (0.42, 0.62),
346
+ "concentric_duration_s": (0.28, 0.42),
347
+ "eccentric_duration_s": (0.35, 0.55),
348
+ "peak_eccentric_velocity_ms": (2.1, 3.2),
349
+ "peak_concentric_velocity_ms": (3.6, 4.2),
350
+ "rsi": (1.85, 2.80),
351
+ "hip_angle_deg": (173, 185),
352
+ "knee_angle_deg": (176, 188),
353
+ "ankle_angle_deg": (132, 148),
354
+ },
355
+ },
356
+ }
357
+
358
+
359
+ def estimate_athlete_profile(metrics_dict: dict) -> AthleteProfile:
360
+ """Estimate athlete profile from metrics.
361
+
362
+ Uses jump height as primary classifier:
363
+ - <0.20m: Elderly
364
+ - 0.20-0.35m: Untrained
365
+ - 0.35-0.65m: Recreational
366
+ - 0.65-0.85m: Trained
367
+ - >0.85m: Elite
368
+ """
369
+ jump_height = metrics_dict.get("jump_height", 0)
370
+
371
+ if jump_height < 0.20:
372
+ return AthleteProfile.ELDERLY
373
+ elif jump_height < 0.35:
374
+ return AthleteProfile.UNTRAINED
375
+ elif jump_height < 0.65:
376
+ return AthleteProfile.RECREATIONAL
377
+ elif jump_height < 0.85:
378
+ return AthleteProfile.TRAINED
379
+ else:
380
+ return AthleteProfile.ELITE
@@ -0,0 +1,75 @@
1
+ """Formatting utilities for consistent numeric output across jump analysis types.
2
+
3
+ This module provides shared helpers for formatting numeric values with appropriate
4
+ precision based on measurement type and capabilities of video-based analysis.
5
+ """
6
+
7
+ # Standard precision values for different measurement types
8
+ # These values are chosen based on:
9
+ # - Video analysis capabilities (30-240 fps)
10
+ # - Typical measurement uncertainty in video-based biomechanics
11
+ # - Balance between accuracy and readability
12
+
13
+ PRECISION_TIME_MS = 2 # Time in milliseconds: ±0.01ms (e.g., 534.12)
14
+ PRECISION_DISTANCE_M = 3 # Distance in meters: ±1mm (e.g., 0.352)
15
+ PRECISION_VELOCITY_M_S = 4 # Velocity in m/s: ±0.0001 m/s (e.g., 2.6340)
16
+ PRECISION_FRAME = 3 # Sub-frame interpolation precision (e.g., 154.342)
17
+ PRECISION_NORMALIZED = 4 # Normalized values 0-1 ratios (e.g., 0.0582)
18
+
19
+
20
+ def format_float_metric(
21
+ value: float | None,
22
+ multiplier: float = 1.0,
23
+ decimals: int = 2,
24
+ ) -> float | None:
25
+ """Format a float metric value with optional scaling and rounding.
26
+
27
+ This helper ensures consistent precision across all jump analysis outputs,
28
+ preventing false precision in measurements while maintaining appropriate
29
+ accuracy for the measurement type.
30
+
31
+ Args:
32
+ value: The value to format, or None
33
+ multiplier: Factor to multiply value by (e.g., 1000 for seconds→milliseconds)
34
+ decimals: Number of decimal places to round to
35
+
36
+ Returns:
37
+ Formatted value rounded to specified decimals, or None if input is None
38
+
39
+ Examples:
40
+ >>> format_float_metric(0.534123, 1000, 2) # seconds to ms
41
+ 534.12
42
+ >>> format_float_metric(0.3521234, 1, 3) # meters
43
+ 0.352
44
+ >>> format_float_metric(None, 1, 2)
45
+ None
46
+ >>> format_float_metric(-1.23456, 1, 4) # negative values preserved
47
+ -1.2346
48
+ """
49
+ if value is None:
50
+ return None
51
+ return round(value * multiplier, decimals)
52
+
53
+
54
+ def format_int_metric(value: float | int | None) -> int | None:
55
+ """Format a value as an integer.
56
+
57
+ Used for frame numbers and other integer-valued metrics.
58
+
59
+ Args:
60
+ value: The value to format, or None
61
+
62
+ Returns:
63
+ Value converted to int, or None if input is None
64
+
65
+ Examples:
66
+ >>> format_int_metric(42.7)
67
+ 42
68
+ >>> format_int_metric(None)
69
+ None
70
+ >>> format_int_metric(154)
71
+ 154
72
+ """
73
+ if value is None:
74
+ return None
75
+ return int(value)
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, TypedDict
5
5
  import numpy as np
6
6
  from numpy.typing import NDArray
7
7
 
8
+ from ..core.formatting import format_float_metric, format_int_metric
8
9
  from ..core.smoothing import compute_acceleration_from_derivative
9
10
  from .analysis import (
10
11
  ContactState,
@@ -19,38 +20,6 @@ if TYPE_CHECKING:
19
20
  from ..core.quality import QualityAssessment
20
21
 
21
22
 
22
- def _format_float_metric(
23
- value: float | None, multiplier: float = 1, decimals: int = 2
24
- ) -> float | None:
25
- """Format a float metric value with optional scaling and rounding.
26
-
27
- Args:
28
- value: The value to format, or None
29
- multiplier: Factor to multiply value by (default: 1)
30
- decimals: Number of decimal places to round to (default: 2)
31
-
32
- Returns:
33
- Formatted value rounded to specified decimals, or None if input is None
34
- """
35
- if value is None:
36
- return None
37
- return round(value * multiplier, decimals)
38
-
39
-
40
- def _format_int_metric(value: float | int | None) -> int | None:
41
- """Format a value as an integer.
42
-
43
- Args:
44
- value: The value to format, or None
45
-
46
- Returns:
47
- Value converted to int, or None if input is None
48
- """
49
- if value is None:
50
- return None
51
- return int(value)
52
-
53
-
54
23
  class DropJumpDataDict(TypedDict, total=False):
55
24
  """Type-safe dictionary for drop jump measurement data."""
56
25
 
@@ -108,32 +77,32 @@ class DropJumpMetrics:
108
77
  Dictionary containing formatted metric values.
109
78
  """
110
79
  return {
111
- "ground_contact_time_ms": _format_float_metric(
80
+ "ground_contact_time_ms": format_float_metric(
112
81
  self.ground_contact_time, 1000, 2
113
82
  ),
114
- "flight_time_ms": _format_float_metric(self.flight_time, 1000, 2),
115
- "jump_height_m": _format_float_metric(self.jump_height, 1, 3),
116
- "jump_height_kinematic_m": _format_float_metric(
83
+ "flight_time_ms": format_float_metric(self.flight_time, 1000, 2),
84
+ "jump_height_m": format_float_metric(self.jump_height, 1, 3),
85
+ "jump_height_kinematic_m": format_float_metric(
117
86
  self.jump_height_kinematic, 1, 3
118
87
  ),
119
- "jump_height_trajectory_normalized": _format_float_metric(
88
+ "jump_height_trajectory_normalized": format_float_metric(
120
89
  self.jump_height_trajectory, 1, 4
121
90
  ),
122
- "contact_start_frame": _format_int_metric(self.contact_start_frame),
123
- "contact_end_frame": _format_int_metric(self.contact_end_frame),
124
- "flight_start_frame": _format_int_metric(self.flight_start_frame),
125
- "flight_end_frame": _format_int_metric(self.flight_end_frame),
126
- "peak_height_frame": _format_int_metric(self.peak_height_frame),
127
- "contact_start_frame_precise": _format_float_metric(
91
+ "contact_start_frame": format_int_metric(self.contact_start_frame),
92
+ "contact_end_frame": format_int_metric(self.contact_end_frame),
93
+ "flight_start_frame": format_int_metric(self.flight_start_frame),
94
+ "flight_end_frame": format_int_metric(self.flight_end_frame),
95
+ "peak_height_frame": format_int_metric(self.peak_height_frame),
96
+ "contact_start_frame_precise": format_float_metric(
128
97
  self.contact_start_frame_precise, 1, 3
129
98
  ),
130
- "contact_end_frame_precise": _format_float_metric(
99
+ "contact_end_frame_precise": format_float_metric(
131
100
  self.contact_end_frame_precise, 1, 3
132
101
  ),
133
- "flight_start_frame_precise": _format_float_metric(
102
+ "flight_start_frame_precise": format_float_metric(
134
103
  self.flight_start_frame_precise, 1, 3
135
104
  ),
136
- "flight_end_frame_precise": _format_float_metric(
105
+ "flight_end_frame_precise": format_float_metric(
137
106
  self.flight_end_frame_precise, 1, 3
138
107
  ),
139
108
  }