kinemotion 0.76.3__py3-none-any.whl → 1.0.0__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (53) hide show
  1. kinemotion/__init__.py +3 -18
  2. kinemotion/api.py +7 -27
  3. kinemotion/cli.py +2 -4
  4. kinemotion/{countermovement_jump → cmj}/analysis.py +158 -16
  5. kinemotion/{countermovement_jump → cmj}/api.py +18 -46
  6. kinemotion/{countermovement_jump → cmj}/cli.py +46 -6
  7. kinemotion/cmj/debug_overlay.py +457 -0
  8. kinemotion/{countermovement_jump → cmj}/joint_angles.py +31 -96
  9. kinemotion/{countermovement_jump → cmj}/metrics_validator.py +293 -184
  10. kinemotion/{countermovement_jump → cmj}/validation_bounds.py +18 -1
  11. kinemotion/core/__init__.py +2 -11
  12. kinemotion/core/auto_tuning.py +107 -149
  13. kinemotion/core/cli_utils.py +0 -74
  14. kinemotion/core/debug_overlay_utils.py +15 -142
  15. kinemotion/core/experimental.py +51 -55
  16. kinemotion/core/filtering.py +56 -116
  17. kinemotion/core/pipeline_utils.py +2 -2
  18. kinemotion/core/pose.py +98 -47
  19. kinemotion/core/quality.py +6 -4
  20. kinemotion/core/smoothing.py +51 -65
  21. kinemotion/core/types.py +0 -15
  22. kinemotion/core/validation.py +7 -76
  23. kinemotion/core/video_io.py +27 -41
  24. kinemotion/{drop_jump → dropjump}/__init__.py +8 -2
  25. kinemotion/{drop_jump → dropjump}/analysis.py +120 -282
  26. kinemotion/{drop_jump → dropjump}/api.py +33 -59
  27. kinemotion/{drop_jump → dropjump}/cli.py +136 -70
  28. kinemotion/dropjump/debug_overlay.py +182 -0
  29. kinemotion/{drop_jump → dropjump}/kinematics.py +65 -175
  30. kinemotion/{drop_jump → dropjump}/metrics_validator.py +51 -25
  31. kinemotion/{drop_jump → dropjump}/validation_bounds.py +1 -1
  32. kinemotion/models/rtmpose-s_simcc-body7_pt-body7-halpe26_700e-256x192-7f134165_20230605.onnx +3 -0
  33. kinemotion/models/yolox_tiny_8xb8-300e_humanart-6f3252f9.onnx +3 -0
  34. {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/METADATA +26 -75
  35. kinemotion-1.0.0.dist-info/RECORD +49 -0
  36. kinemotion/core/overlay_constants.py +0 -61
  37. kinemotion/core/video_analysis_base.py +0 -132
  38. kinemotion/countermovement_jump/debug_overlay.py +0 -325
  39. kinemotion/drop_jump/debug_overlay.py +0 -241
  40. kinemotion/squat_jump/__init__.py +0 -5
  41. kinemotion/squat_jump/analysis.py +0 -377
  42. kinemotion/squat_jump/api.py +0 -610
  43. kinemotion/squat_jump/cli.py +0 -309
  44. kinemotion/squat_jump/debug_overlay.py +0 -163
  45. kinemotion/squat_jump/kinematics.py +0 -342
  46. kinemotion/squat_jump/metrics_validator.py +0 -438
  47. kinemotion/squat_jump/validation_bounds.py +0 -221
  48. kinemotion-0.76.3.dist-info/RECORD +0 -57
  49. /kinemotion/{countermovement_jump → cmj}/__init__.py +0 -0
  50. /kinemotion/{countermovement_jump → cmj}/kinematics.py +0 -0
  51. {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/WHEEL +0 -0
  52. {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/entry_points.txt +0 -0
  53. {kinemotion-0.76.3.dist-info → kinemotion-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,438 +0,0 @@
1
- """SJ metrics validation using physiological bounds.
2
-
3
- Comprehensive validation of Squat Jump metrics against
4
- biomechanical bounds and consistency tests.
5
-
6
- Provides severity levels (ERROR, WARNING, INFO) for different categories
7
- of metric issues.
8
- """
9
-
10
- from dataclasses import dataclass
11
-
12
- from kinemotion.core.types import MetricsDict
13
- from kinemotion.core.validation import (
14
- AthleteProfile,
15
- MetricsValidator,
16
- ValidationResult,
17
- )
18
- from kinemotion.squat_jump.validation_bounds import (
19
- MetricConsistency,
20
- SJBounds,
21
- estimate_athlete_profile,
22
- )
23
-
24
-
25
- @dataclass
26
- class SJValidationResult(ValidationResult):
27
- """SJ-specific validation result."""
28
-
29
- peak_power_consistency: float | None = None
30
- force_height_consistency: float | None = None
31
-
32
- def to_dict(self) -> dict:
33
- """Convert validation result to JSON-serializable dictionary.
34
-
35
- Returns:
36
- Dictionary with status, issues, and consistency metrics.
37
- """
38
- return {
39
- "status": self.status,
40
- "issues": [
41
- {
42
- "severity": issue.severity.value,
43
- "metric": issue.metric,
44
- "message": issue.message,
45
- "value": issue.value,
46
- "bounds": issue.bounds,
47
- }
48
- for issue in self.issues
49
- ],
50
- "athlete_profile": (self.athlete_profile.value if self.athlete_profile else None),
51
- "peak_power_consistency_percent": self.peak_power_consistency,
52
- "force_height_consistency_percent": self.force_height_consistency,
53
- }
54
-
55
-
56
- class SJMetricsValidator(MetricsValidator):
57
- """Comprehensive SJ metrics validator."""
58
-
59
- @staticmethod
60
- def _get_metric_value(
61
- data: dict, key_with_suffix: str, key_without_suffix: str
62
- ) -> float | None:
63
- """Get metric value, supporting both suffixed and legacy key formats.
64
-
65
- Args:
66
- data: Dictionary containing metrics
67
- key_with_suffix: Key with unit suffix (e.g., "flight_time_ms")
68
- key_without_suffix: Legacy key without suffix (e.g., "flight_time")
69
-
70
- Returns:
71
- Metric value or None if not found
72
- """
73
- return data.get(key_with_suffix) or data.get(key_without_suffix)
74
-
75
- @staticmethod
76
- def _convert_raw_duration_to_seconds(value_raw: float) -> float:
77
- """Convert raw duration value to seconds.
78
-
79
- Handles legacy values that may be in seconds (<10) vs milliseconds (>10).
80
- This heuristic works because no SJ duration metric is between 10ms and 10s.
81
-
82
- Args:
83
- value_raw: Raw duration value (may be seconds or milliseconds)
84
-
85
- Returns:
86
- Duration in seconds
87
- """
88
- if value_raw < 10: # Likely in seconds
89
- return value_raw
90
- return value_raw / 1000.0
91
-
92
- def validate(self, metrics: MetricsDict) -> SJValidationResult:
93
- """Validate SJ metrics comprehensively.
94
-
95
- Args:
96
- metrics: Dictionary with SJ metric values
97
-
98
- Returns:
99
- SJValidationResult with all issues and status
100
- """
101
- result = SJValidationResult()
102
-
103
- # Estimate athlete profile if not provided
104
- if self.assumed_profile:
105
- result.athlete_profile = self.assumed_profile
106
- else:
107
- result.athlete_profile = estimate_athlete_profile(metrics)
108
-
109
- profile = result.athlete_profile
110
-
111
- # Extract metric values (handle nested "data" structure)
112
- data = metrics.get("data", metrics) # Support both structures
113
-
114
- # PRIMARY BOUNDS CHECKS
115
- self._check_flight_time(data, result, profile)
116
- self._check_jump_height(data, result, profile)
117
- self._check_squat_hold_duration(data, result)
118
- self._check_concentric_duration(data, result)
119
- self._check_peak_concentric_velocity(data, result, profile)
120
- self._check_power_metrics(data, result)
121
- self._check_force_metrics(data, result)
122
-
123
- # CROSS-VALIDATION CHECKS
124
- self._check_flight_time_height_consistency(data, result)
125
- self._check_power_velocity_consistency(data, result)
126
-
127
- # CONSISTENCY CHECKS
128
- self._check_squat_concentric_ratio(data, result)
129
-
130
- # Finalize status
131
- result.finalize_status()
132
-
133
- return result
134
-
135
- def _check_flight_time(
136
- self, metrics: MetricsDict, result: SJValidationResult, profile: AthleteProfile
137
- ) -> None:
138
- """Validate flight time."""
139
- flight_time_raw = self._get_metric_value(metrics, "flight_time_ms", "flight_time")
140
- if flight_time_raw is None:
141
- return
142
-
143
- flight_time = self._convert_raw_duration_to_seconds(flight_time_raw)
144
- bounds = SJBounds.FLIGHT_TIME
145
- error_label = (
146
- "below frame rate resolution limit"
147
- if flight_time < bounds.absolute_min
148
- else "exceeds elite human capability"
149
- )
150
-
151
- self._validate_metric_with_bounds(
152
- "flight_time",
153
- flight_time,
154
- bounds,
155
- profile,
156
- result,
157
- error_suffix=error_label,
158
- format_str="{value:.3f}s",
159
- )
160
-
161
- def _check_jump_height(
162
- self, metrics: MetricsDict, result: SJValidationResult, profile: AthleteProfile
163
- ) -> None:
164
- """Validate jump height."""
165
- jump_height = self._get_metric_value(metrics, "jump_height_m", "jump_height")
166
- if jump_height is None:
167
- return
168
-
169
- bounds = SJBounds.JUMP_HEIGHT
170
- error_label = (
171
- "essentially no jump (noise)"
172
- if jump_height < bounds.absolute_min
173
- else "exceeds human capability"
174
- )
175
-
176
- self._validate_metric_with_bounds(
177
- "jump_height",
178
- jump_height,
179
- bounds,
180
- profile,
181
- result,
182
- error_suffix=error_label,
183
- format_str="{value:.3f}m",
184
- )
185
-
186
- def _check_squat_hold_duration(self, metrics: MetricsDict, result: SJValidationResult) -> None:
187
- """Validate squat hold duration."""
188
- duration_raw = self._get_metric_value(
189
- metrics, "squat_hold_duration_ms", "squat_hold_duration"
190
- )
191
- if duration_raw is None:
192
- return
193
-
194
- duration = self._convert_raw_duration_to_seconds(duration_raw)
195
- bounds = SJBounds.SQUAT_HOLD_DURATION
196
-
197
- if not bounds.is_physically_possible(duration):
198
- result.add_error(
199
- "squat_hold_duration",
200
- f"Squat hold duration {duration:.3f}s outside physical limits",
201
- value=duration,
202
- bounds=(bounds.absolute_min, bounds.absolute_max),
203
- )
204
- else:
205
- result.add_info(
206
- "squat_hold_duration",
207
- f"Squat hold duration {duration:.3f}s",
208
- value=duration,
209
- )
210
-
211
- def _check_concentric_duration(self, metrics: MetricsDict, result: SJValidationResult) -> None:
212
- """Validate concentric duration."""
213
- duration_raw = self._get_metric_value(
214
- metrics, "concentric_duration_ms", "concentric_duration"
215
- )
216
- if duration_raw is None:
217
- return
218
-
219
- duration = self._convert_raw_duration_to_seconds(duration_raw)
220
- bounds = SJBounds.CONCENTRIC_DURATION
221
-
222
- if not bounds.is_physically_possible(duration):
223
- if duration < bounds.absolute_min:
224
- result.add_error(
225
- "concentric_duration",
226
- f"Concentric duration {duration:.3f}s likely detection error",
227
- value=duration,
228
- bounds=(bounds.absolute_min, bounds.absolute_max),
229
- )
230
- else:
231
- result.add_error(
232
- "concentric_duration",
233
- f"Concentric duration {duration:.3f}s includes pre-takeoff phase",
234
- value=duration,
235
- bounds=(bounds.absolute_min, bounds.absolute_max),
236
- )
237
- else:
238
- result.add_info(
239
- "concentric_duration",
240
- f"Concentric duration {duration:.3f}s",
241
- value=duration,
242
- )
243
-
244
- def _check_peak_concentric_velocity(
245
- self, metrics: MetricsDict, result: SJValidationResult, profile: AthleteProfile
246
- ) -> None:
247
- """Validate peak concentric velocity."""
248
- velocity = self._get_metric_value(
249
- metrics, "peak_concentric_velocity_m_s", "peak_concentric_velocity"
250
- )
251
- if velocity is None:
252
- return
253
-
254
- bounds = SJBounds.PEAK_CONCENTRIC_VELOCITY
255
- error_suffix = "insufficient to leave ground" if velocity < bounds.absolute_min else ""
256
-
257
- self._validate_metric_with_bounds(
258
- "peak_concentric_velocity",
259
- velocity,
260
- bounds,
261
- profile,
262
- result,
263
- error_suffix=error_suffix,
264
- format_str="{value:.3f} m/s",
265
- )
266
-
267
- def _check_power_metrics(self, metrics: MetricsDict, result: SJValidationResult) -> None:
268
- """Validate power metrics (peak and mean)."""
269
- power_checks = [
270
- ("peak_power", "peak_power_w", SJBounds.PEAK_POWER, ""),
271
- ("mean_power", "mean_power_w", SJBounds.MEAN_POWER, ""),
272
- ]
273
-
274
- for metric_name, key_name, bounds, _error_suffix in power_checks:
275
- power = self._get_metric_value(metrics, key_name, metric_name)
276
- if power is None:
277
- continue
278
-
279
- # Skip validation if mass wasn't provided (power calculations require mass)
280
- if power == 0 and metrics.get("mass_kg") is None:
281
- result.add_info(
282
- metric_name,
283
- f"{metric_name.replace('_', ' ').title()} not calculated (mass not provided)",
284
- value=power,
285
- )
286
- continue
287
-
288
- if not bounds.is_physically_possible(power):
289
- result.add_error(
290
- metric_name,
291
- f"Peak {metric_name.replace('_', ' ')} {power:.0f} W outside physical limits",
292
- value=power,
293
- bounds=(bounds.absolute_min, bounds.absolute_max),
294
- )
295
- else:
296
- result.add_info(
297
- metric_name,
298
- f"{metric_name.replace('_', ' ').title()} {power:.0f} W",
299
- value=power,
300
- )
301
-
302
- def _check_force_metrics(self, metrics: MetricsDict, result: SJValidationResult) -> None:
303
- """Validate force metrics."""
304
- peak_force = self._get_metric_value(metrics, "peak_force_n", "peak_force")
305
- if peak_force is None:
306
- return
307
-
308
- # Skip validation if mass wasn't provided
309
- if peak_force == 0 and metrics.get("mass_kg") is None:
310
- result.add_info(
311
- "peak_force",
312
- "Peak force not calculated (mass not provided)",
313
- value=peak_force,
314
- )
315
- return
316
-
317
- bounds = SJBounds.PEAK_FORCE
318
-
319
- if not bounds.is_physically_possible(peak_force):
320
- result.add_error(
321
- "peak_force",
322
- f"Peak force {peak_force:.0f} N outside physical limits",
323
- value=peak_force,
324
- bounds=(bounds.absolute_min, bounds.absolute_max),
325
- )
326
- else:
327
- result.add_info(
328
- "peak_force",
329
- f"Peak force {peak_force:.0f} N",
330
- value=peak_force,
331
- )
332
-
333
- def _check_flight_time_height_consistency(
334
- self, metrics: MetricsDict, result: SJValidationResult
335
- ) -> None:
336
- """Verify jump height is consistent with flight time."""
337
- flight_time_ms = metrics.get("flight_time_ms")
338
- jump_height = metrics.get("jump_height_m")
339
-
340
- if flight_time_ms is None or jump_height is None:
341
- return
342
-
343
- # Convert ms to seconds
344
- flight_time = flight_time_ms / 1000.0
345
-
346
- # Calculate expected height using kinematic formula: h = g*t²/8
347
- g = 9.81
348
- expected_height = (g * flight_time**2) / 8
349
- error_pct = abs(jump_height - expected_height) / expected_height
350
-
351
- result.force_height_consistency = error_pct
352
-
353
- if error_pct > MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE:
354
- result.add_error(
355
- "height_flight_time_consistency",
356
- f"Jump height {jump_height:.3f}m inconsistent with flight "
357
- f"time {flight_time:.3f}s (expected {expected_height:.3f}m, "
358
- f"error {error_pct * 100:.1f}%)",
359
- value=error_pct,
360
- bounds=(0, MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE),
361
- )
362
- else:
363
- result.add_info(
364
- "height_flight_time_consistency",
365
- f"Jump height and flight time consistent (error {error_pct * 100:.1f}%)",
366
- value=error_pct,
367
- )
368
-
369
- def _check_power_velocity_consistency(
370
- self, metrics: MetricsDict, result: SJValidationResult
371
- ) -> None:
372
- """Verify power is consistent with velocity and mass."""
373
- velocity = metrics.get("peak_concentric_velocity_m_s")
374
- peak_power = metrics.get("peak_power_w")
375
- mass_kg = metrics.get("mass_kg")
376
-
377
- if velocity is None or peak_power is None or mass_kg is None:
378
- return
379
-
380
- # Calculate expected power: P = F × v = (m × a) × v
381
- # For simplicity, assume peak acceleration occurs at peak velocity
382
- # TODO: This needs biomechanical validation by specialist
383
- g = 9.81
384
- # Estimate force during concentric phase (simplified)
385
- expected_force = mass_kg * g * 2 # Assume 2x bodyweight force
386
- expected_power = expected_force * velocity
387
- error_pct = abs(peak_power - expected_power) / expected_power
388
-
389
- result.peak_power_consistency = error_pct
390
-
391
- if error_pct > MetricConsistency.POWER_VELOCITY_TOLERANCE:
392
- result.add_warning(
393
- "power_velocity_consistency",
394
- f"Peak power {peak_power:.0f} W inconsistent with velocity "
395
- f"{velocity:.2f} m/s and mass {mass_kg:.1f} kg "
396
- f"(expected ~{expected_power:.0f} W, error {error_pct * 100:.1f}%)",
397
- value=error_pct,
398
- bounds=(0, MetricConsistency.POWER_VELOCITY_TOLERANCE),
399
- )
400
- else:
401
- result.add_info(
402
- "power_velocity_consistency",
403
- f"Power and velocity consistent (error {error_pct * 100:.1f}%)",
404
- value=error_pct,
405
- )
406
-
407
- def _check_squat_concentric_ratio(
408
- self, metrics: MetricsDict, result: SJValidationResult
409
- ) -> None:
410
- """Check squat hold duration to concentric duration ratio."""
411
- squat_ms = metrics.get("squat_hold_duration_ms")
412
- concentric_ms = metrics.get("concentric_duration_ms")
413
-
414
- if squat_ms is None or concentric_ms is None or concentric_ms < 50:
415
- return
416
-
417
- # Convert to seconds for ratio calculation
418
- squat = squat_ms / 1000.0
419
- concentric = concentric_ms / 1000.0
420
- ratio = squat / concentric
421
-
422
- if ratio > MetricConsistency.SQUAT_CONCENTRIC_RATIO_MAX:
423
- result.add_warning(
424
- "squat_concentric_ratio",
425
- f"Squat hold {ratio:.2f}x concentric duration: "
426
- f"Unusually long static phase, verify squat detection",
427
- value=ratio,
428
- bounds=(
429
- MetricConsistency.SQUAT_CONCENTRIC_RATIO_MIN,
430
- MetricConsistency.SQUAT_CONCENTRIC_RATIO_MAX,
431
- ),
432
- )
433
- else:
434
- result.add_info(
435
- "squat_concentric_ratio",
436
- f"Squat-to-concentric ratio {ratio:.2f} within expected range",
437
- value=ratio,
438
- )
@@ -1,221 +0,0 @@
1
- """SJ metrics physiological bounds for validation testing.
2
-
3
- This module defines realistic physiological bounds for Squat Jump (SJ)
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
- Note: SJ metrics differ from CMJ as there is no eccentric phase.
13
- Focus is on static squat hold duration and explosive concentric phase.
14
- """
15
-
16
- from kinemotion.core.types import MetricsDict
17
- from kinemotion.core.validation import AthleteProfile, MetricBounds
18
-
19
-
20
- class SJBounds:
21
- """Collection of physiological bounds for all SJ metrics."""
22
-
23
- # FLIGHT TIME (seconds)
24
- FLIGHT_TIME = MetricBounds(
25
- absolute_min=0.08, # Frame rate resolution limit
26
- practical_min=0.15, # Minimum effort jump
27
- recreational_min=0.20, # Untrained ~5-10cm
28
- recreational_max=0.50, # Recreational ~20-35cm
29
- elite_min=0.55, # Trained ~50cm
30
- elite_max=1.00, # Elite >80cm
31
- absolute_max=1.20,
32
- unit="s",
33
- )
34
-
35
- # JUMP HEIGHT (meters) - SJ typically achieves less than CMJ
36
- JUMP_HEIGHT = MetricBounds(
37
- absolute_min=0.02,
38
- practical_min=0.05,
39
- recreational_min=0.10, # Untrained with effort
40
- recreational_max=0.45, # Good recreational
41
- elite_min=0.50,
42
- elite_max=0.85,
43
- absolute_max=1.20,
44
- unit="m",
45
- )
46
-
47
- # SQUAT HOLD DURATION (seconds) - Key SJ-specific metric
48
- SQUAT_HOLD_DURATION = MetricBounds(
49
- absolute_min=0.0, # Can be very short
50
- practical_min=0.0,
51
- recreational_min=0.0, # May not be detectable
52
- recreational_max=2.0, # Very long static holds
53
- elite_min=0.0,
54
- elite_max=3.0, # Elite athletes sometimes use long pauses
55
- absolute_max=5.0, # Maximum reasonable squat hold
56
- unit="s",
57
- )
58
-
59
- # CONCENTRIC DURATION (seconds) - Similar to CMJ concentric phase
60
- CONCENTRIC_DURATION = MetricBounds(
61
- absolute_min=0.08,
62
- practical_min=0.15, # Extreme plyometric
63
- recreational_min=0.30, # Moderate propulsion
64
- recreational_max=0.70, # Slow push-off
65
- elite_min=0.20,
66
- elite_max=0.45,
67
- absolute_max=1.50,
68
- unit="s",
69
- )
70
-
71
- # PEAK CONCENTRIC VELOCITY (m/s, upward) - Key SJ metric
72
- PEAK_CONCENTRIC_VELOCITY = MetricBounds(
73
- absolute_min=0.30,
74
- practical_min=0.50,
75
- recreational_min=1.60,
76
- recreational_max=2.60,
77
- elite_min=2.80,
78
- elite_max=4.00,
79
- absolute_max=5.00,
80
- unit="m/s",
81
- )
82
-
83
- # PEAK POWER (Watts) - Requires mass calculation
84
- PEAK_POWER = MetricBounds(
85
- absolute_min=1000, # Minimum for adult
86
- practical_min=1500,
87
- recreational_min=3000,
88
- recreational_max=8000,
89
- elite_min=9000,
90
- elite_max=15000,
91
- absolute_max=20000,
92
- unit="W",
93
- )
94
-
95
- # MEAN POWER (Watts) - During concentric phase
96
- MEAN_POWER = MetricBounds(
97
- absolute_min=500, # Minimum for adult
98
- practical_min=800,
99
- recreational_min=1500,
100
- recreational_max=5000,
101
- elite_min=6000,
102
- elite_max=10000,
103
- absolute_max=15000,
104
- unit="W",
105
- )
106
-
107
- # PEAK FORCE (Newtons) - During concentric phase
108
- PEAK_FORCE = MetricBounds(
109
- absolute_min=1000, # Minimum for adult
110
- practical_min=1500,
111
- recreational_min=2000,
112
- recreational_max=4000,
113
- elite_min=4500,
114
- elite_max=6000,
115
- absolute_max=8000,
116
- unit="N",
117
- )
118
-
119
-
120
- class MetricConsistency:
121
- """Cross-validation tolerance for metric consistency checks."""
122
-
123
- # Jump height from flight time: h = g*t²/8
124
- # Allow 10% deviation for measurement noise
125
- HEIGHT_FLIGHT_TIME_TOLERANCE = 0.10
126
-
127
- # Power from velocity and mass: P = F × v
128
- # Allow 25% deviation (power calculations are complex)
129
- POWER_VELOCITY_TOLERANCE = 0.25
130
-
131
- # Squat hold to concentric duration ratio
132
- # Typically 0-2.0, flag if outside 0-3.0
133
- SQUAT_CONCENTRIC_RATIO_MIN = 0.0
134
- SQUAT_CONCENTRIC_RATIO_MAX = 3.0
135
-
136
-
137
- # Athlete profile examples with expected metric ranges
138
- ATHLETE_PROFILES = {
139
- "elderly_deconditioned": {
140
- "label": "Elderly/Deconditioned (70+, sedentary)",
141
- "profile": AthleteProfile.ELDERLY,
142
- "expected": {
143
- "jump_height_m": (0.05, 0.15),
144
- "flight_time_s": (0.10, 0.18),
145
- "squat_hold_duration_s": (0.0, 1.5),
146
- "concentric_duration_s": (0.40, 0.90),
147
- "peak_concentric_velocity_ms": (0.8, 1.4),
148
- "peak_power_w": (1500, 3000),
149
- "mean_power_w": (800, 2000),
150
- "peak_force_n": (1500, 3000),
151
- },
152
- },
153
- "recreational": {
154
- "label": "Recreational Athlete (fitness participant, 30-45 yrs)",
155
- "profile": AthleteProfile.RECREATIONAL,
156
- "expected": {
157
- "jump_height_m": (0.20, 0.45),
158
- "flight_time_s": (0.25, 0.50),
159
- "squat_hold_duration_s": (0.0, 1.0),
160
- "concentric_duration_s": (0.35, 0.65),
161
- "peak_concentric_velocity_ms": (1.8, 2.5),
162
- "peak_power_w": (4000, 7000),
163
- "mean_power_w": (2000, 4500),
164
- "peak_force_n": (2500, 3500),
165
- },
166
- },
167
- "elite_male": {
168
- "label": "Elite Male Athlete (college/pro volleyball/basketball)",
169
- "profile": AthleteProfile.ELITE,
170
- "expected": {
171
- "jump_height_m": (0.60, 0.85),
172
- "flight_time_s": (0.70, 0.80),
173
- "squat_hold_duration_s": (0.0, 1.5),
174
- "concentric_duration_s": (0.25, 0.40),
175
- "peak_concentric_velocity_ms": (3.2, 3.8),
176
- "peak_power_w": (10000, 14000),
177
- "mean_power_w": (6000, 9000),
178
- "peak_force_n": (4500, 5500),
179
- },
180
- },
181
- }
182
-
183
-
184
- def estimate_athlete_profile(
185
- metrics_dict: MetricsDict, _gender: str | None = None
186
- ) -> AthleteProfile:
187
- """Estimate athlete profile from SJ metrics.
188
-
189
- Uses jump height as primary classifier (similar to CMJ):
190
- - <0.15m: Elderly
191
- - 0.15-0.20m: Untrained
192
- - 0.20-0.45m: Recreational
193
- - 0.45-0.50m: Trained
194
- - >0.50m: Elite
195
-
196
- NOTE: Squat Jump typically achieves lower heights than CMJ due to
197
- the lack of pre-stretch (no countermovement). Adjust expectations
198
- accordingly when analyzing results.
199
-
200
- Args:
201
- metrics_dict: Dictionary with SJ metric values
202
- gender: Optional gender for context ("M"/"F"). Currently informational only.
203
-
204
- Returns:
205
- Estimated AthleteProfile
206
- """
207
- # Support both nested "data" structure and flat structure
208
- # Extract with unit suffix as used in serialization, or without suffix (legacy)
209
- data = metrics_dict.get("data", metrics_dict)
210
- jump_height = data.get("jump_height_m") or data.get("jump_height", 0)
211
-
212
- if jump_height < 0.15:
213
- return AthleteProfile.ELDERLY
214
- elif jump_height < 0.20:
215
- return AthleteProfile.UNTRAINED
216
- elif jump_height < 0.45:
217
- return AthleteProfile.RECREATIONAL
218
- elif jump_height < 0.50:
219
- return AthleteProfile.TRAINED
220
- else:
221
- return AthleteProfile.ELITE