Skip to content

Commit a4c9e48

Browse files
committed
Reintroduced tx_time changes to the EKF code
1 parent 64e8bc7 commit a4c9e48

3 files changed

Lines changed: 238 additions & 25 deletions

File tree

gnss_lib_py/algorithms/gnss_filters.py

Lines changed: 104 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from gnss_lib_py.parsers.navdata import NavData
1313
from gnss_lib_py.algorithms.snapshot import solve_wls
14+
from gnss_lib_py.utils import constants as consts
15+
from gnss_lib_py.utils.sim_gnss import _find_delxyz_range
1416
from gnss_lib_py.utils.coordinates import ecef_to_geodetic
1517
from gnss_lib_py.utils.filters import BaseExtendedKalmanFilter
1618

@@ -49,31 +51,80 @@ def solve_gnss_ekf(measurements, init_dict = None,
4951
init_dict = {}
5052

5153
if "state_0" not in init_dict:
52-
pos_0 = None
53-
for _, _, measurement_subset in measurements.loop_time("gps_millis",
54-
delta_t_decimals=delta_t_decimals):
55-
pos_0 = solve_wls(measurement_subset)
56-
if pos_0 is not None:
57-
break
58-
59-
state_0 = np.zeros((7,1))
60-
if pos_0 is not None:
61-
state_0[:3,0] = pos_0[["x_rx_wls_m","y_rx_wls_m","z_rx_wls_m"]]
62-
state_0[6,0] = pos_0[["b_rx_wls_m"]]
63-
54+
try:
55+
# if the given measurement frame has a state estimate, use
56+
# that, including the clock bias estimate
57+
pos_est_rows = measurements.find_wildcard_indexes(["x_rx*_m",
58+
"y_rx*_m",
59+
"z_rx*_m",
60+
"b_rx*_m"],
61+
max_allow=1)
62+
not_nan_idxs = measurements.argwhere(pos_est_rows['x_rx*_m'],
63+
np.nan, 'neq')
64+
state_0 = np.zeros((7,1))
65+
state_0[0,0] = measurements[pos_est_rows['x_rx*_m'], not_nan_idxs[0]]
66+
state_0[1,0] = measurements[pos_est_rows['y_rx*_m'], not_nan_idxs[0]]
67+
state_0[2,0] = measurements[pos_est_rows['z_rx*_m'], not_nan_idxs[0]]
68+
state_0[6,0] = measurements[pos_est_rows['b_rx*_m'], not_nan_idxs[0]]
69+
except KeyError:
70+
try:
71+
# a key error happened and one of the rows from the last
72+
# try clause is not present. Try again without bias,
73+
# which often missing from datasets
74+
pos_est_rows = measurements.find_wildcard_indexes(["x_rx*_m",
75+
"y_rx*_m",
76+
"z_rx*_m"],
77+
max_allow=1)
78+
not_nan_idxs = measurements.argwhere(pos_est_rows['x_rx*_m'],
79+
np.nan, 'neq')
80+
state_0 = np.zeros((7,1))
81+
state_0[0,0] = measurements[pos_est_rows['x_rx*_m'], not_nan_idxs[0]]
82+
state_0[1,0] = measurements[pos_est_rows['y_rx*_m'], not_nan_idxs[0]]
83+
state_0[2,0] = measurements[pos_est_rows['z_rx*_m'], not_nan_idxs[0]]
84+
pos_0 = NavData()
85+
pos_0['gps_millis'] = measurements['gps_millis', not_nan_idxs[0]]
86+
pos_0['x_rx_m'] = state_0[0,0]
87+
pos_0['y_rx_m'] = state_0[1,0]
88+
pos_0['z_rx_m'] = state_0[2,0]
89+
measurement_subset = measurements.copy(cols=not_nan_idxs[0])
90+
pos_0 = solve_wls(measurement_subset,
91+
receiver_state=pos_0,
92+
only_bias=True)
93+
# if len(pos_0.where('b_rx_wls_m', np.nan, 'eq'))==0:
94+
# break
95+
state_0[6,0] = pos_0['b_rx_wls_m']
96+
except KeyError:
97+
# position rows were not found again, use a WLS estimate
98+
pos_0 = None
99+
for _, _, measurement_subset in measurements.loop_time("gps_millis",
100+
delta_t_decimals=delta_t_decimals):
101+
pos_0 = solve_wls(measurement_subset)
102+
# Assume that if 'x_rx_wls_m' is np.nan, then a state estimate
103+
# has not been found and all x, y, and z are np.nan.
104+
# If the length of elements where 'x_rx_wls_m' is np.nan is
105+
# 0, then a solution has been found and can be used as an
106+
# initialization
107+
if len(pos_0.where('x_rx_wls_m', np.nan, 'eq'))==0:
108+
break
109+
110+
state_0 = np.zeros((7,1))
111+
if pos_0 is not None:
112+
state_0[:3,0] = pos_0[["x_rx_wls_m","y_rx_wls_m","z_rx_wls_m"]]
113+
state_0[6,0] = pos_0[["b_rx_wls_m"]]
64114
init_dict["state_0"] = state_0
65115

66116
if "sigma_0" not in init_dict:
67117
sigma_0 = np.eye(init_dict["state_0"].size)
68118
init_dict["sigma_0"] = sigma_0
69119

70120
if "Q" not in init_dict:
71-
process_noise = np.eye(init_dict["state_0"].size)
72-
init_dict["Q"] = process_noise
121+
raise RuntimeError("Process noise must be specified in init_dict")
73122

74123
if "R" not in init_dict:
75-
measurement_noise = np.eye(1) # gets overwritten
76-
init_dict["R"] = measurement_noise
124+
raise RuntimeError("Measurement noise must be specified in init_dict")
125+
126+
if "use_tx_time" not in init_dict:
127+
init_dict["use_tx_time"] = False
77128

78129
# initialize parameter dictionary
79130
if params_dict is None:
@@ -161,6 +212,7 @@ def __init__(self, init_dict, params_dict):
161212
self.delta_t = params_dict.get('dt',1.0)
162213
self.motion_type = params_dict.get('motion_type','stationary')
163214
self.measure_type = params_dict.get('measure_type','pseudorange')
215+
self.use_tx_time = init_dict.get('use_tx_time', False)
164216

165217
def dyn_model(self, u, predict_dict=None):
166218
"""Nonlinear dynamics
@@ -196,11 +248,34 @@ def measure_model(self, update_dict):
196248
of shape [3 x N] with rows of x_sv_m, y_sv_m, and z_sv_m in that
197249
order.
198250
251+
This measurment model uses the current state estimate to find
252+
the time taken for signals to propagate from the satellites to
253+
the receiver and updates the SV positions to reflect the changed
254+
ECEF reference frame.
255+
256+
Since the ECEF reference frame moves with the Earth, the frame
257+
of reference is different at different times.
258+
SV positions are calculated for the time at which the signal was
259+
transmitted but the receiver position is computed for the ECEF
260+
frame of reference when the signals are received. Consequently,
261+
the SV positions must be updated to account for the change in the
262+
ECEF frame between signal transmission and reception.
263+
However, given the EKF has an initial position guess around the
264+
true position (either through prior knowledge or a prior state
265+
estimation process such as WLS), we can simply correct the SV
266+
positions once and use them as such, without further modification.
267+
199268
Parameters
200269
----------
201270
update_dict : dict
202271
Update dictionary containing satellite positions with key
203-
``pos_sv_m``.
272+
``pos_sv_m`` and optionally ``tx_time``. ``tx_time`` specifies
273+
if the filter should use the SV positions at time of
274+
transmission (if True). If False, the the time it takes for
275+
the signal to propagate from the satellite to the receiver
276+
is accounted for and the SV positions are propagated forward
277+
to the ECEF coordinate frame at the receiver time. By default,
278+
``tx_time`` is True.
204279
205280
Returns
206281
-------
@@ -216,6 +291,18 @@ def measure_model(self, update_dict):
216291
"""
217292
if self.measure_type=='pseudorange':
218293
pos_sv_m = update_dict['pos_sv_m']
294+
if not self.use_tx_time:
295+
rx_pos_m = np.array([[self.state[0]], [self.state[1]], [self.state[2]]])
296+
num_svs = np.shape(pos_sv_m)[1]
297+
_, true_range = _find_delxyz_range(pos_sv_m.T, rx_pos_m, num_svs)
298+
tx_time = (true_range - self.state[6])/consts.C
299+
dtheta = consts.OMEGA_E_DOT*tx_time
300+
# The following two lines are expanded position updates for a
301+
# rotation by dtheta radians about the z-axis, which updates
302+
# the positions along the x and y axes but the position along
303+
# the z-axis is unchanged.
304+
pos_sv_m[0, :] = np.cos(dtheta)*pos_sv_m[0,:] + np.sin(dtheta)*pos_sv_m[1,:]
305+
pos_sv_m[1, :] = -np.sin(dtheta)*pos_sv_m[0,:] + np.cos(dtheta)*pos_sv_m[1,:]
219306
pseudo = np.sqrt((self.state[0] - pos_sv_m[0, :])**2
220307
+ (self.state[1] - pos_sv_m[1, :])**2
221308
+ (self.state[2] - pos_sv_m[2, :])**2) \

tests/algorithms/test_gnss_filters.py

Lines changed: 127 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from numpy.random import default_rng
1313

1414
from gnss_lib_py.parsers.navdata import NavData
15-
from gnss_lib_py.parsers.android import AndroidDerived2021
15+
from gnss_lib_py.parsers.android import AndroidDerived2021, AndroidDerived2022
1616
from gnss_lib_py.algorithms.gnss_filters import GNSSEKF, solve_gnss_ekf
1717

1818
@pytest.fixture(name='init_dict')
@@ -35,7 +35,8 @@ def gnss_init_params():
3535
'state_0': state_0,
3636
'sigma_0': 5*np.eye(state_dim),
3737
'Q': Q,
38-
'R': R}
38+
'R': R,
39+
'use_tx_time': True}
3940
return init_dict
4041

