1+ import numpy as np
2+ import pytest
3+ from scipy .signal import resample_poly
4+
5+ import smsfusion as sf
6+ from smsfusion ._ins ._ahrs import AHRS , _state_transition_matrix_init , _state_transition_matrix_update , _measurement_matrix_init , _reset , _process_noise_covariance_matrix
7+ from smsfusion .benchmark import (
8+ benchmark_pure_attitude_beat_202311A ,
9+ benchmark_pure_attitude_chirp_202311A
10+ )
11+
12+
13+ def test_state_transition_matrix_init ():
14+ dt = 0.1
15+ dvel = np .ones (3 ) * 0.01
16+ dtheta = np .ones (3 ) * 0.02
17+ R_nb = np .eye (3 )
18+ gbc = 0.01
19+
20+ phi_out = _state_transition_matrix_init (dt , dvel , dtheta , R_nb , gbc )
21+ phi_expected = np .array ([
22+ [1.0 , 0.0 , 0.0 , 0.0 , 0.01 , - 0.01 , 0.0 , 0.0 , 0.0 ],
23+ [0.0 , 1.0 , 0.0 , - 0.01 , 0.0 , 0.01 , 0.0 , 0.0 , 0.0 ],
24+ [0.0 , 0.0 , 1.0 , 0.01 , - 0.01 , 0.0 , 0.0 , 0.0 , 0.0 ],
25+ [0.0 , 0.0 , 0.0 , 1.0 , 0.02 , - 0.02 , - dt , 0.0 , 0.0 ],
26+ [0.0 , 0.0 , 0.0 , - 0.02 , 1.0 , 0.02 , 0.0 , - dt , 0.0 ],
27+ [0.0 , 0.0 , 0.0 , 0.02 , - 0.02 , 1.0 , 0.0 , 0.0 , - dt ],
28+ [0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 1.0 - dt / gbc , 0.0 , 0.0 ],
29+ [0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 1.0 - dt / gbc , 0.0 ],
30+ [0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 1.0 - dt / gbc ],
31+ ])
32+
33+ np .testing .assert_almost_equal (phi_out , phi_expected )
34+
35+
36+ def test_state_transition_matrix_update ():
37+ dt = 0.1
38+ dvel = np .ones (3 ) * 0.01
39+ dtheta = np .ones (3 ) * 0.02
40+ R_nb = np .eye (3 )
41+ gbc = 0.01
42+
43+ phi_init = _state_transition_matrix_init (dt , dvel , dtheta , R_nb , gbc )
44+
45+ dtheta_update = np .ones (3 ) * 0.01
46+ dvel_update = np .ones (3 ) * 0.1
47+ phi_out = _state_transition_matrix_update (
48+ phi_init ,
49+ dvel = dvel_update ,
50+ dtheta = dtheta_update ,
51+ R_nb = R_nb
52+ )
53+
54+ phi_expected = np .array ([
55+ [1.0 , 0.0 , 0.0 , 0.0 , 0.1 , - 0.1 , 0.0 , 0.0 , 0.0 ],
56+ [0.0 , 1.0 , 0.0 , - 0.1 , 0.0 , 0.1 , 0.0 , 0.0 , 0.0 ],
57+ [0.0 , 0.0 , 1.0 , 0.1 , - 0.1 , 0.0 , 0.0 , 0.0 , 0.0 ],
58+ [0.0 , 0.0 , 0.0 , 1.0 , 0.01 , - 0.01 , - dt , 0.0 , 0.0 ],
59+ [0.0 , 0.0 , 0.0 , - 0.01 , 1.0 , 0.01 , 0.0 , - dt , 0.0 ],
60+ [0.0 , 0.0 , 0.0 , 0.01 , - 0.01 , 1.0 , 0.0 , 0.0 , - dt ],
61+ [0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 1.0 - dt / gbc , 0.0 , 0.0 ],
62+ [0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 1.0 - dt / gbc , 0.0 ],
63+ [0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 1.0 - dt / gbc ],
64+ ])
65+
66+ np .testing .assert_almost_equal (phi_out , phi_expected )
67+
68+
69+ def test_measurement_matrix_init ():
70+ q_nb = np .array ([1.0 , 0.0 , 0.0 , 0.0 ])
71+
72+ expect = np .zeros ((4 , 9 ))
73+ expect [0 :3 , 0 :3 ] = np .eye (3 )
74+ expect [3 , 3 :6 ] = np .array ([0.0 , 0.0 , 1.0 ])
75+ # kappa -> zero due to unit quat
76+
77+ np .testing .assert_array_equal (_measurement_matrix_init (q_nb ), expect )
78+
79+
80+ def test_process_noise_covariance_matrix ():
81+ dt = 0.1
82+ vrw = 0.0005
83+ arw = 0.00005
84+ gbs = 0.00005
85+ gbc = 50.0
86+ Q_out = _process_noise_covariance_matrix (dt , vrw , arw , gbs , gbc )
87+ Q_expect = np .array ([
88+ [dt * vrw ** 2 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 ],
89+ [0.0 , dt * vrw ** 2 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 ],
90+ [0.0 , 0.0 , dt * vrw ** 2 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 ],
91+ [0.0 , 0.0 , 0.0 , dt * arw ** 2 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 ],
92+ [0.0 , 0.0 , 0.0 , 0.0 , dt * arw ** 2 , 0.0 , 0.0 , 0.0 , 0.0 ],
93+ [0.0 , 0.0 , 0.0 , 0.0 , 0.0 , dt * arw ** 2 , 0.0 , 0.0 , 0.0 ],
94+ [0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , dt * (2.0 * gbs ** 2 / gbc ), 0.0 , 0.0 ],
95+ [0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , dt * (2.0 * gbs ** 2 / gbc ), 0.0 ],
96+ [0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , dt * (2.0 * gbs ** 2 / gbc )],
97+ ])
98+
99+ np .testing .assert_allclose (Q_out , Q_expect )
100+
101+ def test_reset ():
102+ v_n = np .array ([0.0 , 0.1 , 0.0 ])
103+ q_nb = np .array ([1.0 , 0.0 , 0.0 , 0.0 ])
104+ bg_b = np .zeros (3 )
105+ dx = np .array ([0.1 , 0.0 , 0.0 , 0.01 , 0.0 , 0.0 , 0.1 , - 0.1 , 0.2 ])
106+
107+ dx , v_n , q_nb , bg_b = _reset (dx , v_n , q_nb , bg_b )
108+
109+ np .testing .assert_allclose (dx , np .zeros_like (dx ))
110+ np .testing .assert_allclose (v_n , np .array ([0.1 , 0.1 , 0.0 ]))
111+ np .testing .assert_allclose (bg_b , np .array ([0.1 , - 0.1 , 0.2 ]))
112+ np .testing .assert_allclose (q_nb , np .array ([np .cos (0.01 / 2 ), np .sin (0.01 / 2 ), 0.0 , 0.0 ]), atol = 1e-6 )
113+
114+
115+ def test_ahrs_init ():
116+ mekf = AHRS (
117+ 10.0
118+ )
119+ np .testing .assert_allclose (mekf .velocity (), np .zeros (3 ))
120+ np .testing .assert_allclose (mekf .quaternion (), np .array ([1.0 , 0.0 , 0.0 , 0.0 ]))
121+ np .testing .assert_allclose (mekf .bias_gyro (), np .zeros (3 ))
122+ np .testing .assert_allclose (mekf .P , np .array (sf ._ins ._ahrs .P0 ))
123+ assert mekf ._g == 9.80665
124+ assert mekf ._nav_frame == "ned"
125+
126+
127+ @pytest .mark .parametrize ("nav_frame, scale" , (["NED" , 1.0 ], ["ENU" , - 1.0 ]))
128+ def test_ahrs_nav_frame (nav_frame , scale ):
129+ mekf = AHRS (
130+ 10.0 ,
131+ nav_frame = nav_frame
132+ )
133+
134+ assert mekf ._nav_frame == nav_frame .lower ()
135+ np .testing .assert_allclose (mekf ._g_n , np .array ([0.0 , 0.0 , mekf ._g * scale ]))
136+
137+
138+ def test_ahrs_methods ():
139+ vel_init = np .array ([0.0 , 0.1 , - 0.2 ])
140+ euler_init = np .array ([10.0 , 20.0 , 30.0 ])
141+ quaternion_init = sf .quaternion_from_euler (euler_init , degrees = True )
142+ bg_init = np .array ([0.01 , - 0.01 , 0.02 ])
143+
144+ mekf = AHRS (
145+ 10.0 ,
146+ v = vel_init ,
147+ q = quaternion_init ,
148+ bg = bg_init
149+ )
150+
151+ np .testing .assert_allclose (mekf .velocity (), vel_init )
152+ np .testing .assert_allclose (mekf .euler (), np .radians (euler_init ))
153+ np .testing .assert_allclose (mekf .euler (degrees = True ), euler_init )
154+ np .testing .assert_allclose (mekf .quaternion (), quaternion_init )
155+ np .testing .assert_allclose (mekf .bias_gyro (), bg_init )
156+ np .testing .assert_allclose (mekf .bias_gyro (degrees = True ), np .degrees (bg_init ))
157+
158+
159+ @pytest .mark .parametrize (
160+ "benchmark_gen, degrees" ,
161+ [(benchmark_pure_attitude_beat_202311A , False ), (benchmark_pure_attitude_chirp_202311A , True )],
162+ )
163+ def test_ahrs_no_head_aiding_benchmark (benchmark_gen , degrees ):
164+ fs_imu = 100.0
165+ warmup = int (fs_imu * 600.0 ) # truncate 600 seconds from the beginning
166+
167+ # Reference signals (without noise)
168+ t , euler_ref , acc_ref , gyro_ref = benchmark_gen (fs_imu )
169+
170+ # IMU measurements (with noise)
171+ bg = np .array ([0.01 , - 0.02 , 0.0 ])
172+ noise_model = sf .noise .IMUNoise (
173+ err_acc = sf .constants .ERR_ACC_MOTION2 ,
174+ err_gyro = sf .constants .ERR_GYRO_MOTION2 ,
175+ seed = 0 ,
176+ )
177+ imu_noise = noise_model (fs_imu , len (t ))
178+ acc_noise = acc_ref + imu_noise [:, :3 ]
179+ gyro_noise = gyro_ref + imu_noise [:, 3 :] + bg
180+
181+ if degrees :
182+ gyro_noise = np .degrees (gyro_noise )
183+
184+ # MEKF
185+ q0 = sf .quaternion_from_euler (euler_ref [0 ], degrees = False )
186+ mekf = AHRS (
187+ fs_imu ,
188+ q = q0 ,
189+ gyro_noise_density = sf .constants .ERR_GYRO_MOTION2 ["N" ],
190+ gyro_bias_stability = sf .constants .ERR_GYRO_MOTION2 ["B" ],
191+ gyro_bias_corr_time = sf .constants .ERR_GYRO_MOTION2 ["tau_cb" ],
192+ )
193+
194+ # Apply filter
195+ euler_out , bias_gyro_out = [], []
196+ for i , (f_i , w_i ) in enumerate (
197+ zip (acc_noise , gyro_noise )
198+ ):
199+
200+ dvel = f_i / fs_imu
201+ dtheta = w_i / fs_imu
202+
203+ mekf .update (
204+ dvel ,
205+ dtheta ,
206+ degrees = degrees ,
207+ )
208+
209+ euler_out .append (mekf .euler (degrees = False ))
210+ bias_gyro_out .append (mekf .bias_gyro (degrees = False ))
211+
212+ euler_out = np .array (euler_out )
213+ bias_gyro_out = np .array (bias_gyro_out )
214+
215+ # Half-sample shift (compensates for the delay introduced by Euler integration)
216+ euler_out = resample_poly (euler_out , 2 , 1 )[1 :- 1 :2 ]
217+ euler_ref = euler_ref [:- 1 , :]
218+
219+ roll_rms , pitch_rms , yaw_rms = np .std ((euler_out - euler_ref )[warmup :], axis = 0 )
220+ bias_gyro_x_rms , bias_gyro_y_rms , bias_gyro_z_rms = np .std (
221+ (bias_gyro_out - bg )[warmup :], axis = 0
222+ )
223+
224+ assert np .degrees (roll_rms ) <= 0.1
225+ assert np .degrees (pitch_rms ) <= 0.1
226+ assert np .degrees (bias_gyro_x_rms ) <= 0.005
227+ assert np .degrees (bias_gyro_y_rms ) <= 0.005
228+
229+
230+ @pytest .mark .parametrize (
231+ "benchmark_gen, degrees" ,
232+ [(benchmark_pure_attitude_beat_202311A , False ), (benchmark_pure_attitude_chirp_202311A , True )],
233+ )
234+ def test_ahrs_benchmark (benchmark_gen , degrees ):
235+ fs_imu = 100.0
236+ warmup = int (fs_imu * 600.0 ) # truncate 600 seconds from the beginning
237+
238+ # Reference signals (without noise)
239+ t , euler_ref , acc_ref , gyro_ref = benchmark_gen (fs_imu )
240+
241+ # IMU measurements (with noise)
242+ bg = np .array ([0.01 , - 0.02 , 0.0 ])
243+ noise_model = sf .noise .IMUNoise (
244+ err_acc = sf .constants .ERR_ACC_MOTION2 ,
245+ err_gyro = sf .constants .ERR_GYRO_MOTION2 ,
246+ seed = 0 ,
247+ )
248+ imu_noise = noise_model (fs_imu , len (t ))
249+ acc_noise = acc_ref + imu_noise [:, :3 ]
250+ gyro_noise = gyro_ref + imu_noise [:, 3 :] + bg
251+
252+ head_std = np .radians (1.0 )
253+ head_noise = euler_ref [:, - 1 ] + np .random .normal (0. , head_std , len (euler_ref ))
254+
255+ if degrees :
256+ gyro_noise = np .degrees (gyro_noise )
257+ head_noise = np .degrees (head_noise )
258+ head_std = np .degrees (head_std )
259+
260+ # MEKF
261+ q0 = sf .quaternion_from_euler (euler_ref [0 ], degrees = False )
262+ mekf = AHRS (
263+ fs_imu ,
264+ q = q0 ,
265+ gyro_noise_density = sf .constants .ERR_GYRO_MOTION2 ["N" ],
266+ gyro_bias_stability = sf .constants .ERR_GYRO_MOTION2 ["B" ],
267+ gyro_bias_corr_time = sf .constants .ERR_GYRO_MOTION2 ["tau_cb" ],
268+ )
269+
270+ # Apply filter
271+ euler_out , bias_gyro_out = [], []
272+ for i , (f_i , w_i , head_i ) in enumerate (
273+ zip (acc_noise , gyro_noise , head_noise )
274+ ):
275+
276+ dvel = f_i / fs_imu
277+ dtheta = w_i / fs_imu
278+
279+ mekf .update (
280+ dvel ,
281+ dtheta ,
282+ degrees = degrees ,
283+ head = head_i ,
284+ head_degrees = degrees ,
285+ head_var = head_std ** 2 ,
286+ )
287+
288+ euler_out .append (mekf .euler (degrees = False ))
289+ bias_gyro_out .append (mekf .bias_gyro (degrees = False ))
290+
291+ euler_out = np .array (euler_out )
292+ bias_gyro_out = np .array (bias_gyro_out )
293+
294+ # Half-sample shift (compensates for the delay introduced by Euler integration)
295+ euler_out = resample_poly (euler_out , 2 , 1 )[1 :- 1 :2 ]
296+ euler_ref = euler_ref [:- 1 , :]
297+
298+ roll_rms , pitch_rms , yaw_rms = np .std ((euler_out - euler_ref )[warmup :], axis = 0 )
299+ bias_gyro_x_rms , bias_gyro_y_rms , bias_gyro_z_rms = np .std (
300+ (bias_gyro_out - bg )[warmup :], axis = 0
301+ )
302+
303+ assert np .degrees (roll_rms ) <= 0.1
304+ assert np .degrees (pitch_rms ) <= 0.1
305+ assert np .degrees (yaw_rms ) <= 0.1
306+ assert np .degrees (bias_gyro_x_rms ) <= 0.005
307+ assert np .degrees (bias_gyro_y_rms ) <= 0.005
308+ assert np .degrees (bias_gyro_z_rms ) <= 0.005
0 commit comments