10
10
import gaitalytics .model as model
11
11
import gaitalytics .utils .linalg as linalg
12
12
import gaitalytics .utils .mocap as mocap
13
+ import gaitalytics .utils .math as math
13
14
14
15
15
16
class FeatureCalculation (ABC ):
@@ -246,7 +247,20 @@ def _get_progression_vector(self, trial: model.Trial) -> xr.DataArray:
246
247
An xarray DataArray containing the calculated progression vector.
247
248
"""
248
249
return mocap .get_progression_vector (trial , self ._config )
250
+
251
+ def _get_sagittal_vector (self , trial : model .Trial ) -> xr .DataArray :
252
+ """Calculate the sagittal vector for a trial.
249
253
254
+ The sagittal vector is the vector normal to the sagittal plane.
255
+
256
+ Args:
257
+ trial: The trial for which to calculate the sagittal vector.
258
+ Returns:
259
+ An xarray DataArray containing the calculated sagittal vector.
260
+ """
261
+ progression_vector = self ._get_progression_vector (trial )
262
+ vertical_vector = xr .DataArray ([0 ,0 ,1 ], dims = ['axis' ], coords = {'axis' : ['x' , 'y' , 'z' ]})
263
+ return linalg .get_normal_vector (progression_vector , vertical_vector )
250
264
251
265
class TimeSeriesFeatures (_CycleFeaturesCalculation ):
252
266
"""Calculate time series features for a trial.
@@ -477,14 +491,19 @@ class SpatialFeatures(_PointDependentFeature):
477
491
This class calculates following spatial features for a trial.
478
492
- step_length
479
493
- step_width
494
+ - minimal_toe_clearance
495
+ - AP_margin_of_stability
496
+ - ML_margin_of_stability
480
497
"""
481
498
482
499
def _calculate (self , trial : model .Trial ) -> xr .DataArray :
483
500
484
501
"""Calculate the spatial features for a trial.
485
502
486
- Definitions of the spatial features
487
- Hollmann et al. 2011 (doi: 10.1016/j.gaitpost.2011.03.024)
503
+ Definitions of the spatial features:
504
+ Step length & Step width: Hollmann et al. 2011 (doi: 10.1016/j.gaitpost.2011.03.024)
505
+ Margin of stability: Jinfeng et al. 2021 (doi: 10.1152/jn.00091.2021)
506
+ Minimal toe clearance: Schulz 2017 (doi: 10.1016/j.jbiomech.2017.02.024)
488
507
489
508
Args:
490
509
trial: The trial for which to calculate the features.
@@ -499,18 +518,83 @@ def _calculate(self, trial: model.Trial) -> xr.DataArray:
499
518
if trial .events is None :
500
519
raise ValueError ("Trial does not have events." )
501
520
502
- if trial .events .attrs ["context" ] == "Right" :
503
- ipsi_marker = mapping .MappedMarkers .R_TOE
504
- contra_marker = mapping .MappedMarkers .L_TOE
505
- else :
506
- ipsi_marker = mapping .MappedMarkers .L_TOE
507
- contra_marker = mapping .MappedMarkers .R_TOE
508
-
509
- results_dict = self ._calculate_step_length (trial , ipsi_marker , contra_marker )
521
+ marker_dict = self .select_markers_for_spatial_features (trial )
522
+
523
+ results_dict = self ._calculate_step_length (trial , marker_dict ["ipsi_heel" ], marker_dict ["contra_heel" ])
524
+ results_dict .update (
525
+ self ._calculate_step_width (trial , marker_dict ["ipsi_heel" ], marker_dict ["contra_heel" ])
526
+ )
527
+ results_dict .update (
528
+ self ._calculate_stride_length (trial , marker_dict ["ipsi_heel" ], marker_dict ["contra_heel" ])
529
+ )
510
530
results_dict .update (
511
- self ._calculate_step_width (trial , ipsi_marker , contra_marker )
531
+ self ._calculate_minimal_toe_clearance (trial , marker_dict [ "ipsi_toe_2" ], marker_dict [ "ipsi_heel" ], marker_dict [ "ipsi_toe_5" ] )
512
532
)
533
+ if marker_dict ["xcom" ] is not None :
534
+ results_dict .update (
535
+ self ._calculate_AP_margin_of_stability (trial , marker_dict ["ipsi_toe_2" ], marker_dict ["contra_toe_2" ], marker_dict ["xcom" ])
536
+ )
537
+ if (marker_dict ['ipsi_ankle' ] is not None ) and (marker_dict ['contra_ankle' ] is not None ):
538
+ results_dict .update (
539
+ self ._calculate_ML_margin_of_stability (trial , marker_dict ['ipsi_ankle' ], marker_dict ['contra_ankle' ], marker_dict ["xcom" ])
540
+ )
541
+
513
542
return self ._create_result_from_dict (results_dict )
543
+
544
+
545
+ def select_markers_for_spatial_features (self ,
546
+ trial : model .Trial
547
+ ) -> dict [str , mapping .MappedMarkers ]:
548
+ """Select markers based on the trial's context (Right or Left). If some markers are missing, return them as None
549
+
550
+ Args:
551
+ trial: The trial object containing event attributes.
552
+
553
+ Returns:
554
+ A dictionary of markers based on context
555
+ """
556
+ if trial .events .attrs ["context" ] == "Right" :
557
+ ipsi_heel_marker = mapping .MappedMarkers .R_HEEL
558
+ ipsi_toe_2_marker = mapping .MappedMarkers .R_TOE
559
+ ipsi_toe_5_marker = self .get_optional_marker ('R_TOE_5' )
560
+ ipsi_ankle_marker = self .get_optional_marker ('R_ANKLE' )
561
+
562
+ contra_toe_2_marker = mapping .MappedMarkers .L_TOE
563
+ contra_heel_marker = mapping .MappedMarkers .L_HEEL
564
+ contra_ankle_marker = self .get_optional_marker ('L_ANKLE' )
565
+
566
+ else :
567
+ ipsi_heel_marker = mapping .MappedMarkers .L_HEEL
568
+ ipsi_toe_2_marker = mapping .MappedMarkers .L_TOE
569
+ ipsi_toe_5_marker = self .get_optional_marker ('L_TOE_5' )
570
+ ipsi_ankle_marker = self .get_optional_marker ('L_ANKLE' )
571
+
572
+ contra_toe_2_marker = mapping .MappedMarkers .R_TOE
573
+ contra_heel_marker = mapping .MappedMarkers .R_HEEL
574
+ contra_ankle_marker = self .get_optional_marker ('R_ANKLE' )
575
+
576
+ xcom_marker = self .get_optional_marker ('XCOM' )
577
+
578
+ return {
579
+ "ipsi_toe_2" : ipsi_toe_2_marker ,
580
+ "ipsi_toe_5" : ipsi_toe_5_marker ,
581
+ "ipsi_heel" : ipsi_heel_marker ,
582
+ "ipsi_ankle" : ipsi_ankle_marker ,
583
+ "contra_toe_2" : contra_toe_2_marker ,
584
+ "contra_heel" : contra_heel_marker ,
585
+ "contra_ankle" : contra_ankle_marker ,
586
+ "xcom" : xcom_marker
587
+ }
588
+
589
+
590
+ def get_optional_marker (self , marker_name : str ) -> mapping .MappedMarkers | None :
591
+ """ Returns the marker if exists, else returns None
592
+
593
+ Args:
594
+ marker_name (str): The marker name
595
+ """
596
+ return getattr (mapping .MappedMarkers , marker_name , None )
597
+
514
598
515
599
def _calculate_step_length (
516
600
self ,
@@ -525,7 +609,6 @@ def _calculate_step_length(
525
609
ipsi_marker: The ipsi-lateral heel marker.
526
610
contra_marker: The contra-lateral heel marker.
527
611
528
-
529
612
Returns:
530
613
The calculated step length.
531
614
"""
@@ -545,6 +628,7 @@ def _calculate_step_length(
545
628
distance = linalg .calculate_distance (projected_ipsi , projected_contra ).values
546
629
return {"step_length" : distance }
547
630
631
+
548
632
def _calculate_step_width (
549
633
self ,
550
634
trial : model .Trial ,
@@ -577,3 +661,207 @@ def _calculate_step_width(
577
661
distance = linalg .calculate_distance (ipsi_heel , projected_ipsi ).values
578
662
579
663
return {"step_width" : distance }
664
+
665
+
666
+ def _calculate_stride_length (
667
+ self ,
668
+ trial : model .Trial ,
669
+ ipsi_marker : mapping .MappedMarkers ,
670
+ contra_marker : mapping .MappedMarkers ,
671
+ ) -> dict [str , np .ndarray ]:
672
+ """Calculate the stride length for a trial.
673
+
674
+ Args:
675
+ trial: The trial for which to calculate the stride length.
676
+ ipsi_marker: The ipsi-lateral heel marker.
677
+ contra_marker: The contra-lateral heel marker.
678
+
679
+ Returns:
680
+ The calculated stride length.
681
+ """
682
+
683
+ event_times = self .get_event_times (trial .events )
684
+ progress_axis = self ._get_progression_vector (trial )
685
+ progress_axis = linalg .normalize_vector (progress_axis )
686
+
687
+ total_distance = 0
688
+
689
+ for event_time in [event_times [2 ], event_times [- 1 ]]:
690
+ ipsi_heel = self ._get_marker_data (trial , ipsi_marker ).sel (time = event_time , method = "nearest" )
691
+ contra_heel = self ._get_marker_data (trial , contra_marker ).sel (time = event_time , method = "nearest" )
692
+ print (f'ipsi heel: { ipsi_heel } ' )
693
+
694
+ projected_ipsi = linalg .project_point_on_vector (ipsi_heel , progress_axis )
695
+ projected_contra = linalg .project_point_on_vector (contra_heel , progress_axis )
696
+ print (f'projected ipsi: { projected_ipsi } ' )
697
+
698
+ distance = linalg .calculate_distance (projected_ipsi , projected_contra ).values
699
+ print (f'distance: { distance } ' )
700
+ total_distance += distance
701
+
702
+
703
+ return {"stride_length" : total_distance }
704
+
705
+
706
+ def _calculate_minimal_toe_clearance (
707
+ self ,
708
+ trial : model .Trial ,
709
+ ipsi_toe_marker : mapping .MappedMarkers ,
710
+ ipsi_heel_marker : mapping .MappedMarkers ,
711
+ * opt_ipsi_toe_markers : mapping .MappedMarkers
712
+ ) -> dict [str , np .ndarray ]:
713
+ """Calculate the minimal toe clearance for a trial. Toe clearance is computed for all toe markers passed, only the minimal is returned
714
+
715
+ Args:
716
+ trial (model.Trial): The trial to compute the minimal toe clearance for
717
+ ipsi_toe_marker (mapping.MappedMarkers): The ipsilateral toe marker
718
+ ipsi_heel_marker (mapping.MappedMarkers): The ipsilateral heel marker
719
+
720
+ Returns:
721
+ dict[str, np.ndarray]: The calculated minimal toe clearance in a dict
722
+ """
723
+ event_times = self .get_event_times (trial .events )
724
+
725
+ ipsi_heel = self ._get_marker_data (trial , ipsi_heel_marker ).sel (time = slice (event_times [3 ], event_times [4 ]))
726
+ ipsi_toe = self ._get_marker_data (trial , ipsi_toe_marker ).sel (time = slice (event_times [3 ], event_times [4 ]))
727
+
728
+ toes_vel = linalg .calculate_speed_norm (ipsi_toe )
729
+
730
+ additional_meta_data = []
731
+
732
+ for meta_marker in opt_ipsi_toe_markers :
733
+ if meta_marker is not None :
734
+ meta_data = self ._get_marker_data (trial , meta_marker ).sel (time = slice (event_times [3 ], event_times [4 ]))
735
+ toes_vel += linalg .calculate_speed_norm (meta_data )
736
+ additional_meta_data .append (meta_data )
737
+
738
+ toes_vel /= (1 + len (additional_meta_data ))
739
+
740
+ mtc_i = self ._find_mtc_index (ipsi_toe , ipsi_heel , toes_vel )
741
+ mtc_additional_indices = [
742
+ self ._find_mtc_index (meta_data , ipsi_heel , toes_vel )
743
+ for meta_data in additional_meta_data
744
+ ]
745
+
746
+ # Handle NaN cases and find minimal clearance
747
+ mtc_values = [] if np .isnan (mtc_i ) else [ipsi_toe .sel (axis = 'z' )[mtc_i ]]
748
+ for i , meta_data in zip (mtc_additional_indices , additional_meta_data ):
749
+ if not np .isnan (i ):
750
+ mtc_values .append (meta_data .sel (axis = 'z' )[i ])
751
+
752
+ if not mtc_values :
753
+ return {"minimal_toe_clearance" : np .NaN }
754
+
755
+ return {"minimal_toe_clearance" : min (mtc_values )}
756
+
757
+
758
+ def _find_mtc_index (self ,
759
+ toe_position : xr .DataArray ,
760
+ heel_position : xr .DataArray ,
761
+ toes_vel : xr .DataArray ):
762
+ """Find the time corresponding to minimal toe clearance of a specific toe.
763
+ Valid minimal toe clearance point must validates conditions
764
+ defined in Schulz 2017 (doi: 10.1016/j.jbiomech.2017.02.024)
765
+ Args:
766
+ toe_position: A DataArray containing positions of the toe
767
+ heel_position: A DataArray containing positions of the heel
768
+ toes_vel: A DataArray containing the mean toes velocity at each timepoint
769
+ Returns:
770
+ The time corresponding to minimal toe clearance for the input toe.
771
+ """
772
+ toes_vel_up_quant = np .quantile (toes_vel , .5 )
773
+ toe_z = toe_position .sel (axis = 'z' )
774
+ heel_z = heel_position .sel (axis = 'z' )
775
+
776
+ # Check conditions according to Schulz 2017
777
+ mtc_i = math .find_local_minimas (toe_z )
778
+ mtc_i = [i for i in mtc_i if toes_vel [i ] >= toes_vel_up_quant ]
779
+ mtc_i = [i for i in mtc_i if toe_z [i ] <= heel_z [i ]]
780
+
781
+ return np .NaN if not mtc_i else min (mtc_i , key = lambda i : toe_z [i ])
782
+
783
+
784
+ def _calculate_AP_margin_of_stability (self ,
785
+ trial : model .Trial ,
786
+ ipsi_toe_marker : mapping .MappedMarkers ,
787
+ contra_toe_marker : mapping .MappedMarkers ,
788
+ xcom_marker : mapping .MappedMarkers ,
789
+ ) -> dict [str , np .ndarray ]:
790
+ """Calculate the anterio-posterior margin of stability at heel strike
791
+ Args:
792
+ trial: The trial for which to calculate the AP margin of stability
793
+ ipsi_toe_marker: The ipsi-lateral toe marker
794
+ contra_marker: The contra-lateral toe marker
795
+ xcom_marker: The extrapolated center of mass marker
796
+
797
+ Returns:
798
+ The calculated anterio-posterior margin of stability in a dict
799
+ """
800
+ event_times = self .get_event_times (trial .events )
801
+
802
+ ipsi_toe = self ._get_marker_data (trial , ipsi_toe_marker ).sel (
803
+ time = event_times [0 ], method = "nearest"
804
+ )
805
+ contra_toe = self ._get_marker_data (trial , contra_toe_marker ).sel (
806
+ time = event_times [0 ], method = "nearest"
807
+ )
808
+ xcom = self ._get_marker_data (trial , xcom_marker ).sel (
809
+ time = event_times [0 ], method = "nearest"
810
+ )
811
+
812
+ progress_axis = self ._get_progression_vector (trial )
813
+ progress_axis = linalg .normalize_vector (progress_axis )
814
+
815
+ projected_ipsi = linalg .project_point_on_vector (ipsi_toe , progress_axis )
816
+ projected_contra = linalg .project_point_on_vector (contra_toe , progress_axis )
817
+ projected_xcom = linalg .project_point_on_vector (xcom , progress_axis )
818
+
819
+ bos_len = linalg .calculate_distance (projected_ipsi , projected_contra ).values
820
+ xcom_len = linalg .calculate_distance (projected_contra , projected_xcom ).values
821
+
822
+ mos = bos_len - xcom_len
823
+
824
+ return {"AP_margin_of_stability" : mos }
825
+
826
+
827
+ def _calculate_ML_margin_of_stability (self ,
828
+ trial : model .Trial ,
829
+ ipsi_ankle_marker : mapping .MappedMarkers ,
830
+ contra_ankle_marker : mapping .MappedMarkers ,
831
+ xcom_marker : mapping .MappedMarkers
832
+ ) -> dict [str , np .ndarray ]:
833
+ """Calculate the medio-lateral margin of stability at heel strike
834
+ Args:
835
+ trial: The trial for which to calculate the AP margin of stability
836
+ ipsi_toe_marker: The ipsi-lateral lateral ankle marker
837
+ contra_marker: The contra-lateral lateral ankle marker
838
+ xcom_marker: The extrapolated center of mass marker
839
+
840
+ Returns:
841
+ The calculated anterio-posterior margin of stability in a dict
842
+ """
843
+ event_times = self .get_event_times (trial .events )
844
+
845
+ ipsi_ankle = self ._get_marker_data (trial , ipsi_ankle_marker ).sel (
846
+ time = event_times [0 ], method = "nearest"
847
+ )
848
+ contra_ankle = self ._get_marker_data (trial , contra_ankle_marker ).sel (
849
+ time = event_times [0 ], method = "nearest"
850
+ )
851
+ xcom = self ._get_marker_data (trial , xcom_marker ).sel (
852
+ time = event_times [0 ], method = "nearest"
853
+ )
854
+
855
+ sagittal_axis = self ._get_sagittal_vector (trial )
856
+ sagittal_axis = linalg .normalize_vector (sagittal_axis )
857
+
858
+ projected_ipsi = linalg .project_point_on_vector (ipsi_ankle , sagittal_axis )
859
+ projected_contra = linalg .project_point_on_vector (contra_ankle , sagittal_axis )
860
+ projected_xcom = linalg .project_point_on_vector (xcom , sagittal_axis )
861
+
862
+ bos_len = linalg .calculate_distance (projected_contra , projected_ipsi ).values
863
+ xcom_len = linalg .calculate_distance (projected_contra , projected_xcom ).values
864
+
865
+ mos = bos_len - xcom_len
866
+
867
+ return {"ML_margin_of_stability" : mos }
0 commit comments