4142

@@ -159,17 +160,83 @@ def fixture_load_derived(derived_path):
159160
derived = AndroidDerived2021(derived_path)
160161
return derived
161162

162-
def test_solve_gnss_ekf(derived):
163+
164+
@pytest.fixture(name="derived_2022_path")
165+
def fixture_derived_2022_path(root_path):
166+
"""Filepath of Android Derived 2022 measurements
167+
168+
Returns
169+
-------
170+
derived_2022_path : string
171+
Location for the unit_test Android 2022 derived measurements
172+
173+
Notes
174+
-----
175+
Test data is a subset of the Android Raw Measurement Dataset [3]_,
176+
from the 2022 Decimeter Challenge. Particularly, the
177+
train/2021-04-29-MTV-2/SamsungGalaxyS20Ultra trace. The dataset
178+
was retrieved from
179+
https://www.kaggle.com/competitions/smartphone-decimeter-2022/data
180+
181+
References
182+
----------
183+
.. [3] Fu, Guoyu Michael, Mohammed Khider, and Frank van Diggelen.
184+
"Android Raw GNSS Measurement Datasets for Precise Positioning."
185+
Proceedings of the 33rd International Technical Meeting of the
186+
Satellite Division of The Institute of Navigation (ION GNSS+
187+
2020). 2020.
188+
"""
189+
derived_2022_path = os.path.join(root_path, '../android_2022/device_gnss.csv')
190+
return derived_2022_path
191+
192+
193+
@pytest.fixture(name="derived_2022")
194+
def fixture_load_derived_2022(derived_2022_path):
195+
"""Load instance of AndroidDerived2021
196+
197+
Parameters
198+
----------
199+
derived_2022_path : pytest.fixture
200+
String with location of Android derived 2022 measurement file
201+
202+
Returns
203+
-------
204+
derived_2022 : AndroidDerived2021
205+
Instance of AndroidDerived2022 for testing
206+
"""
207+
derived_2022 = AndroidDerived2022(derived_2022_path)
208+
return derived_2022
209+
210+
211+
@pytest.fixture(name="noise_tx_init_dict")
212+
def fixture_android_init_dict():
213+
"""Define dictionary containing identity process and measure noises.
214+
215+
Returns
216+
-------
217+
init_dict : dict
218+
Dictionary of initialization parameters, in this case, containing
219+
just the process and measurement noise covariance matrices.
220+
"""
221+
init_dict = {}
222+
init_dict['Q'] = np.eye(7)
223+
init_dict['R'] = np.eye(1)
224+
init_dict['use_tx_time'] = False
225+
return init_dict
226+
227+
def test_solve_gnss_ekf(derived, noise_tx_init_dict):
163228
"""Test that solving for GNSS EKF doesn't fail
164229
165230
Parameters
166231
----------
167232
derived : AndroidDerived2021
168233
Instance of AndroidDerived2021 for testing.
234+
init_dict : dict
235+
Dictionary of initialization parameters, in this case, containing
236+
just the process and measurement noise covariance matrices.
169237
170238
"""
171-
state_estimate = solve_gnss_ekf(derived)
172-
239+
state_estimate = solve_gnss_ekf(derived, noise_tx_init_dict)
173240
# result should be a NavData Class instance
174241
assert isinstance(state_estimate,type(NavData()))
175242

