Skip to content

Commit 90e5eef

Browse files
[ImuFactor] Unit test the on manifold ImuFactor
Adds three tests meant to jointly test the `ImuManifoldPreintegrationUpdate` function. The calculated covariance is tested by sampling the values it is a covariance of and comparing it to the numircally calculated covariance from the samples. Warning, there is a tradeoff between time (number of samples) and accuracy for the calculated covariance. To speed things up, the sampling process is multi-threaded. Note, with my current parameters, it takes about 2^23 samples to be pretty robust to changes in the random seed. But that takes a minute to run on my desktop with 16 cores. So in the interest of testing time, I've cut the sample size by a bit. Of course, this means if you change the seed, the test might end up failing. To know if it's really a problem, bump up the sample size and see if it persists. The derivatives wrt to bias are calculated by perturbing the input in each direction to numerically calculate the columns of the derivative. The actual change in state (DR, Dv, Dp) isn't tested in a very intelligent manner. They didn't have any simple and obvious properties that could be easily tested. Still, their implementations are pretty simple, so I figured comparing to a C++ implementation would at the very least protect against someone accidentally deleting a line in the symbolic code or whatever. Topic: on_manifold_imu_factor_test Relative: on_manifold_imu_factor Labels: draft
1 parent b75e14b commit 90e5eef

2 files changed

Lines changed: 313 additions & 0 deletions

File tree

test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ foreach(SOURCE_FILE ${SYMFORCE_TEST_CC_SOURCES})
5858
symforce_opt
5959
symforce_examples
6060
symforce_test
61+
symforce_slam
6162
)
6263

