kinemotion 0.75.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.
- kinemotion/__init__.py +13 -1
- kinemotion/api.py +21 -1
- kinemotion/cli.py +2 -0
- kinemotion/squat_jump/__init__.py +5 -0
- kinemotion/squat_jump/analysis.py +342 -0
- kinemotion/squat_jump/api.py +610 -0
- kinemotion/squat_jump/cli.py +309 -0
- kinemotion/squat_jump/debug_overlay.py +215 -0
- kinemotion/squat_jump/kinematics.py +348 -0
- kinemotion/squat_jump/metrics_validator.py +446 -0
- kinemotion/squat_jump/validation_bounds.py +221 -0
- {kinemotion-0.75.0.dist-info → kinemotion-0.76.0.dist-info}/METADATA +51 -2
- {kinemotion-0.75.0.dist-info → kinemotion-0.76.0.dist-info}/RECORD +16 -8
- {kinemotion-0.75.0.dist-info → kinemotion-0.76.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.75.0.dist-info → kinemotion-0.76.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.75.0.dist-info → kinemotion-0.76.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|