@@ -202,23 +269,76 @@ def test_solve_gnss_ekf(derived):
202269
assert row_index in str(excinfo.value)
203270

204271

205-
def test_solve_gnss_ekf_fails(derived):
272+
273+
def test_solve_gnss_ekf_fails(derived, noise_tx_init_dict):
206274
"""Test expected fails for the GNSS EKF.
207275
208276
Parameters
209277
----------
210278
derived : AndroidDerived2021
211279
Instance of AndroidDerived2021 for testing
280+
init_dict : dict
281+
Dictionary of initialization parameters, in this case, containing
282+
just the process and measurement noise covariance matrices.
212283
213284
"""
214285

215286
navdata = derived.remove(cols=list(range(len(derived))))
216287

217288
with pytest.warns(RuntimeWarning) as warns:
218-
solve_gnss_ekf(navdata)
289+
solve_gnss_ekf(navdata, noise_tx_init_dict)
219290

220291
# verify RuntimeWarning
221292
assert len(warns) == 1
222293
warn = warns[0]
223294
assert issubclass(warn.category, RuntimeWarning)
224295
assert "No valid state" in str(warn.message)
296+
297+
298+
# Test that RuntimeError is raised if no measurment noise is provided
299+
with pytest.raises(RuntimeError):
300+
del(noise_tx_init_dict['R'])
301+
solve_gnss_ekf(derived, noise_tx_init_dict)
302+
# Test that RuntimeError is raised if no process noise is provided
303+
with pytest.raises(RuntimeError):
304+
del(noise_tx_init_dict['Q'])
305+
solve_gnss_ekf(derived, noise_tx_init_dict)
306+
# Test that RuntimeError is raised if no initial dictionary is provided
307+
with pytest.raises(RuntimeError):
308+
solve_gnss_ekf(derived)
309+
310+
311+
def test_solve_gnss_ekf_initializations(derived_2022):
312+
"""Tests that different initial state cases run without error.
313+
314+
Parameters
315+
----------
316+
derived_2022 : AndroidDerived2022
317+
Instance of AndroidDerived2022 for testing
318+
init_dict : dict
319+
Dictionary of initialization parameters, in this case, containing
320+
just the process and measurement noise covariance matrices.
321+
"""
322+
# GNSS EKF solution when initial states and biases are given
323+
derived_2022['b_rx_m'] = 0
324+
# Reinitializing the initial dictionary because other functions might
325+
# have added to this.
326+
reset_init_dict = {}
327+
reset_init_dict['Q'] = np.eye(7)
328+
reset_init_dict['R'] = np.eye(1)
329+
reset_init_dict['use_tx_time'] = True
330+
_ = solve_gnss_ekf(derived_2022, reset_init_dict)
331+
# GNSS EKF solution when initial positions are given
332+
derived_2022.remove(rows=['b_rx_m'], inplace=True)
333+
reset_init_dict = {}
334+
reset_init_dict['Q'] = np.eye(7)
335+
reset_init_dict['R'] = np.eye(1)
336+
reset_init_dict['use_tx_time'] = True
337+
_ = solve_gnss_ekf(derived_2022, reset_init_dict)
338+
# GNSS EKF solution when no initial states are given
339+
derived_no_rx_rows = derived_2022.remove(rows=['x_rx_m', 'y_rx_m', 'z_rx_m'])
340+
reset_init_dict = {}
341+
reset_init_dict['Q'] = np.eye(7)
342+
reset_init_dict['R'] = np.eye(1)
343+
reset_init_dict['use_tx_time'] = True
344+
_ = solve_gnss_ekf(derived_no_rx_rows, reset_init_dict)

tests/parsers/test_android.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -750,7 +750,13 @@ def test_solve_kaggle_dataset(root_path):
750750
solve_gnss_ekf,
751751
]:
752752
for verbose in [True, False]:
753-
solution = android.solve_kaggle_dataset(folder_path, solver,
753+
if solver == solve_gnss_ekf:
754+
init_dict = {'Q': np.eye(7), 'R': np.eye(1)}
755+
solution = android.solve_kaggle_dataset(folder_path, solver,
756+
verbose, init_dict=init_dict)
757+
758+
else:
759+
solution = android.solve_kaggle_dataset(folder_path, solver,
754760
verbose)
755761

756762
solution.in_rows(["tripId","UnixTimeMillis",

0 commit comments

Comments
 (0)