6364
list(APPEND SYMFORCE_CC_TEST_TARGETS ${TARGET_NAME})
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
/* ----------------------------------------------------------------------------
2+
* SymForce - Copyright 2022, Skydio, Inc.
3+
* This source code is under the Apache 2.0 license found in the LICENSE file.
4+
* ---------------------------------------------------------------------------- */
5+
6+
#include <numeric>
7+
#include <random>
8+
#include <thread>
9+
10+
#include <Eigen/Dense>
11+
#include <catch2/catch_test_macros.hpp>
12+
13+
#include <sym/factors/internal/imu_manifold_preintegration_update.h>
14+
#include <sym/rot3.h>
15+
#include <sym/util/epsilon.h>
16+
#include <symforce/slam/imu_preintegration/imu_preintegrator.h>
17+
18+
using Eigen::Ref;
19+
using Eigen::Vector3d;
20+
using sym::Rot3d;
21+
22+
/**
23+
* Calculates what ImuManifoldPreintegrationUpdate should for the changes in orientation,
24+
* velocity, and position.
25+
*/
26+
void UpdateState(const Rot3d& DR, const Ref<const Vector3d>& Dv, const Ref<const Vector3d>& Dp,
27+
const Ref<const Vector3d>& gyro, const Ref<const Vector3d>& accel,
28+
const Ref<const Vector3d>& gyro_bias, const Ref<const Vector3d>& accel_bias,
29+
const double dt, const double epsilon, Rot3d& new_DR, Ref<Vector3d> new_Dv,
30+
Ref<Vector3d> new_Dp) {
31+
const auto corrected_accel = accel - accel_bias;
32+
new_DR = DR * Rot3d::FromTangent((gyro - gyro_bias) * dt, epsilon);
33+
new_Dv = Dv + DR * corrected_accel * dt;
34+
new_Dp = Dp + Dv * dt + DR * corrected_accel * (0.5 * dt * dt);
35+
}
36+
37+
TEST_CASE("Test ImuManifoldPreintegrationUpdate has correct DR, Dv, & Dp", "[slam]") {
38+
std::mt19937 gen(1804);
39+
std::normal_distribution<double> dist{0.0, 10.0};
40+
const auto randomM3 = Eigen::Matrix3d::NullaryExpr([&]() { return dist(gen); });
41+
const auto randomV3 = Eigen::Vector3d::NullaryExpr([&]() { return dist(gen); });
42+
const auto randomM9 = Eigen::Matrix<double, 9, 9>::NullaryExpr([&]() { return dist(gen); });
43+
44+
for (int i_ = 0; i_ < 10; i_++) {
45+
const sym::Rot3d DR = sym::Rot3d::Random(gen);
46+
const Eigen::Vector3d Dv = randomV3;
47+
const Eigen::Vector3d Dp = randomV3;
48+
const Eigen::Matrix<double, 9, 9> covariance = randomM9;
49+
const Eigen::Matrix3d DR_D_gyro_bias = randomM3;
50+
const Eigen::Matrix3d Dv_D_gyro_bias = randomM3;
51+
const Eigen::Matrix3d Dv_D_accel_bias = randomM3;
52+
const Eigen::Matrix3d Dp_D_gyro_bias = randomM3;
53+
const Eigen::Matrix3d Dp_D_accel_bias = randomM3;
54+
55+
const Eigen::Vector3d gyro = randomV3;
56+
const Eigen::Vector3d accel = randomV3;
57+
58+
const double dt = 1.24;
59+
60+
const Eigen::Vector3d gyro_bias = randomV3;
61+
const Eigen::Vector3d accel_bias = randomV3;
62+
const Eigen::Vector3d gyro_cov = randomV3;
63+
const Eigen::Vector3d accel_cov = randomV3;
64+
65+
sym::Rot3d new_DR;
66+
Eigen::Vector3d new_Dv;
67+
Eigen::Vector3d new_Dp;
68+
Eigen::Matrix<double, 9, 9> new_covarinace;
69+
Eigen::Matrix3d new_DR_D_gyro_bias;
70+
Eigen::Matrix3d new_Dp_D_gyro_bias;
71+
Eigen::Matrix3d new_Dp_D_accel_bias;
72+
Eigen::Matrix3d new_Dv_D_gyro_bias;
73+
Eigen::Matrix3d new_Dv_D_accel_bias;
74+
75+
sym::ImuManifoldPreintegrationUpdate<double>(
76+
DR, Dv, Dp, covariance, DR_D_gyro_bias, Dv_D_accel_bias, Dv_D_gyro_bias, Dp_D_accel_bias,
77+
Dp_D_gyro_bias, accel_bias, gyro_bias, accel_cov, gyro_cov, accel, gyro, dt,
78+
sym::kDefaultEpsilond, &new_DR, &new_Dv, &new_Dp, nullptr, nullptr, nullptr, nullptr,
79+
nullptr, nullptr);
80+
81+
sym::Rot3d expected_DR;
82+
Eigen::Vector3d expected_Dv;
83+
Eigen::Vector3d expected_Dp;
84+
85+
UpdateState(DR, Dv, Dp, gyro, accel, gyro_bias, accel_bias, dt, sym::kDefaultEpsilond,
86+
expected_DR, expected_Dv, expected_Dp);
87+
88+
CHECK(sym::IsClose(new_DR, expected_DR, 1e-14));
89+
CHECK(sym::IsClose(new_Dv, expected_Dv, 1e-14));
90+
CHECK(sym::IsClose(new_Dp, expected_Dp, 1e-14));
91+
}
92+
}
93+
94+
/**
95+
* Helper class to generate samples from a multi-variate normal distribution with
96+
* the same covariance as that passed into the constructor.
97+
*/
98+
class MultiVarNormalDist {
99+
private:
100+
const Eigen::MatrixXd L;
101+
std::normal_distribution<double> dist;
102+
103+
public:
104+
/**
105+
* covar is the covariance of the desired normal distribution.
106+
* Precondition: covar is a symmetric, positive definite matrix.
107+
*/
108+
MultiVarNormalDist(const Eigen::MatrixXd covar) : L{covar.llt().matrixL()}, dist{} {}
109+
110+
/**
111+
* Returns a matrix whose (count) colums are samples from the distribution.
112+
*/
113+
template <typename Generator>
114+
Eigen::MatrixXd Sample(Generator& gen, const int count) {
115+
Eigen::MatrixXd rand =
116+
Eigen::MatrixXd::NullaryExpr(L.cols(), count, [&]() { return dist(gen); });
117+
return L * rand;
118+
}
119+
};
120+
121+
/**
122+
* Calculates the covariance of the columns of samples.
123+
*/
124+
Eigen::MatrixXd Covariance(const Eigen::MatrixXd& samples) {
125+
const long int sample_size = samples.cols();
126+
const Eigen::VectorXd avg_col = samples.rowwise().sum() / sample_size;
127+
const Eigen::MatrixXd meanless_samples = samples.colwise() - avg_col;
128+
return (meanless_samples * meanless_samples.transpose()) / (sample_size - 1);
129+
}
130+
131+
TEST_CASE("Test MultiVarNormalDist generates samples with correct covariance", "[slam]") {
132+
std::mt19937 gen(170);
133+
134+
// Create covar, a symmetric positive definite matrix
135+
// NOTE(brad): This matrix is positive semi-definite because the diagonal entries all
136+
// have values of at least 2.0, and the max value of an off-diagonal entry is .2. Thus
137+
// within a row, the sum of the non-diagonal entries is capped by 0.2 * 8 = 1.6. Thus by the
138+
// Gershgorin circle theorem, any eigen value of covar is at least 0.4 (i.e., positive).
139+
std::uniform_real_distribution<double> uniform_dist(0.01, 0.1);
140+
Eigen::MatrixXd covar = Eigen::MatrixXd::NullaryExpr(9, 9, [&]() { return uniform_dist(gen); });
141+
covar += covar.transpose().eval();
142+
covar.diagonal().array() += 2.0;
143+
144+
MultiVarNormalDist dist(covar);
145+
146+
const Eigen::MatrixXd samples = dist.Sample(gen, 1 << 23);
147+
const Eigen::Matrix<double, 9, 9> calculated_covar = Covariance(samples);
148+
149+
for (int col = 0; col < 9; col++) {
150+
for (int row = 0; row < 9; row++) {
151+
const double covar_rc = covar(row, col);
152+
const double difference = covar_rc - calculated_covar(row, col);
153+
// NOTE(brad): trade-off between sample count (time) and accuracy of calculated_covar
154+
CHECK(difference * difference < 9e-3 * covar_rc * covar_rc);
155+
}
156+
}
157+
}
158+
159+
namespace example {
160+
static const Eigen::Vector3d accel_bias = {3.4, 1.6, -5.9};
161+
static const Eigen::Vector3d gyro_bias = {1.2, -2.4, 0.5};
162+
static const Eigen::Vector3d accel_cov(7e-5, 7e-5, 7e-5);
163+
static const Eigen::Vector3d gyro_cov(1e-3, 1e-3, 1e-3);
164+
static const Eigen::Vector3d true_gyro = Eigen::Vector3d::Constant(10.2);
165+
static const Eigen::Vector3d true_accel = Eigen::Vector3d::Constant(4.3);
166+
} // namespace example
167+
168+
TEST_CASE("Test ImuPreintegator.covariance", "[slam]") {
169+
// In order to test that the computed covariance is correct, we sample the values many
170+
// time to numerically calculate the covariance to compare.
171+
using M99 = Eigen::Matrix<double, 9, 9>;
172+
// Parameters
173+
const double dt = 1e-4;
174+
const int measurements_per_sample = 130;
175+
const int sample_count = 1 << 19;
176+
177+
// Multithreading
178+
const int thread_count = 16;
179+
const int steps_per_thread = sample_count / thread_count;
180+
// NOTE(brad): I assume I can equally distribute samples among the threads
181+
CHECK(sample_count % thread_count == 0);
182+
std::vector<std::thread> threads;
183+
std::array<Eigen::Matrix<double, 9, 9>, thread_count> covariance_sums;
184+
covariance_sums.fill(M99::Zero());
185+
186+
Eigen::MatrixXd samples(9, sample_count);
187+
188+
sym::ImuPreintegrator<double> noiseless_integrator(example::accel_bias, example::gyro_bias);
189+
for (int k = 0; k < measurements_per_sample; k++) {
190+
noiseless_integrator.IntegrateMeasurement(
191+
example::accel_bias + example::true_accel, example::gyro_bias + example::true_gyro,
192+
example::accel_cov, example::gyro_cov, dt, sym::kDefaultEpsilond);
193+
}
194+
195+
for (int i = 0; i < thread_count; i++) {
196+
threads.emplace_back([&samples, &covariance_sums, &noiseless_integrator, dt, i]() {
197+
std::mt19937 gen(1216 + i);
198+
// Each thread needs its own distribution as they are not thread safe
199+
MultiVarNormalDist accel_dist(example::accel_cov.asDiagonal() * (1 / dt));
200+
MultiVarNormalDist gyro_dist(example::gyro_cov.asDiagonal() * (1 / dt));
201+
202+
Eigen::MatrixXd accel_noise;
203+
Eigen::MatrixXd gyro_noise;
204+
205+
for (int j = steps_per_thread * i; j < steps_per_thread * (i + 1); j++) {
206+
sym::ImuPreintegrator<double> integrator(example::accel_bias, example::gyro_bias);
207+
208+
gyro_noise = gyro_dist.Sample(gen, measurements_per_sample);
209+
accel_noise = accel_dist.Sample(gen, measurements_per_sample);
210+
211+
for (int k = 0; k < measurements_per_sample; k++) {
212+
integrator.IntegrateMeasurement(
213+
example::accel_bias + example::true_accel + accel_noise.col(k),
214+
example::gyro_bias + example::true_gyro + gyro_noise.col(k), example::accel_cov,
215+
example::gyro_cov, dt, sym::kDefaultEpsilond);
216+
}
217+
218+
samples.col(j).segment(0, 3) =
219+
noiseless_integrator.PreintegratedMeasurements().DR.LocalCoordinates(
220+
integrator.PreintegratedMeasurements().DR);
221+
samples.col(j).segment(3, 3) = integrator.PreintegratedMeasurements().Dv;
222+
samples.col(j).segment(6, 3) = integrator.PreintegratedMeasurements().Dp;
223+
224+
covariance_sums[i] += integrator.Covariance();
225+
}
226+
});
227+
}
228+
for (std::thread& t : threads) {
229+
t.join();
230+
}
231+
232+
const M99 calculated_covariance =
233+
(1.0 / sample_count) *
234+
std::accumulate(covariance_sums.begin(), covariance_sums.end(), M99::Zero().eval());
235+
236+
const Eigen::MatrixXd sampled_covariance = Covariance(samples);
237+
const double sampled_covariance_max = sampled_covariance.array().abs().maxCoeff();
238+
239+
const Eigen::MatrixXd weighted_relative_error = sampled_covariance.binaryExpr(
240+
calculated_covariance, [=](const double x, const double y) -> double {
241+
// NOTE(brad): 3e-2 is partially arbitrary. Seemed like a reasonable value
242+
return std::abs((x - y) / (std::abs(x) + 3e-2 * sampled_covariance_max));
243+
});
244+
245+
// NOTE(brad): 0.05 is also somewhat arbitrary. Seemed reasonable.
246+
CHECK(weighted_relative_error.maxCoeff() < 0.05);
247+
}
248+
249+
TEST_CASE("Test preintegrated derivatives wrt IMU biases", "[slam]") {
250+
using M33 = Eigen::Matrix<double, 3, 3>;
251+
using M96 = Eigen::Matrix<double, 9, 6>;
252+
253+
const double dt = 1e-3;
254+
const int iterations = 100;
255+
256+
sym::ImuPreintegrator<double> integrator(example::accel_bias, example::gyro_bias);
257+
258+
for (int k_ = 0; k_ < iterations; k_++) {
259+
integrator.IntegrateMeasurement(example::accel_bias + example::true_accel,
260+
example::gyro_bias + example::true_gyro, example::accel_cov,
261+
example::gyro_cov, dt, sym::kDefaultEpsilond);
262+
}
263+
264+
Eigen::Matrix<double, 9, 6> state_D_bias =
265+
(Eigen::Matrix<double, 9, 6>() << integrator.PreintegratedMeasurements().DR_D_gyro_bias,
266+
M33::Zero(), integrator.PreintegratedMeasurements().Dv_D_gyro_bias,
267+
integrator.PreintegratedMeasurements().Dv_D_accel_bias,
268+
integrator.PreintegratedMeasurements().Dp_D_gyro_bias,
269+
integrator.PreintegratedMeasurements().Dp_D_accel_bias)
270+
.finished();
271+
272+
// Perturb inputs and calculate derivatives numerically from them
273+
const double perturbation = 1e-5;
274+
M96 numerical_state_D_bias;
275+
for (int i = 0; i < 6; i++) {
276+
// Create biases and perturb one of their coefficients
277+
Eigen::Vector3d perturbed_gyro_bias = example::gyro_bias;
278+
Eigen::Vector3d perturbed_accel_bias = example::accel_bias;
279+
if (i < 3) {
280+
perturbed_gyro_bias[i] += perturbation;
281+
} else {
282+
perturbed_accel_bias[i - 3] += perturbation;
283+
}
284+
285+
sym::ImuPreintegrator<double> perturbed_integrator(perturbed_accel_bias, perturbed_gyro_bias);
286+
for (int k_ = 0; k_ < iterations; k_++) {
287+
perturbed_integrator.IntegrateMeasurement(
288+
example::accel_bias + example::true_accel, example::gyro_bias + example::true_gyro,
289+
example::accel_cov, example::gyro_cov, dt, sym::kDefaultEpsilond);
290+
}
291+
292+
numerical_state_D_bias.col(i).segment(0, 3) =
293+
integrator.PreintegratedMeasurements().DR.LocalCoordinates(
294+
perturbed_integrator.PreintegratedMeasurements().DR) /
295+
perturbation;
296+
numerical_state_D_bias.col(i).segment(3, 3) =
297+
(perturbed_integrator.PreintegratedMeasurements().Dv -
298+
integrator.PreintegratedMeasurements().Dv) /
299+
perturbation;
300+
numerical_state_D_bias.col(i).segment(6, 3) =
301+
(perturbed_integrator.PreintegratedMeasurements().Dp -
302+
integrator.PreintegratedMeasurements().Dp) /
303+
perturbation;
304+
}
305+
306+
const Eigen::MatrixXd relative_error =
307+
numerical_state_D_bias.binaryExpr(state_D_bias, [](const double x, const double y) -> double {
308+
return std::abs((x - y) / (std::abs(x) + 1e-10));
309+
});
310+
311+
CHECK(relative_error.maxCoeff() < 0.05);
312+
}

0 commit comments

Comments
 (0)