kinemotion 0.28.0__py3-none-any.whl → 0.29.1__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,717 @@
1
+ """CMJ metrics validation using physiological bounds.
2
+
3
+ Comprehensive validation of Counter Movement Jump metrics against
4
+ biomechanical bounds, cross-validation checks, 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, field
11
+ from enum import Enum
12
+
13
+ from kinemotion.core.cmj_validation_bounds import (
14
+ AthleteProfile,
15
+ CMJBounds,
16
+ MetricBounds,
17
+ MetricConsistency,
18
+ RSIBounds,
19
+ TripleExtensionBounds,
20
+ estimate_athlete_profile,
21
+ )
22
+
23
+
24
+ class ValidationSeverity(Enum):
25
+ """Severity level for validation issues."""
26
+
27
+ ERROR = "ERROR" # Metrics invalid, likely data corruption
28
+ WARNING = "WARNING" # Metrics valid but unusual, needs review
29
+ INFO = "INFO" # Normal variation, informational only
30
+
31
+
32
+ @dataclass
33
+ class ValidationIssue:
34
+ """Single validation issue."""
35
+
36
+ severity: ValidationSeverity
37
+ metric: str
38
+ message: str
39
+ value: float | None = None
40
+ bounds: tuple[float, float] | None = None
41
+
42
+
43
+ @dataclass
44
+ class ValidationResult:
45
+ """Complete validation result for CMJ metrics."""
46
+
47
+ issues: list[ValidationIssue] = field(default_factory=list)
48
+ status: str = "PASS" # "PASS", "PASS_WITH_WARNINGS", "FAIL"
49
+ athlete_profile: AthleteProfile | None = None
50
+ rsi: float | None = None
51
+ height_flight_time_consistency: float | None = None # % error
52
+ velocity_height_consistency: float | None = None # % error
53
+ depth_height_ratio: float | None = None
54
+ contact_depth_ratio: float | None = None
55
+
56
+ def add_error(
57
+ self,
58
+ metric: str,
59
+ message: str,
60
+ value: float | None = None,
61
+ bounds: tuple[float, float] | None = None,
62
+ ) -> None:
63
+ """Add error-level issue."""
64
+ self.issues.append(
65
+ ValidationIssue(
66
+ severity=ValidationSeverity.ERROR,
67
+ metric=metric,
68
+ message=message,
69
+ value=value,
70
+ bounds=bounds,
71
+ )
72
+ )
73
+
74
+ def add_warning(
75
+ self,
76
+ metric: str,
77
+ message: str,
78
+ value: float | None = None,
79
+ bounds: tuple[float, float] | None = None,
80
+ ) -> None:
81
+ """Add warning-level issue."""
82
+ self.issues.append(
83
+ ValidationIssue(
84
+ severity=ValidationSeverity.WARNING,
85
+ metric=metric,
86
+ message=message,
87
+ value=value,
88
+ bounds=bounds,
89
+ )
90
+ )
91
+
92
+ def add_info(
93
+ self,
94
+ metric: str,
95
+ message: str,
96
+ value: float | None = None,
97
+ ) -> None:
98
+ """Add info-level issue."""
99
+ self.issues.append(
100
+ ValidationIssue(
101
+ severity=ValidationSeverity.INFO,
102
+ metric=metric,
103
+ message=message,
104
+ value=value,
105
+ )
106
+ )
107
+
108
+ def finalize_status(self) -> None:
109
+ """Determine final pass/fail status based on issues."""
110
+ has_errors = any(
111
+ issue.severity == ValidationSeverity.ERROR for issue in self.issues
112
+ )
113
+ has_warnings = any(
114
+ issue.severity == ValidationSeverity.WARNING for issue in self.issues
115
+ )
116
+
117
+ if has_errors:
118
+ self.status = "FAIL"
119
+ elif has_warnings:
120
+ self.status = "PASS_WITH_WARNINGS"
121
+ else:
122
+ self.status = "PASS"
123
+
124
+
125
+ class CMJMetricsValidator:
126
+ """Comprehensive CMJ metrics validator."""
127
+
128
+ def __init__(self, assumed_profile: AthleteProfile | None = None):
129
+ """Initialize validator.
130
+
131
+ Args:
132
+ assumed_profile: If provided, validate against this specific profile.
133
+ Otherwise, estimate from metrics.
134
+ """
135
+ self.assumed_profile = assumed_profile
136
+
137
+ def validate(self, metrics: dict) -> ValidationResult:
138
+ """Validate CMJ metrics comprehensively.
139
+
140
+ Args:
141
+ metrics: Dictionary with CMJ metric values
142
+
143
+ Returns:
144
+ ValidationResult with all issues and status
145
+ """
146
+ result = ValidationResult()
147
+
148
+ # Estimate athlete profile if not provided
149
+ if self.assumed_profile:
150
+ result.athlete_profile = self.assumed_profile
151
+ else:
152
+ result.athlete_profile = estimate_athlete_profile(metrics)
153
+
154
+ profile = result.athlete_profile
155
+
156
+ # PRIMARY BOUNDS CHECKS
157
+ self._check_flight_time(metrics, result, profile)
158
+ self._check_jump_height(metrics, result, profile)
159
+ self._check_countermovement_depth(metrics, result, profile)
160
+ self._check_concentric_duration(metrics, result, profile)
161
+ self._check_eccentric_duration(metrics, result, profile)
162
+ self._check_peak_velocities(metrics, result, profile)
163
+
164
+ # CROSS-VALIDATION CHECKS
165
+ self._check_flight_time_height_consistency(metrics, result)
166
+ self._check_velocity_height_consistency(metrics, result)
167
+ self._check_rsi_validity(metrics, result, profile)
168
+
169
+ # CONSISTENCY CHECKS
170
+ self._check_depth_height_ratio(metrics, result)
171
+ self._check_contact_depth_ratio(metrics, result)
172
+
173
+ # TRIPLE EXTENSION ANGLES
174
+ self._check_triple_extension(metrics, result, profile)
175
+
176
+ # Finalize status
177
+ result.finalize_status()
178
+
179
+ return result
180
+
181
+ def _check_flight_time(
182
+ self, metrics: dict, result: ValidationResult, profile: AthleteProfile
183
+ ) -> None:
184
+ """Validate flight time."""
185
+ flight_time = metrics.get("flight_time")
186
+ if flight_time is None:
187
+ return
188
+
189
+ bounds = CMJBounds.FLIGHT_TIME
190
+
191
+ if not bounds.is_physically_possible(flight_time):
192
+ if flight_time < bounds.absolute_min:
193
+ result.add_error(
194
+ "flight_time",
195
+ f"Flight time {flight_time:.3f}s below frame rate resolution limit",
196
+ value=flight_time,
197
+ bounds=(bounds.absolute_min, bounds.absolute_max),
198
+ )
199
+ else:
200
+ result.add_error(
201
+ "flight_time",
202
+ f"Flight time {flight_time:.3f}s exceeds elite human capability",
203
+ value=flight_time,
204
+ bounds=(bounds.absolute_min, bounds.absolute_max),
205
+ )
206
+ elif bounds.contains(flight_time, profile):
207
+ result.add_info(
208
+ "flight_time",
209
+ f"Flight time {flight_time:.3f}s within expected range for {profile.value}",
210
+ value=flight_time,
211
+ )
212
+ else:
213
+ # Outside expected range but physically possible
214
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
215
+ result.add_warning(
216
+ "flight_time",
217
+ f"Flight time {flight_time:.3f}s outside typical range "
218
+ f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
219
+ value=flight_time,
220
+ bounds=(expected_min, expected_max),
221
+ )
222
+
223
+ def _check_jump_height(
224
+ self, metrics: dict, result: ValidationResult, profile: AthleteProfile
225
+ ) -> None:
226
+ """Validate jump height."""
227
+ jump_height = metrics.get("jump_height")
228
+ if jump_height is None:
229
+ return
230
+
231
+ bounds = CMJBounds.JUMP_HEIGHT
232
+
233
+ if not bounds.is_physically_possible(jump_height):
234
+ if jump_height < bounds.absolute_min:
235
+ result.add_error(
236
+ "jump_height",
237
+ f"Jump height {jump_height:.3f}m essentially no jump (noise)",
238
+ value=jump_height,
239
+ bounds=(bounds.absolute_min, bounds.absolute_max),
240
+ )
241
+ else:
242
+ result.add_error(
243
+ "jump_height",
244
+ f"Jump height {jump_height:.3f}m exceeds human capability",
245
+ value=jump_height,
246
+ bounds=(bounds.absolute_min, bounds.absolute_max),
247
+ )
248
+ elif bounds.contains(jump_height, profile):
249
+ result.add_info(
250
+ "jump_height",
251
+ f"Jump height {jump_height:.3f}m within expected range for {profile.value}",
252
+ value=jump_height,
253
+ )
254
+ else:
255
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
256
+ result.add_warning(
257
+ "jump_height",
258
+ f"Jump height {jump_height:.3f}m outside typical range "
259
+ f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
260
+ value=jump_height,
261
+ bounds=(expected_min, expected_max),
262
+ )
263
+
264
+ def _check_countermovement_depth(
265
+ self, metrics: dict, result: ValidationResult, profile: AthleteProfile
266
+ ) -> None:
267
+ """Validate countermovement depth."""
268
+ depth = metrics.get("countermovement_depth")
269
+ if depth is None:
270
+ return
271
+
272
+ bounds = CMJBounds.COUNTERMOVEMENT_DEPTH
273
+
274
+ if not bounds.is_physically_possible(depth):
275
+ if depth < bounds.absolute_min:
276
+ result.add_error(
277
+ "countermovement_depth",
278
+ f"Countermovement depth {depth:.3f}m essentially no squat",
279
+ value=depth,
280
+ bounds=(bounds.absolute_min, bounds.absolute_max),
281
+ )
282
+ else:
283
+ result.add_error(
284
+ "countermovement_depth",
285
+ f"Countermovement depth {depth:.3f}m exceeds physical limit",
286
+ value=depth,
287
+ bounds=(bounds.absolute_min, bounds.absolute_max),
288
+ )
289
+ elif bounds.contains(depth, profile):
290
+ result.add_info(
291
+ "countermovement_depth",
292
+ f"Countermovement depth {depth:.3f}m within expected range for {profile.value}",
293
+ value=depth,
294
+ )
295
+ else:
296
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
297
+ result.add_warning(
298
+ "countermovement_depth",
299
+ f"Countermovement depth {depth:.3f}m outside typical range "
300
+ f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
301
+ value=depth,
302
+ bounds=(expected_min, expected_max),
303
+ )
304
+
305
+ def _check_concentric_duration(
306
+ self, metrics: dict, result: ValidationResult, profile: AthleteProfile
307
+ ) -> None:
308
+ """Validate concentric duration (contact time)."""
309
+ duration = metrics.get("concentric_duration")
310
+ if duration is None:
311
+ return
312
+
313
+ bounds = CMJBounds.CONCENTRIC_DURATION
314
+
315
+ if not bounds.is_physically_possible(duration):
316
+ if duration < bounds.absolute_min:
317
+ result.add_error(
318
+ "concentric_duration",
319
+ f"Concentric duration {duration:.3f}s likely phase detection error",
320
+ value=duration,
321
+ bounds=(bounds.absolute_min, bounds.absolute_max),
322
+ )
323
+ else:
324
+ result.add_error(
325
+ "concentric_duration",
326
+ f"Concentric duration {duration:.3f}s likely includes standing phase",
327
+ value=duration,
328
+ bounds=(bounds.absolute_min, bounds.absolute_max),
329
+ )
330
+ elif bounds.contains(duration, profile):
331
+ result.add_info(
332
+ "concentric_duration",
333
+ f"Concentric duration {duration:.3f}s within expected range for {profile.value}",
334
+ value=duration,
335
+ )
336
+ else:
337
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
338
+ result.add_warning(
339
+ "concentric_duration",
340
+ f"Concentric duration {duration:.3f}s outside typical range "
341
+ f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
342
+ value=duration,
343
+ bounds=(expected_min, expected_max),
344
+ )
345
+
346
+ def _check_eccentric_duration(
347
+ self, metrics: dict, result: ValidationResult, profile: AthleteProfile
348
+ ) -> None:
349
+ """Validate eccentric duration."""
350
+ duration = metrics.get("eccentric_duration")
351
+ if duration is None:
352
+ return
353
+
354
+ bounds = CMJBounds.ECCENTRIC_DURATION
355
+
356
+ if not bounds.is_physically_possible(duration):
357
+ result.add_error(
358
+ "eccentric_duration",
359
+ f"Eccentric duration {duration:.3f}s outside physical limits",
360
+ value=duration,
361
+ bounds=(bounds.absolute_min, bounds.absolute_max),
362
+ )
363
+ elif bounds.contains(duration, profile):
364
+ result.add_info(
365
+ "eccentric_duration",
366
+ f"Eccentric duration {duration:.3f}s within expected range for {profile.value}",
367
+ value=duration,
368
+ )
369
+ else:
370
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
371
+ result.add_warning(
372
+ "eccentric_duration",
373
+ f"Eccentric duration {duration:.3f}s outside typical range "
374
+ f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
375
+ value=duration,
376
+ bounds=(expected_min, expected_max),
377
+ )
378
+
379
+ def _check_peak_velocities(
380
+ self, metrics: dict, result: ValidationResult, profile: AthleteProfile
381
+ ) -> None:
382
+ """Validate peak eccentric and concentric velocities."""
383
+ # Eccentric
384
+ ecc_vel = metrics.get("peak_eccentric_velocity")
385
+ if ecc_vel is not None:
386
+ bounds = CMJBounds.PEAK_ECCENTRIC_VELOCITY
387
+ if not bounds.is_physically_possible(ecc_vel):
388
+ result.add_error(
389
+ "peak_eccentric_velocity",
390
+ f"Peak eccentric velocity {ecc_vel:.2f} m/s outside limits",
391
+ value=ecc_vel,
392
+ bounds=(bounds.absolute_min, bounds.absolute_max),
393
+ )
394
+ elif bounds.contains(ecc_vel, profile):
395
+ result.add_info(
396
+ "peak_eccentric_velocity",
397
+ f"Peak eccentric velocity {ecc_vel:.2f} m/s within range for {profile.value}",
398
+ value=ecc_vel,
399
+ )
400
+ else:
401
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
402
+ result.add_warning(
403
+ "peak_eccentric_velocity",
404
+ f"Peak eccentric velocity {ecc_vel:.2f} m/s outside typical range "
405
+ f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
406
+ value=ecc_vel,
407
+ bounds=(expected_min, expected_max),
408
+ )
409
+
410
+ # Concentric
411
+ con_vel = metrics.get("peak_concentric_velocity")
412
+ if con_vel is not None:
413
+ bounds = CMJBounds.PEAK_CONCENTRIC_VELOCITY
414
+ if not bounds.is_physically_possible(con_vel):
415
+ if con_vel < bounds.absolute_min:
416
+ result.add_error(
417
+ "peak_concentric_velocity",
418
+ f"Peak concentric velocity {con_vel:.2f} m/s insufficient to leave ground",
419
+ value=con_vel,
420
+ bounds=(bounds.absolute_min, bounds.absolute_max),
421
+ )
422
+ else:
423
+ result.add_error(
424
+ "peak_concentric_velocity",
425
+ f"Peak concentric velocity {con_vel:.2f} m/s exceeds elite capability",
426
+ value=con_vel,
427
+ bounds=(bounds.absolute_min, bounds.absolute_max),
428
+ )
429
+ elif bounds.contains(con_vel, profile):
430
+ result.add_info(
431
+ "peak_concentric_velocity",
432
+ f"Peak concentric velocity {con_vel:.2f} m/s within range for {profile.value}",
433
+ value=con_vel,
434
+ )
435
+ else:
436
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
437
+ result.add_warning(
438
+ "peak_concentric_velocity",
439
+ f"Peak concentric velocity {con_vel:.2f} m/s outside typical range "
440
+ f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
441
+ value=con_vel,
442
+ bounds=(expected_min, expected_max),
443
+ )
444
+
445
+ def _check_flight_time_height_consistency(
446
+ self, metrics: dict, result: ValidationResult
447
+ ) -> None:
448
+ """Verify jump height is consistent with flight time."""
449
+ flight_time = metrics.get("flight_time")
450
+ jump_height = metrics.get("jump_height")
451
+
452
+ if flight_time is None or jump_height is None:
453
+ return
454
+
455
+ # h = g * t^2 / 8
456
+ g = 9.81
457
+ expected_height = (g * flight_time**2) / 8
458
+ error_pct = abs(jump_height - expected_height) / expected_height
459
+
460
+ result.height_flight_time_consistency = error_pct
461
+
462
+ if error_pct > MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE:
463
+ result.add_error(
464
+ "height_flight_time_consistency",
465
+ f"Jump height {jump_height:.3f}m inconsistent with flight time {flight_time:.3f}s "
466
+ f"(expected {expected_height:.3f}m, error {error_pct*100:.1f}%)",
467
+ value=error_pct,
468
+ bounds=(0, MetricConsistency.HEIGHT_FLIGHT_TIME_TOLERANCE),
469
+ )
470
+ else:
471
+ result.add_info(
472
+ "height_flight_time_consistency",
473
+ f"Jump height and flight time consistent (error {error_pct*100:.1f}%)",
474
+ value=error_pct,
475
+ )
476
+
477
+ def _check_velocity_height_consistency(
478
+ self, metrics: dict, result: ValidationResult
479
+ ) -> None:
480
+ """Verify peak velocity is consistent with jump height."""
481
+ velocity = metrics.get("peak_concentric_velocity")
482
+ jump_height = metrics.get("jump_height")
483
+
484
+ if velocity is None or jump_height is None:
485
+ return
486
+
487
+ # h = v^2 / (2*g)
488
+ g = 9.81
489
+ expected_velocity = (2 * g * jump_height) ** 0.5
490
+ error_pct = abs(velocity - expected_velocity) / expected_velocity
491
+
492
+ result.velocity_height_consistency = error_pct
493
+
494
+ if error_pct > MetricConsistency.VELOCITY_HEIGHT_TOLERANCE:
495
+ error_msg = (
496
+ f"Peak velocity {velocity:.2f} m/s inconsistent with "
497
+ f"jump height {jump_height:.3f}m (expected {expected_velocity:.2f} "
498
+ f"m/s, error {error_pct*100:.1f}%)"
499
+ )
500
+ result.add_warning(
501
+ "velocity_height_consistency",
502
+ error_msg,
503
+ value=error_pct,
504
+ bounds=(0, MetricConsistency.VELOCITY_HEIGHT_TOLERANCE),
505
+ )
506
+ else:
507
+ result.add_info(
508
+ "velocity_height_consistency",
509
+ f"Peak velocity and jump height consistent (error {error_pct*100:.1f}%)",
510
+ value=error_pct,
511
+ )
512
+
513
+ def _check_rsi_validity(
514
+ self, metrics: dict, result: ValidationResult, profile: AthleteProfile
515
+ ) -> None:
516
+ """Validate Reactive Strength Index."""
517
+ flight_time = metrics.get("flight_time")
518
+ concentric_duration = metrics.get("concentric_duration")
519
+
520
+ if (
521
+ flight_time is None
522
+ or concentric_duration is None
523
+ or concentric_duration == 0
524
+ ):
525
+ return
526
+
527
+ rsi = flight_time / concentric_duration
528
+ result.rsi = rsi
529
+
530
+ if not RSIBounds.is_valid(rsi):
531
+ if rsi < RSIBounds.MIN_VALID:
532
+ result.add_error(
533
+ "rsi",
534
+ f"RSI {rsi:.2f} below physiological minimum (likely error)",
535
+ value=rsi,
536
+ bounds=(RSIBounds.MIN_VALID, RSIBounds.MAX_VALID),
537
+ )
538
+ else:
539
+ result.add_error(
540
+ "rsi",
541
+ f"RSI {rsi:.2f} exceeds physiological maximum (likely error)",
542
+ value=rsi,
543
+ bounds=(RSIBounds.MIN_VALID, RSIBounds.MAX_VALID),
544
+ )
545
+ else:
546
+ expected_min, expected_max = RSIBounds.get_rsi_range(profile)
547
+ if expected_min <= rsi <= expected_max:
548
+ result.add_info(
549
+ "rsi",
550
+ f"RSI {rsi:.2f} within expected range [{expected_min:.2f}-{expected_max:.2f}] "
551
+ f"for {profile.value}",
552
+ value=rsi,
553
+ )
554
+ else:
555
+ result.add_warning(
556
+ "rsi",
557
+ f"RSI {rsi:.2f} outside typical range [{expected_min:.2f}-{expected_max:.2f}] "
558
+ f"for {profile.value}",
559
+ value=rsi,
560
+ bounds=(expected_min, expected_max),
561
+ )
562
+
563
+ def _check_depth_height_ratio(
564
+ self, metrics: dict, result: ValidationResult
565
+ ) -> None:
566
+ """Check countermovement depth to jump height ratio."""
567
+ depth = metrics.get("countermovement_depth")
568
+ jump_height = metrics.get("jump_height")
569
+
570
+ if (
571
+ depth is None or jump_height is None or depth < 0.05
572
+ ): # Skip if depth minimal
573
+ return
574
+
575
+ ratio = jump_height / depth
576
+ result.depth_height_ratio = ratio
577
+
578
+ if ratio < MetricConsistency.DEPTH_HEIGHT_RATIO_MIN:
579
+ result.add_warning(
580
+ "depth_height_ratio",
581
+ f"Jump height {ratio:.2f}x countermovement depth: "
582
+ f"May indicate incomplete squat or standing position detection error",
583
+ value=ratio,
584
+ bounds=(
585
+ MetricConsistency.DEPTH_HEIGHT_RATIO_MIN,
586
+ MetricConsistency.DEPTH_HEIGHT_RATIO_MAX,
587
+ ),
588
+ )
589
+ elif ratio > MetricConsistency.DEPTH_HEIGHT_RATIO_MAX:
590
+ result.add_warning(
591
+ "depth_height_ratio",
592
+ f"Jump height only {ratio:.2f}x countermovement depth: "
593
+ f"Unusually inefficient (verify lowest point detection)",
594
+ value=ratio,
595
+ bounds=(
596
+ MetricConsistency.DEPTH_HEIGHT_RATIO_MIN,
597
+ MetricConsistency.DEPTH_HEIGHT_RATIO_MAX,
598
+ ),
599
+ )
600
+ else:
601
+ result.add_info(
602
+ "depth_height_ratio",
603
+ f"Depth-to-height ratio {ratio:.2f} within expected range",
604
+ value=ratio,
605
+ )
606
+
607
+ def _check_contact_depth_ratio(
608
+ self, metrics: dict, result: ValidationResult
609
+ ) -> None:
610
+ """Check contact time to countermovement depth ratio."""
611
+ contact = metrics.get("concentric_duration")
612
+ depth = metrics.get("countermovement_depth")
613
+
614
+ if contact is None or depth is None or depth < 0.05:
615
+ return
616
+
617
+ ratio = contact / depth
618
+ result.contact_depth_ratio = ratio
619
+
620
+ if ratio < MetricConsistency.CONTACT_DEPTH_RATIO_MIN:
621
+ result.add_warning(
622
+ "contact_depth_ratio",
623
+ f"Contact time {ratio:.2f}s/m to depth ratio: Very fast for depth traversed",
624
+ value=ratio,
625
+ bounds=(
626
+ MetricConsistency.CONTACT_DEPTH_RATIO_MIN,
627
+ MetricConsistency.CONTACT_DEPTH_RATIO_MAX,
628
+ ),
629
+ )
630
+ elif ratio > MetricConsistency.CONTACT_DEPTH_RATIO_MAX:
631
+ result.add_warning(
632
+ "contact_depth_ratio",
633
+ f"Contact time {ratio:.2f}s/m to depth ratio: Slow for depth traversed",
634
+ value=ratio,
635
+ bounds=(
636
+ MetricConsistency.CONTACT_DEPTH_RATIO_MIN,
637
+ MetricConsistency.CONTACT_DEPTH_RATIO_MAX,
638
+ ),
639
+ )
640
+ else:
641
+ result.add_info(
642
+ "contact_depth_ratio",
643
+ f"Contact-depth ratio {ratio:.2f} s/m within expected range",
644
+ value=ratio,
645
+ )
646
+
647
+ def _check_triple_extension(
648
+ self, metrics: dict, result: ValidationResult, profile: AthleteProfile
649
+ ) -> None:
650
+ """Validate triple extension angles."""
651
+ angles = metrics.get("triple_extension")
652
+ if angles is None:
653
+ return
654
+
655
+ hip = angles.get("hip_angle")
656
+ if hip is not None:
657
+ if not TripleExtensionBounds.hip_angle_valid(hip, profile):
658
+ result.add_warning(
659
+ "hip_angle",
660
+ f"Hip angle {hip:.1f}° outside expected range for {profile.value}",
661
+ value=hip,
662
+ )
663
+ else:
664
+ result.add_info(
665
+ "hip_angle",
666
+ f"Hip angle {hip:.1f}° within expected range for {profile.value}",
667
+ value=hip,
668
+ )
669
+
670
+ knee = angles.get("knee_angle")
671
+ if knee is not None:
672
+ if not TripleExtensionBounds.knee_angle_valid(knee, profile):
673
+ result.add_warning(
674
+ "knee_angle",
675
+ f"Knee angle {knee:.1f}° outside expected range for {profile.value}",
676
+ value=knee,
677
+ )
678
+ else:
679
+ result.add_info(
680
+ "knee_angle",
681
+ f"Knee angle {knee:.1f}° within expected range for {profile.value}",
682
+ value=knee,
683
+ )
684
+
685
+ ankle = angles.get("ankle_angle")
686
+ if ankle is not None:
687
+ if not TripleExtensionBounds.ankle_angle_valid(ankle, profile):
688
+ result.add_warning(
689
+ "ankle_angle",
690
+ f"Ankle angle {ankle:.1f}° outside expected range for {profile.value}",
691
+ value=ankle,
692
+ )
693
+ else:
694
+ result.add_info(
695
+ "ankle_angle",
696
+ f"Ankle angle {ankle:.1f}° within expected range for {profile.value}",
697
+ value=ankle,
698
+ )
699
+
700
+ @staticmethod
701
+ def _get_profile_range(
702
+ profile: AthleteProfile, bounds: MetricBounds
703
+ ) -> tuple[float, float]:
704
+ """Get min/max bounds for specific profile."""
705
+ if profile == AthleteProfile.ELDERLY:
706
+ return (bounds.practical_min, bounds.recreational_max)
707
+ elif profile == AthleteProfile.UNTRAINED:
708
+ return (bounds.practical_min, bounds.recreational_max)
709
+ elif profile == AthleteProfile.RECREATIONAL:
710
+ return (bounds.recreational_min, bounds.recreational_max)
711
+ elif profile == AthleteProfile.TRAINED:
712
+ trained_min = (bounds.recreational_min + bounds.elite_min) / 2
713
+ trained_max = (bounds.recreational_max + bounds.elite_max) / 2
714
+ return (trained_min, trained_max)
715
+ elif profile == AthleteProfile.ELITE:
716
+ return (bounds.elite_min, bounds.elite_max)
717
+ return (bounds.absolute_min, bounds.absolute_max)