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.
- kinemotion/__init__.py +1 -1
- kinemotion/cmj/analysis.py +43 -10
- kinemotion/cmj/joint_angles.py +23 -6
- kinemotion/cmj/kinematics.py +33 -23
- kinemotion/core/cmj_metrics_validator.py +717 -0
- kinemotion/core/cmj_validation_bounds.py +380 -0
- kinemotion/core/formatting.py +75 -0
- kinemotion/dropjump/kinematics.py +15 -46
- {kinemotion-0.27.0.dist-info → kinemotion-0.29.0.dist-info}/METADATA +111 -49
- {kinemotion-0.27.0.dist-info → kinemotion-0.29.0.dist-info}/RECORD +13 -10
- {kinemotion-0.27.0.dist-info → kinemotion-0.29.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.27.0.dist-info → kinemotion-0.29.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.27.0.dist-info → kinemotion-0.29.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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":
|
|
80
|
+
"ground_contact_time_ms": format_float_metric(
|
|
112
81
|
self.ground_contact_time, 1000, 2
|
|
113
82
|
),
|
|
114
|
-
"flight_time_ms":
|
|
115
|
-
"jump_height_m":
|
|
116
|
-
"jump_height_kinematic_m":
|
|
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":
|
|
88
|
+
"jump_height_trajectory_normalized": format_float_metric(
|
|
120
89
|
self.jump_height_trajectory, 1, 4
|
|
121
90
|
),
|
|
122
|
-
"contact_start_frame":
|
|
123
|
-
"contact_end_frame":
|
|
124
|
-
"flight_start_frame":
|
|
125
|
-
"flight_end_frame":
|
|
126
|
-
"peak_height_frame":
|
|
127
|
-
"contact_start_frame_precise":
|
|
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":
|
|
99
|
+
"contact_end_frame_precise": format_float_metric(
|
|
131
100
|
self.contact_end_frame_precise, 1, 3
|
|
132
101
|
),
|
|
133
|
-
"flight_start_frame_precise":
|
|
102
|
+
"flight_start_frame_precise": format_float_metric(
|
|
134
103
|
self.flight_start_frame_precise, 1, 3
|
|
135
104
|
),
|
|
136
|
-
"flight_end_frame_precise":
|
|
105
|
+
"flight_end_frame_precise": format_float_metric(
|
|
137
106
|
self.flight_end_frame_precise, 1, 3
|
|
138
107
|
),
|
|
139
108
|
}
|