kinemotion 0.29.3__py3-none-any.whl → 0.31.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/api.py +35 -7
- kinemotion/cmj/analysis.py +6 -3
- kinemotion/cmj/cli.py +11 -6
- kinemotion/cmj/debug_overlay.py +3 -3
- kinemotion/cmj/kinematics.py +22 -7
- kinemotion/core/cli_utils.py +4 -2
- kinemotion/core/cmj_metrics_validator.py +134 -21
- kinemotion/core/cmj_validation_bounds.py +15 -1
- kinemotion/core/dropjump_metrics_validator.py +346 -0
- kinemotion/core/dropjump_validation_bounds.py +196 -0
- kinemotion/core/metadata.py +2 -1
- kinemotion/core/quality.py +5 -4
- kinemotion/core/smoothing.py +4 -2
- kinemotion/core/video_io.py +3 -2
- kinemotion/dropjump/analysis.py +12 -7
- kinemotion/dropjump/cli.py +7 -4
- kinemotion/dropjump/kinematics.py +18 -5
- {kinemotion-0.29.3.dist-info → kinemotion-0.31.0.dist-info}/METADATA +1 -1
- kinemotion-0.31.0.dist-info/RECORD +35 -0
- {kinemotion-0.29.3.dist-info → kinemotion-0.31.0.dist-info}/WHEEL +1 -1
- kinemotion-0.29.3.dist-info/RECORD +0 -33
- {kinemotion-0.29.3.dist-info → kinemotion-0.31.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.29.3.dist-info → kinemotion-0.31.0.dist-info}/licenses/LICENSE +0 -0
kinemotion/api.py
CHANGED
|
@@ -18,6 +18,8 @@ from .core.auto_tuning import (
|
|
|
18
18
|
analyze_video_sample,
|
|
19
19
|
auto_tune_parameters,
|
|
20
20
|
)
|
|
21
|
+
from .core.cmj_metrics_validator import CMJMetricsValidator
|
|
22
|
+
from .core.dropjump_metrics_validator import DropJumpMetricsValidator
|
|
21
23
|
from .core.filtering import reject_outliers
|
|
22
24
|
from .core.metadata import (
|
|
23
25
|
AlgorithmConfig,
|
|
@@ -60,7 +62,8 @@ def _parse_quality_preset(quality: str) -> QualityPreset:
|
|
|
60
62
|
return QualityPreset(quality.lower())
|
|
61
63
|
except ValueError as e:
|
|
62
64
|
raise ValueError(
|
|
63
|
-
f"Invalid quality preset: {quality}.
|
|
65
|
+
f"Invalid quality preset: {quality}. "
|
|
66
|
+
"Must be 'fast', 'balanced', or 'accurate'"
|
|
64
67
|
) from e
|
|
65
68
|
|
|
66
69
|
|
|
@@ -499,7 +502,8 @@ def process_dropjump_video(
|
|
|
499
502
|
if verbose:
|
|
500
503
|
print("Assessing tracking quality...")
|
|
501
504
|
|
|
502
|
-
# Detect outliers for quality scoring (doesn't affect results, just
|
|
505
|
+
# Detect outliers for quality scoring (doesn't affect results, just
|
|
506
|
+
# for assessment)
|
|
503
507
|
_, outlier_mask = reject_outliers(
|
|
504
508
|
vertical_positions,
|
|
505
509
|
use_ransac=True,
|
|
@@ -602,6 +606,16 @@ def process_dropjump_video(
|
|
|
602
606
|
if verbose:
|
|
603
607
|
print("Analysis complete!")
|
|
604
608
|
|
|
609
|
+
# Validate metrics against physiological bounds
|
|
610
|
+
validator = DropJumpMetricsValidator()
|
|
611
|
+
validation_result = validator.validate(metrics.to_dict()) # type: ignore[arg-type]
|
|
612
|
+
metrics.validation_result = validation_result
|
|
613
|
+
|
|
614
|
+
if verbose and validation_result.issues:
|
|
615
|
+
print("\n⚠️ Validation Results:")
|
|
616
|
+
for issue in validation_result.issues:
|
|
617
|
+
print(f" [{issue.severity.value}] {issue.metric}: {issue.message}")
|
|
618
|
+
|
|
605
619
|
return metrics
|
|
606
620
|
|
|
607
621
|
|
|
@@ -614,9 +628,11 @@ def process_dropjump_videos_bulk(
|
|
|
614
628
|
Process multiple drop jump videos in parallel using ProcessPoolExecutor.
|
|
615
629
|
|
|
616
630
|
Args:
|
|
617
|
-
configs: List of DropJumpVideoConfig objects specifying video paths
|
|
631
|
+
configs: List of DropJumpVideoConfig objects specifying video paths
|
|
632
|
+
and parameters
|
|
618
633
|
max_workers: Maximum number of parallel workers (default: 4)
|
|
619
|
-
progress_callback: Optional callback function called after each video
|
|
634
|
+
progress_callback: Optional callback function called after each video
|
|
635
|
+
completes.
|
|
620
636
|
Receives DropJumpVideoResult object.
|
|
621
637
|
|
|
622
638
|
Returns:
|
|
@@ -941,7 +957,8 @@ def process_cmj_video(
|
|
|
941
957
|
if verbose:
|
|
942
958
|
print("Assessing tracking quality...")
|
|
943
959
|
|
|
944
|
-
# Detect outliers for quality scoring (doesn't affect results, just
|
|
960
|
+
# Detect outliers for quality scoring (doesn't affect results, just
|
|
961
|
+
# for assessment)
|
|
945
962
|
_, outlier_mask = reject_outliers(
|
|
946
963
|
vertical_positions,
|
|
947
964
|
use_ransac=True,
|
|
@@ -1035,9 +1052,19 @@ def process_cmj_video(
|
|
|
1035
1052
|
|
|
1036
1053
|
if verbose:
|
|
1037
1054
|
print(f"\nJump height: {metrics.jump_height:.3f}m")
|
|
1038
|
-
print(f"Flight time: {metrics.flight_time*1000:.1f}ms")
|
|
1055
|
+
print(f"Flight time: {metrics.flight_time * 1000:.1f}ms")
|
|
1039
1056
|
print(f"Countermovement depth: {metrics.countermovement_depth:.3f}m")
|
|
1040
1057
|
|
|
1058
|
+
# Validate metrics against physiological bounds
|
|
1059
|
+
validator = CMJMetricsValidator()
|
|
1060
|
+
validation_result = validator.validate(metrics.to_dict()["data"]) # type: ignore[arg-type]
|
|
1061
|
+
metrics.validation_result = validation_result
|
|
1062
|
+
|
|
1063
|
+
if verbose and validation_result.issues:
|
|
1064
|
+
print("\n⚠️ Validation Results:")
|
|
1065
|
+
for issue in validation_result.issues:
|
|
1066
|
+
print(f" [{issue.severity.value}] {issue.metric}: {issue.message}")
|
|
1067
|
+
|
|
1041
1068
|
return metrics
|
|
1042
1069
|
|
|
1043
1070
|
|
|
@@ -1104,7 +1131,8 @@ def process_cmj_videos_bulk(
|
|
|
1104
1131
|
|
|
1105
1132
|
def _process_cmj_video_wrapper(config: CMJVideoConfig) -> CMJVideoResult:
|
|
1106
1133
|
"""
|
|
1107
|
-
Wrapper function for parallel CMJ processing. Must be picklable
|
|
1134
|
+
Wrapper function for parallel CMJ processing. Must be picklable
|
|
1135
|
+
(top-level function).
|
|
1108
1136
|
|
|
1109
1137
|
Args:
|
|
1110
1138
|
config: CMJVideoConfig object with processing parameters
|
kinemotion/cmj/analysis.py
CHANGED
|
@@ -62,7 +62,8 @@ def find_standing_phase(
|
|
|
62
62
|
"""
|
|
63
63
|
Find the end of standing phase (start of countermovement).
|
|
64
64
|
|
|
65
|
-
Looks for a period of low velocity (standing) followed by consistent
|
|
65
|
+
Looks for a period of low velocity (standing) followed by consistent
|
|
66
|
+
downward motion.
|
|
66
67
|
|
|
67
68
|
Args:
|
|
68
69
|
positions: Array of vertical positions (normalized 0-1)
|
|
@@ -109,11 +110,13 @@ def find_countermovement_start(
|
|
|
109
110
|
"""
|
|
110
111
|
Find the start of countermovement (eccentric phase).
|
|
111
112
|
|
|
112
|
-
Detects when velocity becomes consistently positive (downward motion in
|
|
113
|
+
Detects when velocity becomes consistently positive (downward motion in
|
|
114
|
+
normalized coords).
|
|
113
115
|
|
|
114
116
|
Args:
|
|
115
117
|
velocities: Array of SIGNED vertical velocities
|
|
116
|
-
countermovement_threshold: Velocity threshold for detecting downward
|
|
118
|
+
countermovement_threshold: Velocity threshold for detecting downward
|
|
119
|
+
motion (POSITIVE)
|
|
117
120
|
min_eccentric_frames: Minimum consecutive frames of downward motion
|
|
118
121
|
standing_start: Optional frame where standing phase ended
|
|
119
122
|
|
kinemotion/cmj/cli.py
CHANGED
|
@@ -177,7 +177,8 @@ def _process_batch_videos(
|
|
|
177
177
|
default=None,
|
|
178
178
|
help="[EXPERT] Override pose tracking confidence",
|
|
179
179
|
)
|
|
180
|
-
def cmj_analyze( # NOSONAR(S107) - Click CLI requires individual parameters
|
|
180
|
+
def cmj_analyze( # NOSONAR(S107) - Click CLI requires individual parameters
|
|
181
|
+
# for each option
|
|
181
182
|
video_path: tuple[str, ...],
|
|
182
183
|
output: str | None,
|
|
183
184
|
json_output: str | None,
|
|
@@ -197,10 +198,12 @@ def cmj_analyze( # NOSONAR(S107) - Click CLI requires individual parameters for
|
|
|
197
198
|
tracking_confidence: float | None,
|
|
198
199
|
) -> None:
|
|
199
200
|
"""
|
|
200
|
-
Analyze counter movement jump (CMJ) video(s) to estimate jump performance
|
|
201
|
+
Analyze counter movement jump (CMJ) video(s) to estimate jump performance
|
|
202
|
+
metrics.
|
|
201
203
|
|
|
202
|
-
Uses intelligent auto-tuning to select optimal parameters based on video
|
|
203
|
-
Parameters are automatically adjusted for frame rate,
|
|
204
|
+
Uses intelligent auto-tuning to select optimal parameters based on video
|
|
205
|
+
characteristics. Parameters are automatically adjusted for frame rate,
|
|
206
|
+
tracking quality, and analysis preset.
|
|
204
207
|
|
|
205
208
|
VIDEO_PATH: Path(s) to video file(s). Supports glob patterns in batch mode.
|
|
206
209
|
|
|
@@ -341,11 +344,13 @@ def _output_results(metrics: Any, json_output: str | None) -> None:
|
|
|
341
344
|
f"Total movement time: {metrics.total_movement_time * 1000:.1f} ms", err=True
|
|
342
345
|
)
|
|
343
346
|
click.echo(
|
|
344
|
-
f"Peak eccentric velocity: {abs(metrics.peak_eccentric_velocity):.3f}
|
|
347
|
+
f"Peak eccentric velocity: {abs(metrics.peak_eccentric_velocity):.3f} "
|
|
348
|
+
"m/s (downward)",
|
|
345
349
|
err=True,
|
|
346
350
|
)
|
|
347
351
|
click.echo(
|
|
348
|
-
f"Peak concentric velocity: {metrics.peak_concentric_velocity:.3f}
|
|
352
|
+
f"Peak concentric velocity: {metrics.peak_concentric_velocity:.3f} "
|
|
353
|
+
"m/s (upward)",
|
|
349
354
|
err=True,
|
|
350
355
|
)
|
|
351
356
|
if metrics.transition_time is not None:
|
kinemotion/cmj/debug_overlay.py
CHANGED
|
@@ -370,10 +370,10 @@ class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
|
|
|
370
370
|
|
|
371
371
|
metrics_text = [
|
|
372
372
|
f"Jump Height: {metrics.jump_height:.3f}m",
|
|
373
|
-
f"Flight Time: {metrics.flight_time*1000:.0f}ms",
|
|
373
|
+
f"Flight Time: {metrics.flight_time * 1000:.0f}ms",
|
|
374
374
|
f"CM Depth: {metrics.countermovement_depth:.3f}m",
|
|
375
|
-
f"Ecc Duration: {metrics.eccentric_duration*1000:.0f}ms",
|
|
376
|
-
f"Con Duration: {metrics.concentric_duration*1000:.0f}ms",
|
|
375
|
+
f"Ecc Duration: {metrics.eccentric_duration * 1000:.0f}ms",
|
|
376
|
+
f"Con Duration: {metrics.concentric_duration * 1000:.0f}ms",
|
|
377
377
|
]
|
|
378
378
|
|
|
379
379
|
# Draw background
|
kinemotion/cmj/kinematics.py
CHANGED
|
@@ -9,6 +9,7 @@ from numpy.typing import NDArray
|
|
|
9
9
|
from ..core.formatting import format_float_metric
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
|
+
from ..core.cmj_metrics_validator import ValidationResult
|
|
12
13
|
from ..core.metadata import ResultMetadata
|
|
13
14
|
from ..core.quality import QualityAssessment
|
|
14
15
|
|
|
@@ -32,11 +33,12 @@ class CMJDataDict(TypedDict, total=False):
|
|
|
32
33
|
tracking_method: str
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
class CMJResultDict(TypedDict):
|
|
36
|
+
class CMJResultDict(TypedDict, total=False):
|
|
36
37
|
"""Type-safe dictionary for complete CMJ result with data and metadata."""
|
|
37
38
|
|
|
38
39
|
data: CMJDataDict
|
|
39
40
|
metadata: dict # ResultMetadata.to_dict()
|
|
41
|
+
validation: dict # ValidationResult.to_dict()
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
@dataclass
|
|
@@ -46,12 +48,17 @@ class CMJMetrics:
|
|
|
46
48
|
Attributes:
|
|
47
49
|
jump_height: Maximum jump height in meters
|
|
48
50
|
flight_time: Time spent in the air in milliseconds
|
|
49
|
-
countermovement_depth: Vertical distance traveled during eccentric
|
|
50
|
-
|
|
51
|
+
countermovement_depth: Vertical distance traveled during eccentric
|
|
52
|
+
phase in meters
|
|
53
|
+
eccentric_duration: Time from countermovement start to lowest point in
|
|
54
|
+
milliseconds
|
|
51
55
|
concentric_duration: Time from lowest point to takeoff in milliseconds
|
|
52
|
-
total_movement_time: Total time from countermovement start to takeoff
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
total_movement_time: Total time from countermovement start to takeoff
|
|
57
|
+
in milliseconds
|
|
58
|
+
peak_eccentric_velocity: Maximum downward velocity during
|
|
59
|
+
countermovement in m/s
|
|
60
|
+
peak_concentric_velocity: Maximum upward velocity during propulsion in
|
|
61
|
+
m/s
|
|
55
62
|
transition_time: Duration at lowest point (amortization phase) in milliseconds
|
|
56
63
|
standing_start_frame: Frame where standing phase ends (countermovement begins)
|
|
57
64
|
lowest_point_frame: Frame at lowest point of countermovement
|
|
@@ -60,6 +67,7 @@ class CMJMetrics:
|
|
|
60
67
|
video_fps: Frames per second of the analyzed video
|
|
61
68
|
tracking_method: Method used for tracking ("foot" or "com")
|
|
62
69
|
quality_assessment: Optional quality assessment with confidence and warnings
|
|
70
|
+
validation_result: Optional validation result with physiological bounds checks
|
|
63
71
|
"""
|
|
64
72
|
|
|
65
73
|
jump_height: float
|
|
@@ -79,6 +87,7 @@ class CMJMetrics:
|
|
|
79
87
|
tracking_method: str
|
|
80
88
|
quality_assessment: "QualityAssessment | None" = None
|
|
81
89
|
result_metadata: "ResultMetadata | None" = None
|
|
90
|
+
validation_result: "ValidationResult | None" = None
|
|
82
91
|
|
|
83
92
|
def to_dict(self) -> CMJResultDict:
|
|
84
93
|
"""Convert metrics to JSON-serializable dictionary with data/metadata structure.
|
|
@@ -129,7 +138,13 @@ class CMJMetrics:
|
|
|
129
138
|
# No metadata available
|
|
130
139
|
metadata = {}
|
|
131
140
|
|
|
132
|
-
|
|
141
|
+
result: CMJResultDict = {"data": data, "metadata": metadata}
|
|
142
|
+
|
|
143
|
+
# Include validation results if available
|
|
144
|
+
if self.validation_result is not None:
|
|
145
|
+
result["validation"] = self.validation_result.to_dict()
|
|
146
|
+
|
|
147
|
+
return result
|
|
133
148
|
|
|
134
149
|
|
|
135
150
|
def calculate_cmj_metrics(
|
kinemotion/core/cli_utils.py
CHANGED
|
@@ -120,8 +120,10 @@ def print_auto_tuned_params(
|
|
|
120
120
|
video: Video processor
|
|
121
121
|
quality_preset: Quality preset
|
|
122
122
|
params: Auto-tuned parameters
|
|
123
|
-
characteristics: Optional video characteristics (for tracking quality
|
|
124
|
-
|
|
123
|
+
characteristics: Optional video characteristics (for tracking quality
|
|
124
|
+
display)
|
|
125
|
+
extra_params: Optional extra parameters to display (e.g.,
|
|
126
|
+
countermovement_threshold)
|
|
125
127
|
"""
|
|
126
128
|
click.echo("\n" + "=" * 60, err=True)
|
|
127
129
|
click.echo("AUTO-TUNED PARAMETERS", err=True)
|
|
@@ -121,6 +121,36 @@ class ValidationResult:
|
|
|
121
121
|
else:
|
|
122
122
|
self.status = "PASS"
|
|
123
123
|
|
|
124
|
+
def to_dict(self) -> dict:
|
|
125
|
+
"""Convert validation result to JSON-serializable dictionary.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Dictionary with status, issues, and consistency metrics.
|
|
129
|
+
"""
|
|
130
|
+
return {
|
|
131
|
+
"status": self.status,
|
|
132
|
+
"issues": [
|
|
133
|
+
{
|
|
134
|
+
"severity": issue.severity.value,
|
|
135
|
+
"metric": issue.metric,
|
|
136
|
+
"message": issue.message,
|
|
137
|
+
"value": issue.value,
|
|
138
|
+
"bounds": issue.bounds,
|
|
139
|
+
}
|
|
140
|
+
for issue in self.issues
|
|
141
|
+
],
|
|
142
|
+
"athlete_profile": (
|
|
143
|
+
self.athlete_profile.value if self.athlete_profile else None
|
|
144
|
+
),
|
|
145
|
+
"rsi": self.rsi,
|
|
146
|
+
"height_flight_time_consistency_percent": (
|
|
147
|
+
self.height_flight_time_consistency
|
|
148
|
+
),
|
|
149
|
+
"velocity_height_consistency_percent": self.velocity_height_consistency,
|
|
150
|
+
"depth_height_ratio": self.depth_height_ratio,
|
|
151
|
+
"contact_depth_ratio": self.contact_depth_ratio,
|
|
152
|
+
}
|
|
153
|
+
|
|
124
154
|
|
|
125
155
|
class CMJMetricsValidator:
|
|
126
156
|
"""Comprehensive CMJ metrics validator."""
|
|
@@ -206,7 +236,8 @@ class CMJMetricsValidator:
|
|
|
206
236
|
elif bounds.contains(flight_time, profile):
|
|
207
237
|
result.add_info(
|
|
208
238
|
"flight_time",
|
|
209
|
-
f"Flight time {flight_time:.3f}s within expected range for
|
|
239
|
+
f"Flight time {flight_time:.3f}s within expected range for "
|
|
240
|
+
f"{profile.value}",
|
|
210
241
|
value=flight_time,
|
|
211
242
|
)
|
|
212
243
|
else:
|
|
@@ -248,7 +279,8 @@ class CMJMetricsValidator:
|
|
|
248
279
|
elif bounds.contains(jump_height, profile):
|
|
249
280
|
result.add_info(
|
|
250
281
|
"jump_height",
|
|
251
|
-
f"Jump height {jump_height:.3f}m within expected range for
|
|
282
|
+
f"Jump height {jump_height:.3f}m within expected range for "
|
|
283
|
+
f"{profile.value}",
|
|
252
284
|
value=jump_height,
|
|
253
285
|
)
|
|
254
286
|
else:
|
|
@@ -289,7 +321,8 @@ class CMJMetricsValidator:
|
|
|
289
321
|
elif bounds.contains(depth, profile):
|
|
290
322
|
result.add_info(
|
|
291
323
|
"countermovement_depth",
|
|
292
|
-
f"Countermovement depth {depth:.3f}m within expected range for
|
|
324
|
+
f"Countermovement depth {depth:.3f}m within expected range for "
|
|
325
|
+
f"{profile.value}",
|
|
293
326
|
value=depth,
|
|
294
327
|
)
|
|
295
328
|
else:
|
|
@@ -323,14 +356,16 @@ class CMJMetricsValidator:
|
|
|
323
356
|
else:
|
|
324
357
|
result.add_error(
|
|
325
358
|
"concentric_duration",
|
|
326
|
-
f"Concentric duration {duration:.3f}s likely includes
|
|
359
|
+
f"Concentric duration {duration:.3f}s likely includes "
|
|
360
|
+
"standing phase",
|
|
327
361
|
value=duration,
|
|
328
362
|
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
329
363
|
)
|
|
330
364
|
elif bounds.contains(duration, profile):
|
|
331
365
|
result.add_info(
|
|
332
366
|
"concentric_duration",
|
|
333
|
-
f"Concentric duration {duration:.3f}s within expected range for
|
|
367
|
+
f"Concentric duration {duration:.3f}s within expected range for "
|
|
368
|
+
f"{profile.value}",
|
|
334
369
|
value=duration,
|
|
335
370
|
)
|
|
336
371
|
else:
|
|
@@ -363,7 +398,8 @@ class CMJMetricsValidator:
|
|
|
363
398
|
elif bounds.contains(duration, profile):
|
|
364
399
|
result.add_info(
|
|
365
400
|
"eccentric_duration",
|
|
366
|
-
f"Eccentric duration {duration:.3f}s within expected range for
|
|
401
|
+
f"Eccentric duration {duration:.3f}s within expected range for "
|
|
402
|
+
f"{profile.value}",
|
|
367
403
|
value=duration,
|
|
368
404
|
)
|
|
369
405
|
else:
|
|
@@ -394,7 +430,8 @@ class CMJMetricsValidator:
|
|
|
394
430
|
elif bounds.contains(ecc_vel, profile):
|
|
395
431
|
result.add_info(
|
|
396
432
|
"peak_eccentric_velocity",
|
|
397
|
-
f"Peak eccentric velocity {ecc_vel:.2f} m/s within range
|
|
433
|
+
f"Peak eccentric velocity {ecc_vel:.2f} m/s within range "
|
|
434
|
+
f"for {profile.value}",
|
|
398
435
|
value=ecc_vel,
|
|
399
436
|
)
|
|
400
437
|
else:
|
|
@@ -415,21 +452,24 @@ class CMJMetricsValidator:
|
|
|
415
452
|
if con_vel < bounds.absolute_min:
|
|
416
453
|
result.add_error(
|
|
417
454
|
"peak_concentric_velocity",
|
|
418
|
-
f"Peak concentric velocity {con_vel:.2f} m/s
|
|
455
|
+
f"Peak concentric velocity {con_vel:.2f} m/s "
|
|
456
|
+
"insufficient to leave ground",
|
|
419
457
|
value=con_vel,
|
|
420
458
|
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
421
459
|
)
|
|
422
460
|
else:
|
|
423
461
|
result.add_error(
|
|
424
462
|
"peak_concentric_velocity",
|
|
425
|
-
f"Peak concentric velocity {con_vel:.2f} m/s exceeds
|
|
463
|
+
f"Peak concentric velocity {con_vel:.2f} m/s exceeds "
|
|
464
|
+
"elite capability",
|
|
426
465
|
value=con_vel,
|
|
427
466
|
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
428
467
|
)
|
|
429
468
|
elif bounds.contains(con_vel, profile):
|
|
430
469
|
result.add_info(
|
|
431
470
|
"peak_concentric_velocity",
|
|
432
|
-
f"Peak concentric velocity {con_vel:.2f} m/s within range
|
|
471
|
+
f"Peak concentric velocity {con_vel:.2f} m/s within range "
|
|
472
|
+
f"for {profile.value}",
|
|
433
473
|
value=con_vel,
|
|
434
474
|
)
|
|
435
475
|
else:
|
|
@@ -462,15 +502,17 @@ class CMJMetricsValidator:
|
|
|
462
502
|
if error_pct > MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE:
|
|
463
503
|
result.add_error(
|
|
464
504
|
"height_flight_time_consistency",
|
|
465
|
-
f"Jump height {jump_height:.3f}m inconsistent with flight
|
|
466
|
-
f"(expected {expected_height:.3f}m,
|
|
505
|
+
f"Jump height {jump_height:.3f}m inconsistent with flight "
|
|
506
|
+
f"time {flight_time:.3f}s (expected {expected_height:.3f}m, "
|
|
507
|
+
f"error {error_pct * 100:.1f}%)",
|
|
467
508
|
value=error_pct,
|
|
468
509
|
bounds=(0, MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE),
|
|
469
510
|
)
|
|
470
511
|
else:
|
|
471
512
|
result.add_info(
|
|
472
513
|
"height_flight_time_consistency",
|
|
473
|
-
f"Jump height and flight time consistent
|
|
514
|
+
f"Jump height and flight time consistent "
|
|
515
|
+
f"(error {error_pct * 100:.1f}%)",
|
|
474
516
|
value=error_pct,
|
|
475
517
|
)
|
|
476
518
|
|
|
@@ -495,7 +537,7 @@ class CMJMetricsValidator:
|
|
|
495
537
|
error_msg = (
|
|
496
538
|
f"Peak velocity {velocity:.2f} m/s inconsistent with "
|
|
497
539
|
f"jump height {jump_height:.3f}m (expected {expected_velocity:.2f} "
|
|
498
|
-
f"m/s, error {error_pct*100:.1f}%)"
|
|
540
|
+
f"m/s, error {error_pct * 100:.1f}%)"
|
|
499
541
|
)
|
|
500
542
|
result.add_warning(
|
|
501
543
|
"velocity_height_consistency",
|
|
@@ -506,7 +548,8 @@ class CMJMetricsValidator:
|
|
|
506
548
|
else:
|
|
507
549
|
result.add_info(
|
|
508
550
|
"velocity_height_consistency",
|
|
509
|
-
f"Peak velocity and jump height consistent
|
|
551
|
+
f"Peak velocity and jump height consistent "
|
|
552
|
+
f"(error {error_pct * 100:.1f}%)",
|
|
510
553
|
value=error_pct,
|
|
511
554
|
)
|
|
512
555
|
|
|
@@ -547,14 +590,16 @@ class CMJMetricsValidator:
|
|
|
547
590
|
if expected_min <= rsi <= expected_max:
|
|
548
591
|
result.add_info(
|
|
549
592
|
"rsi",
|
|
550
|
-
f"RSI {rsi:.2f} within expected range
|
|
593
|
+
f"RSI {rsi:.2f} within expected range "
|
|
594
|
+
f"[{expected_min:.2f}-{expected_max:.2f}] "
|
|
551
595
|
f"for {profile.value}",
|
|
552
596
|
value=rsi,
|
|
553
597
|
)
|
|
554
598
|
else:
|
|
555
599
|
result.add_warning(
|
|
556
600
|
"rsi",
|
|
557
|
-
f"RSI {rsi:.2f} outside typical range
|
|
601
|
+
f"RSI {rsi:.2f} outside typical range "
|
|
602
|
+
f"[{expected_min:.2f}-{expected_max:.2f}] "
|
|
558
603
|
f"for {profile.value}",
|
|
559
604
|
value=rsi,
|
|
560
605
|
bounds=(expected_min, expected_max),
|
|
@@ -620,7 +665,8 @@ class CMJMetricsValidator:
|
|
|
620
665
|
if ratio < MetricConsistency.CONTACT_DEPTH_RATIO_MIN:
|
|
621
666
|
result.add_warning(
|
|
622
667
|
"contact_depth_ratio",
|
|
623
|
-
f"Contact time {ratio:.2f}s/m to depth ratio: Very fast for
|
|
668
|
+
f"Contact time {ratio:.2f}s/m to depth ratio: Very fast for "
|
|
669
|
+
"depth traversed",
|
|
624
670
|
value=ratio,
|
|
625
671
|
bounds=(
|
|
626
672
|
MetricConsistency.CONTACT_DEPTH_RATIO_MIN,
|
|
@@ -672,7 +718,8 @@ class CMJMetricsValidator:
|
|
|
672
718
|
if not TripleExtensionBounds.knee_angle_valid(knee, profile):
|
|
673
719
|
result.add_warning(
|
|
674
720
|
"knee_angle",
|
|
675
|
-
f"Knee angle {knee:.1f}° outside expected range for
|
|
721
|
+
f"Knee angle {knee:.1f}° outside expected range for "
|
|
722
|
+
f"{profile.value}",
|
|
676
723
|
value=knee,
|
|
677
724
|
)
|
|
678
725
|
else:
|
|
@@ -687,16 +734,82 @@ class CMJMetricsValidator:
|
|
|
687
734
|
if not TripleExtensionBounds.ankle_angle_valid(ankle, profile):
|
|
688
735
|
result.add_warning(
|
|
689
736
|
"ankle_angle",
|
|
690
|
-
f"Ankle angle {ankle:.1f}° outside expected range for
|
|
737
|
+
f"Ankle angle {ankle:.1f}° outside expected range for "
|
|
738
|
+
f"{profile.value}",
|
|
691
739
|
value=ankle,
|
|
692
740
|
)
|
|
693
741
|
else:
|
|
694
742
|
result.add_info(
|
|
695
743
|
"ankle_angle",
|
|
696
|
-
f"Ankle angle {ankle:.1f}° within expected range for
|
|
744
|
+
f"Ankle angle {ankle:.1f}° within expected range for "
|
|
745
|
+
f"{profile.value}",
|
|
697
746
|
value=ankle,
|
|
698
747
|
)
|
|
699
748
|
|
|
749
|
+
# Detect joint compensation patterns
|
|
750
|
+
self._check_joint_compensation_pattern(angles, result, profile)
|
|
751
|
+
|
|
752
|
+
def _check_joint_compensation_pattern(
|
|
753
|
+
self, angles: dict, result: ValidationResult, profile: AthleteProfile
|
|
754
|
+
) -> None:
|
|
755
|
+
"""Detect compensatory joint patterns in triple extension.
|
|
756
|
+
|
|
757
|
+
When one joint cannot achieve full extension, others may compensate.
|
|
758
|
+
Example: Limited hip extension (160°) with excessive knee extension (185°+)
|
|
759
|
+
suggests compensation rather than balanced movement quality.
|
|
760
|
+
|
|
761
|
+
This is a biomechanical quality indicator, not an error.
|
|
762
|
+
"""
|
|
763
|
+
hip = angles.get("hip_angle")
|
|
764
|
+
knee = angles.get("knee_angle")
|
|
765
|
+
ankle = angles.get("ankle_angle")
|
|
766
|
+
|
|
767
|
+
if hip is None or knee is None or ankle is None:
|
|
768
|
+
return # Need all three to detect patterns
|
|
769
|
+
|
|
770
|
+
# Get profile-specific bounds
|
|
771
|
+
if profile == AthleteProfile.ELDERLY:
|
|
772
|
+
hip_min, hip_max = 150, 175
|
|
773
|
+
knee_min, knee_max = 155, 175
|
|
774
|
+
ankle_min, ankle_max = 100, 125
|
|
775
|
+
elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
|
|
776
|
+
hip_min, hip_max = 160, 180
|
|
777
|
+
knee_min, knee_max = 165, 182
|
|
778
|
+
ankle_min, ankle_max = 110, 140
|
|
779
|
+
elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
|
|
780
|
+
hip_min, hip_max = 170, 185
|
|
781
|
+
knee_min, knee_max = 173, 190
|
|
782
|
+
ankle_min, ankle_max = 125, 155
|
|
783
|
+
else:
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
# Count how many joints are near their boundaries
|
|
787
|
+
joints_at_boundary = 0
|
|
788
|
+
boundary_threshold = 3.0 # degrees from limit
|
|
789
|
+
|
|
790
|
+
if hip <= hip_min + boundary_threshold or hip >= hip_max - boundary_threshold:
|
|
791
|
+
joints_at_boundary += 1
|
|
792
|
+
if (
|
|
793
|
+
knee <= knee_min + boundary_threshold
|
|
794
|
+
or knee >= knee_max - boundary_threshold
|
|
795
|
+
):
|
|
796
|
+
joints_at_boundary += 1
|
|
797
|
+
if (
|
|
798
|
+
ankle <= ankle_min + boundary_threshold
|
|
799
|
+
or ankle >= ankle_max - boundary_threshold
|
|
800
|
+
):
|
|
801
|
+
joints_at_boundary += 1
|
|
802
|
+
|
|
803
|
+
# If 2+ joints at boundaries, likely compensation pattern
|
|
804
|
+
if joints_at_boundary >= 2:
|
|
805
|
+
result.add_info(
|
|
806
|
+
"joint_compensation",
|
|
807
|
+
f"Multiple joints near extension limits (hip={hip:.0f}°, "
|
|
808
|
+
f"knee={knee:.0f}°, ankle={ankle:.0f}°). "
|
|
809
|
+
f"May indicate compensatory movement pattern.",
|
|
810
|
+
value=float(joints_at_boundary),
|
|
811
|
+
)
|
|
812
|
+
|
|
700
813
|
@staticmethod
|
|
701
814
|
def _get_profile_range(
|
|
702
815
|
profile: AthleteProfile, bounds: MetricBounds
|
|
@@ -356,7 +356,9 @@ ATHLETE_PROFILES = {
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
|
|
359
|
-
def estimate_athlete_profile(
|
|
359
|
+
def estimate_athlete_profile(
|
|
360
|
+
metrics_dict: dict, gender: str | None = None
|
|
361
|
+
) -> AthleteProfile:
|
|
360
362
|
"""Estimate athlete profile from metrics.
|
|
361
363
|
|
|
362
364
|
Uses jump height as primary classifier:
|
|
@@ -365,6 +367,18 @@ def estimate_athlete_profile(metrics_dict: dict) -> AthleteProfile:
|
|
|
365
367
|
- 0.35-0.65m: Recreational
|
|
366
368
|
- 0.65-0.85m: Trained
|
|
367
369
|
- >0.85m: Elite
|
|
370
|
+
|
|
371
|
+
NOTE: Bounds are calibrated for adult males. Female athletes typically achieve
|
|
372
|
+
60-70% of male heights due to lower muscle mass and strength. If analyzing
|
|
373
|
+
female athletes, interpret results one level lower than classification suggests.
|
|
374
|
+
Example: Female athlete with 0.45m jump = Recreational male = Trained female.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
metrics_dict: Dictionary with CMJ metric values
|
|
378
|
+
gender: Optional gender for context ("M"/"F"). Currently informational only.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Estimated AthleteProfile
|
|
368
382
|
"""
|
|
369
383
|
jump_height = metrics_dict.get("jump_height", 0)
|
|
370
384
|
|