kinemotion 0.74.0__py3-none-any.whl → 0.76.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.
@@ -0,0 +1,446 @@
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, profile)
118
+ self._check_concentric_duration(data, result, profile)
119
+ self._check_peak_concentric_velocity(data, result, profile)
120
+ self._check_power_metrics(data, result, profile)
121
+ self._check_force_metrics(data, result, profile)
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(
187
+ self, metrics: MetricsDict, result: SJValidationResult, profile: AthleteProfile
188
+ ) -> None:
189
+ """Validate squat hold duration."""
190
+ duration_raw = self._get_metric_value(
191
+ metrics, "squat_hold_duration_ms", "squat_hold_duration"
192
+ )
193
+ if duration_raw is None:
194
+ return
195
+
196
+ duration = self._convert_raw_duration_to_seconds(duration_raw)
197
+ bounds = SJBounds.SQUAT_HOLD_DURATION
198
+
199
+ if not bounds.is_physically_possible(duration):
200
+ result.add_error(
201
+ "squat_hold_duration",
202
+ f"Squat hold duration {duration:.3f}s outside physical limits",
203
+ value=duration,
204
+ bounds=(bounds.absolute_min, bounds.absolute_max),
205
+ )
206
+ else:
207
+ result.add_info(
208
+ "squat_hold_duration",
209
+ f"Squat hold duration {duration:.3f}s",
210
+ value=duration,
211
+ )
212
+
213
+ def _check_concentric_duration(
214
+ self, metrics: MetricsDict, result: SJValidationResult, profile: AthleteProfile
215
+ ) -> None:
216
+ """Validate concentric duration."""
217
+ duration_raw = self._get_metric_value(
218
+ metrics, "concentric_duration_ms", "concentric_duration"
219
+ )
220
+ if duration_raw is None:
221
+ return
222
+
223
+ duration = self._convert_raw_duration_to_seconds(duration_raw)
224
+ bounds = SJBounds.CONCENTRIC_DURATION
225
+
226
+ if not bounds.is_physically_possible(duration):
227
+ if duration < bounds.absolute_min:
228
+ result.add_error(
229
+ "concentric_duration",
230
+ f"Concentric duration {duration:.3f}s likely detection error",
231
+ value=duration,
232
+ bounds=(bounds.absolute_min, bounds.absolute_max),
233
+ )
234
+ else:
235
+ result.add_error(
236
+ "concentric_duration",
237
+ f"Concentric duration {duration:.3f}s includes pre-takeoff phase",
238
+ value=duration,
239
+ bounds=(bounds.absolute_min, bounds.absolute_max),
240
+ )
241
+ else:
242
+ result.add_info(
243
+ "concentric_duration",
244
+ f"Concentric duration {duration:.3f}s",
245
+ value=duration,
246
+ )
247
+
248
+ def _check_peak_concentric_velocity(
249
+ self, metrics: MetricsDict, result: SJValidationResult, profile: AthleteProfile
250
+ ) -> None:
251
+ """Validate peak concentric velocity."""
252
+ velocity = self._get_metric_value(
253
+ metrics, "peak_concentric_velocity_m_s", "peak_concentric_velocity"
254
+ )
255
+ if velocity is None:
256
+ return
257
+
258
+ bounds = SJBounds.PEAK_CONCENTRIC_VELOCITY
259
+ error_suffix = "insufficient to leave ground" if velocity < bounds.absolute_min else ""
260
+
261
+ self._validate_metric_with_bounds(
262
+ "peak_concentric_velocity",
263
+ velocity,
264
+ bounds,
265
+ profile,
266
+ result,
267
+ error_suffix=error_suffix,
268
+ format_str="{value:.3f} m/s",
269
+ )
270
+
271
+ def _check_power_metrics(
272
+ self, metrics: MetricsDict, result: SJValidationResult, profile: AthleteProfile
273
+ ) -> None:
274
+ """Validate power metrics (peak and mean)."""
275
+ power_checks = [
276
+ ("peak_power", "peak_power_w", SJBounds.PEAK_POWER, ""),
277
+ ("mean_power", "mean_power_w", SJBounds.MEAN_POWER, ""),
278
+ ]
279
+
280
+ for metric_name, key_name, bounds, _error_suffix in power_checks:
281
+ power = self._get_metric_value(metrics, key_name, metric_name)
282
+ if power is None:
283
+ continue
284
+
285
+ # Skip validation if mass wasn't provided (power calculations require mass)
286
+ if power == 0 and metrics.get("mass_kg") is None:
287
+ result.add_info(
288
+ metric_name,
289
+ f"{metric_name.replace('_', ' ').title()} not calculated (mass not provided)",
290
+ value=power,
291
+ )
292
+ continue
293
+
294
+ if not bounds.is_physically_possible(power):
295
+ result.add_error(
296
+ metric_name,
297
+ f"Peak {metric_name.replace('_', ' ')} {power:.0f} W outside physical limits",
298
+ value=power,
299
+ bounds=(bounds.absolute_min, bounds.absolute_max),
300
+ )
301
+ else:
302
+ result.add_info(
303
+ metric_name,
304
+ f"{metric_name.replace('_', ' ').title()} {power:.0f} W",
305
+ value=power,
306
+ )
307
+
308
+ def _check_force_metrics(
309
+ self, metrics: MetricsDict, result: SJValidationResult, profile: AthleteProfile
310
+ ) -> None:
311
+ """Validate force metrics."""
312
+ peak_force = self._get_metric_value(metrics, "peak_force_n", "peak_force")
313
+ if peak_force is None:
314
+ return
315
+
316
+ # Skip validation if mass wasn't provided
317
+ if peak_force == 0 and metrics.get("mass_kg") is None:
318
+ result.add_info(
319
+ "peak_force",
320
+ "Peak force not calculated (mass not provided)",
321
+ value=peak_force,
322
+ )
323
+ return
324
+
325
+ bounds = SJBounds.PEAK_FORCE
326
+
327
+ if not bounds.is_physically_possible(peak_force):
328
+ result.add_error(
329
+ "peak_force",
330
+ f"Peak force {peak_force:.0f} N outside physical limits",
331
+ value=peak_force,
332
+ bounds=(bounds.absolute_min, bounds.absolute_max),
333
+ )
334
+ else:
335
+ result.add_info(
336
+ "peak_force",
337
+ f"Peak force {peak_force:.0f} N",
338
+ value=peak_force,
339
+ )
340
+
341
+ def _check_flight_time_height_consistency(
342
+ self, metrics: MetricsDict, result: SJValidationResult
343
+ ) -> None:
344
+ """Verify jump height is consistent with flight time."""
345
+ flight_time_ms = metrics.get("flight_time_ms")
346
+ jump_height = metrics.get("jump_height_m")
347
+
348
+ if flight_time_ms is None or jump_height is None:
349
+ return
350
+
351
+ # Convert ms to seconds
352
+ flight_time = flight_time_ms / 1000.0
353
+
354
+ # Calculate expected height using kinematic formula: h = g*t²/8
355
+ g = 9.81
356
+ expected_height = (g * flight_time**2) / 8
357
+ error_pct = abs(jump_height - expected_height) / expected_height
358
+
359
+ result.force_height_consistency = error_pct
360
+
361
+ if error_pct > MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE:
362
+ result.add_error(
363
+ "height_flight_time_consistency",
364
+ f"Jump height {jump_height:.3f}m inconsistent with flight "
365
+ f"time {flight_time:.3f}s (expected {expected_height:.3f}m, "
366
+ f"error {error_pct * 100:.1f}%)",
367
+ value=error_pct,
368
+ bounds=(0, MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE),
369
+ )
370
+ else:
371
+ result.add_info(
372
+ "height_flight_time_consistency",
373
+ f"Jump height and flight time consistent (error {error_pct * 100:.1f}%)",
374
+ value=error_pct,
375
+ )
376
+
377
+ def _check_power_velocity_consistency(
378
+ self, metrics: MetricsDict, result: SJValidationResult
379
+ ) -> None:
380
+ """Verify power is consistent with velocity and mass."""
381
+ velocity = metrics.get("peak_concentric_velocity_m_s")
382
+ peak_power = metrics.get("peak_power_w")
383
+ mass_kg = metrics.get("mass_kg")
384
+
385
+ if velocity is None or peak_power is None or mass_kg is None:
386
+ return
387
+
388
+ # Calculate expected power: P = F × v = (m × a) × v
389
+ # For simplicity, assume peak acceleration occurs at peak velocity
390
+ # TODO: This needs biomechanical validation by specialist
391
+ g = 9.81
392
+ # Estimate force during concentric phase (simplified)
393
+ expected_force = mass_kg * g * 2 # Assume 2x bodyweight force
394
+ expected_power = expected_force * velocity
395
+ error_pct = abs(peak_power - expected_power) / expected_power
396
+
397
+ result.peak_power_consistency = error_pct
398
+
399
+ if error_pct > MetricConsistency.POWER_VELOCITY_TOLERANCE:
400
+ result.add_warning(
401
+ "power_velocity_consistency",
402
+ f"Peak power {peak_power:.0f} W inconsistent with velocity "
403
+ f"{velocity:.2f} m/s and mass {mass_kg:.1f} kg "
404
+ f"(expected ~{expected_power:.0f} W, error {error_pct * 100:.1f}%)",
405
+ value=error_pct,
406
+ bounds=(0, MetricConsistency.POWER_VELOCITY_TOLERANCE),
407
+ )
408
+ else:
409
+ result.add_info(
410
+ "power_velocity_consistency",
411
+ f"Power and velocity consistent (error {error_pct * 100:.1f}%)",
412
+ value=error_pct,
413
+ )
414
+
415
+ def _check_squat_concentric_ratio(
416
+ self, metrics: MetricsDict, result: SJValidationResult
417
+ ) -> None:
418
+ """Check squat hold duration to concentric duration ratio."""
419
+ squat_ms = metrics.get("squat_hold_duration_ms")
420
+ concentric_ms = metrics.get("concentric_duration_ms")
421
+
422
+ if squat_ms is None or concentric_ms is None or concentric_ms < 50:
423
+ return
424
+
425
+ # Convert to seconds for ratio calculation
426
+ squat = squat_ms / 1000.0
427
+ concentric = concentric_ms / 1000.0
428
+ ratio = squat / concentric
429
+
430
+ if ratio > MetricConsistency.SQUAT_CONCENTRIC_RATIO_MAX:
431
+ result.add_warning(
432
+ "squat_concentric_ratio",
433
+ f"Squat hold {ratio:.2f}x concentric duration: "
434
+ f"Unusually long static phase, verify squat detection",
435
+ value=ratio,
436
+ bounds=(
437
+ MetricConsistency.SQUAT_CONCENTRIC_RATIO_MIN,
438
+ MetricConsistency.SQUAT_CONCENTRIC_RATIO_MAX,
439
+ ),
440
+ )
441
+ else:
442
+ result.add_info(
443
+ "squat_concentric_ratio",
444
+ f"Squat-to-concentric ratio {ratio:.2f} within expected range",
445
+ value=ratio,
446
+ )
@@ -0,0 +1,221 @@
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