11"""Movement smoothness analysis module.
22
3- This module provides tools for quantifying the smoothness of movement velocity signals
3+ This module provides tools for quantifying the smoothness of movement signals
44using multiple metrics including SPARC (Spectral Arc Length) and Jerk RMS.
55Designed for real-time analysis of motion capture or sensor data.
66
7- IMPORTANT: This module expects velocity signals as input, not position signals.
8- Velocity should be computed from position data before analysis ( for example velocity = sqrt(dx^2 + dy^2) / dt) .
7+ The module can accept either position or velocity signals as input. When position
8+ is provided, velocity is automatically computed for SPARC analysis .
99
1010Smoothness metrics are important indicators of movement quality in:
11111. Motor control assessment
1818
1919from pyeyesweb .data_models .sliding_window import SlidingWindow
2020from pyeyesweb .utils .signal_processing import apply_savgol_filter
21- from pyeyesweb .utils .math_utils import compute_sparc , compute_jerk_rms , normalize_signal
21+ from pyeyesweb .utils .math_utils import (
22+ compute_sparc , compute_jerk_rms , normalize_signal , extract_velocity_from_position
23+ )
2224from pyeyesweb .utils .validators import validate_numeric , validate_boolean
2325
2426
2527class Smoothness :
26- """Compute movement smoothness metrics from velocity signal data.
28+ """Compute movement smoothness metrics from position or velocity signal data.
2729
2830 This class analyzes movement smoothness using SPARC (Spectral Arc Length)
29- and Jerk RMS metrics. This class expects velocity signals as input,
30- not position signals . It can optionally apply Savitzky-Golay filtering
31- to reduce noise before analysis.
31+ and Jerk RMS metrics. It can accept either position or velocity signals as input.
32+ When position is provided, velocity is automatically computed . It can optionally
33+ apply Savitzky-Golay filtering to reduce noise before analysis.
3234
3335 SPARC implementation is based on Balasubramanian et al. (2015) "On the analysis
3436 of movement smoothness" from Journal of NeuroEngineering and Rehabilitation.
@@ -44,26 +46,31 @@ class Smoothness:
4446 Sampling rate of the signal in Hz (default: 50.0).
4547 use_filter : bool, optional
4648 Whether to apply Savitzky-Golay filtering before analysis (default: True).
49+ signal_type : {'velocity', 'position'}, optional
50+ Type of input signal (default: 'velocity').
51+ - 'velocity': Direct velocity input
52+ - 'position': Position input (x, y coordinates or 1D position)
4753
4854 Attributes
4955 ----------
5056 rate_hz : float
5157 Signal sampling rate.
5258 use_filter : bool
5359 Filter application flag.
60+ signal_type : str
61+ Type of input signal ('velocity' or 'position').
5462
5563 Examples
5664 --------
5765 >>> from pyeyesweb.low_level.smoothness import Smoothness
5866 >>> from pyeyesweb.data_models.sliding_window import SlidingWindow
5967 >>> import numpy as np
6068 >>>
61- >>> # Generate sample velocity data
62- >>> # For example, from tracking hand movement: velocity = sqrt(dx^2 + dy^2) / dt
69+ >>> # Example 1: Using velocity data (traditional approach)
6370 >>> t = np.linspace(0, 2, 200)
6471 >>> velocity_data = np.sin(2 * np.pi * t) + 0.1 * np.random.randn(200)
6572 >>>
66- >>> smooth = Smoothness(rate_hz=100.0, use_filter=True)
73+ >>> smooth = Smoothness(rate_hz=100.0, use_filter=True, signal_type='velocity' )
6774 >>> window = SlidingWindow(max_length=200, n_columns=1)
6875 >>>
6976 >>> # Add velocity data to the window
@@ -72,6 +79,22 @@ class Smoothness:
7279 >>>
7380 >>> result = smooth(window)
7481 >>> print(f"SPARC: {result['sparc']:.3f}, Jerk RMS: {result['jerk_rms']:.3f}")
82+ >>>
83+ >>> # Example 2: Using position data
84+ >>> # 2D position data (x, y coordinates)
85+ >>> t = np.linspace(0, 2, 200)
86+ >>> x_positions = 10 * np.sin(2 * np.pi * t)
87+ >>> y_positions = 5 * np.cos(2 * np.pi * t)
88+ >>>
89+ >>> smooth_pos = Smoothness(rate_hz=100.0, use_filter=True, signal_type='position')
90+ >>> window_pos = SlidingWindow(max_length=200, n_columns=2)
91+ >>>
92+ >>> # Add position data to the window
93+ >>> for x, y in zip(x_positions, y_positions):
94+ ... window_pos.append([x, y])
95+ >>>
96+ >>> result = smooth_pos(window_pos)
97+ >>> print(f"SPARC: {result['sparc']:.3f}, Jerk RMS: {result['jerk_rms']:.3f}")
7598
7699 Notes
77100 -----
@@ -86,10 +109,15 @@ class Smoothness:
86109 12(1), 1-11.
87110 """
88111
89- def __init__ (self , rate_hz = 50.0 , use_filter = True ):
112+ def __init__ (self , rate_hz = 50.0 , use_filter = True , signal_type = 'velocity' ):
90113 self .rate_hz = validate_numeric (rate_hz , 'rate_hz' , min_val = 0.01 , max_val = 100000 )
91114 self .use_filter = validate_boolean (use_filter , 'use_filter' )
92115
116+ # Validate signal_type
117+ if signal_type not in ['velocity' , 'position' ]:
118+ raise ValueError (f"signal_type must be 'velocity' or 'position', got '{ signal_type } '" )
119+ self .signal_type = signal_type
120+
93121 def _filter_signal (self , signal ):
94122 """Apply Savitzky-Golay filter if enabled and enough data.
95123
@@ -108,37 +136,52 @@ def _filter_signal(self, signal):
108136 return apply_savgol_filter (signal , self .rate_hz )
109137
110138 def __call__ (self , sliding_window : SlidingWindow ):
111- """Compute smoothness metrics from windowed velocity signal data.
139+ """Compute smoothness metrics from windowed signal data.
112140
113141 Parameters
114142 ----------
115143 sliding_window : SlidingWindow
116- Buffer containing velocity signal data to analyze.
117- Input should be velocity values, not position values.
144+ Buffer containing signal data to analyze.
145+ - If signal_type='velocity': expects velocity values
146+ - If signal_type='position': expects position values (1D or 2D)
118147
119148 Returns
120149 -------
121150 dict
122151 Dictionary containing:
123152 - 'sparc': Spectral Arc Length (closer to 0 = smoother).
124153 Returns NaN if insufficient data.
125- - 'jerk_rms': RMS of jerk (computed from velocity input) .
154+ - 'jerk_rms': RMS of jerk.
126155 Returns NaN if insufficient data.
127156 """
128157 if len (sliding_window ) < 5 :
129158 return {"sparc" : np .nan , "jerk_rms" : np .nan }
130159
131160 signal , _ = sliding_window .to_array ()
132161
133- # If multi-channel, compute for first channel only
134- if signal .ndim > 1 and signal .shape [1 ] > 1 :
135- signal = signal [:, 0 ]
136-
137- filtered = self ._filter_signal (signal .squeeze ())
138- normalized = normalize_signal (filtered )
139-
162+ # Extract or compute velocity based on signal type
163+ if self .signal_type == 'position' :
164+ # For position: compute velocity for SPARC
165+ velocity = extract_velocity_from_position (signal , self .rate_hz )
166+ # For jerk: use first dimension of position
167+ signal_for_jerk = signal [:, 0 ] if signal .ndim > 1 else signal .squeeze ()
168+ else : # self.signal_type == 'velocity'
169+ # For velocity: use directly
170+ if signal .ndim > 1 and signal .shape [1 ] > 1 :
171+ velocity = signal [:, 0 ]
172+ else :
173+ velocity = signal .squeeze ()
174+ signal_for_jerk = velocity
175+
176+ # Apply filtering to velocity for SPARC
177+ filtered_velocity = self ._filter_signal (velocity )
178+ normalized = normalize_signal (filtered_velocity )
179+
180+ # Compute SPARC (always uses velocity)
140181 sparc = compute_sparc (normalized , self .rate_hz )
141- # Note: Since the smoothness module expects velocity input (as shown in examples), we specify signal_type='velocity' to compute proper jerk
142- jerk = compute_jerk_rms (filtered , self .rate_hz , signal_type = 'velocity' )
182+
183+ # Compute jerk with appropriate signal type
184+ filtered_for_jerk = self ._filter_signal (signal_for_jerk )
185+ jerk = compute_jerk_rms (filtered_for_jerk , self .rate_hz , signal_type = self .signal_type )
143186
144187 return {"sparc" : sparc , "jerk_rms" : jerk }
0 commit comments