Skip to content

Commit 3be09c4

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
1 parent deda3fc commit 3be09c4

2 files changed

Lines changed: 384 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: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
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 <limits>
7+
#include <numeric>
8+
#include <random>
9+
#include <thread>
10+
11+
#include <Eigen/Dense>
12+
#include <catch2/catch_test_macros.hpp>
13+
14+
#include <sym/factors/internal/imu_manifold_preintegration_update.h>
15+
#include <sym/factors/internal/imu_manifold_preintegration_update_auto_derivative.h>
16+
#include <sym/rot3.h>
17+
#include <sym/util/epsilon.h>
18+
#include <symforce/slam/imu_preintegration/imu_preintegrator.h>
19+
20+
using Eigen::Ref;
21+
using Eigen::Vector3d;
22+
using sym::Rot3d;
23+
24+
/**
25+
* Calculates what ImuManifoldPreintegrationUpdate should be for the changes in orientation,
26+
* velocity, and position.
27+
*/
28+
void UpdateState(const Rot3d& DR, const Ref<const Vector3d>& Dv, const Ref<const Vector3d>& Dp,
29+
const Ref<const Vector3d>& accel, const Ref<const Vector3d>& gyro,
30+
const Ref<const Vector3d>& accel_bias, const Ref<const Vector3d>& gyro_bias,
31+
const double dt, const double epsilon, Rot3d& new_DR, Ref<Vector3d> new_Dv,
32+
Ref<Vector3d> new_Dp) {
33+
const auto corrected_accel = accel - accel_bias;
34+
new_DR = DR * Rot3d::FromTangent((gyro - gyro_bias) * dt, epsilon);
35+
new_Dv = Dv + DR * corrected_accel * dt;
36+
new_Dp = Dp + Dv * dt + DR * corrected_accel * (0.5 * dt * dt);
37+
}
38+
39+
TEST_CASE("Test ImuManifoldPreintegrationUpdate has correct DR, Dv, & Dp", "[slam]") {
40+
std::mt19937 gen(1804);
41+
const double nan = std::numeric_limits<double>::quiet_NaN();
42+
const Eigen::Matrix<double, 9, 9> nanM99 = Eigen::Matrix<double, 9, 9>::Constant(nan);
43+
const Eigen::Matrix3d nanM33 = Eigen::Matrix3d::Constant(nan);
44+
45+
for (int i_ = 0; i_ < 10; i_++) {
46+
const sym::Rot3d DR = sym::Rot3d::Random(gen);
47+
const Eigen::Vector3d Dv = sym::Random<Eigen::Vector3d>(gen);
48+
const Eigen::Vector3d Dp = sym::Random<Eigen::Vector3d>(gen);
49+
50+
// Set to NaN because output should not depend on them
51+
const Eigen::Matrix<double, 9, 9> covariance = nanM99;
52+
const Eigen::Matrix3d DR_D_gyro_bias = nanM33;
53+
const Eigen::Matrix3d Dv_D_accel_bias = nanM33;
54+
const Eigen::Matrix3d Dv_D_gyro_bias = nanM33;
55+
const Eigen::Matrix3d Dp_D_accel_bias = nanM33;
56+
const Eigen::Matrix3d Dp_D_gyro_bias = nanM33;
57+
58+
const Eigen::Vector3d accel = sym::Random<Eigen::Vector3d>(gen);
59+
const Eigen::Vector3d gyro = sym::Random<Eigen::Vector3d>(gen);
60+
61+
const double dt = 1.24;
62+
63+
const Eigen::Vector3d accel_bias = sym::Random<Eigen::Vector3d>(gen);
64+
const Eigen::Vector3d gyro_bias = sym::Random<Eigen::Vector3d>(gen);
65+
const Eigen::Vector3d accel_cov = sym::Random<Eigen::Vector3d>(gen);
66+
const Eigen::Vector3d gyro_cov = sym::Random<Eigen::Vector3d>(gen);
67+
68+
sym::Rot3d new_DR;
69+
Eigen::Vector3d new_Dv;
70+
Eigen::Vector3d new_Dp;
71+
72+
sym::ImuManifoldPreintegrationUpdate<double>(
73+
DR, Dv, Dp, covariance, DR_D_gyro_bias, Dv_D_accel_bias, Dv_D_gyro_bias, Dp_D_accel_bias,
74+
Dp_D_gyro_bias, accel_bias, gyro_bias, accel_cov, gyro_cov, accel, gyro, dt,
75+
sym::kDefaultEpsilond, &new_DR, &new_Dv, &new_Dp, nullptr, nullptr, nullptr, nullptr,
76+
nullptr, nullptr);
77+
78+
sym::Rot3d expected_DR;
79+
Eigen::Vector3d expected_Dv;
80+
Eigen::Vector3d expected_Dp;
81+
82+
UpdateState(DR, Dv, Dp, accel, gyro, accel_bias, gyro_bias, dt, sym::kDefaultEpsilond,
83+
expected_DR, expected_Dv, expected_Dp);
84+
85+
CHECK(sym::IsClose(new_DR, expected_DR, 1e-14));
86+
CHECK(sym::IsClose(new_Dv, expected_Dv, 1e-14));
87+
CHECK(sym::IsClose(new_Dp, expected_Dp, 1e-14));
88+
}
89+
}
90+
91+
/**
92+
* Helper class to generate samples from a multi-variate normal distribution with
93+
* the same covariance as that passed into the constructor.
94+
*/
95+
class MultiVarNormalDist {
96+
private:
97+
const Eigen::MatrixXd L;
98+
std::normal_distribution<double> dist;
99+
100+
public:
101+
/**
102+
* covar is the covariance of the desired normal distribution.
103+
* Precondition: covar is a symmetric, positive definite matrix.
104+
*/
105+
MultiVarNormalDist(const Eigen::MatrixXd covar) : L{covar.llt().matrixL()}, dist{} {}
106+
107+
/**
108+
* Returns a matrix whose (count) colums are samples from the distribution.
109+
*/
110+
template <typename Generator>
111+
Eigen::MatrixXd Sample(Generator& gen, const int count) {
112+
Eigen::MatrixXd rand =
113+
Eigen::MatrixXd::NullaryExpr(L.cols(), count, [&]() { return dist(gen); });
114+
return L * rand;
115+
}
116+
};
117+
118+
/**
119+
* Calculates the covariance of the columns of samples.
120+
*/
121+
Eigen::MatrixXd Covariance(const Eigen::MatrixXd& samples) {
122+
const long int sample_size = samples.cols();
123+
const Eigen::VectorXd avg_col = samples.rowwise().sum() / sample_size;
124+
const Eigen::MatrixXd meanless_samples = samples.colwise() - avg_col;
125+
return (meanless_samples * meanless_samples.transpose()) / (sample_size - 1);
126+
}
127+
128+
TEST_CASE("Test MultiVarNormalDist generates samples with correct covariance", "[slam]") {
129+
std::mt19937 gen(170);
130+
131+
// Create covar, a symmetric positive definite matrix
132+
// NOTE(brad): This matrix is positive semi-definite because the diagonal entries all
133+
// have values of at least 2.0, and the max value of an off-diagonal entry is .2. Thus
134+
// within a row, the sum of the non-diagonal entries is capped by 0.2 * 8 = 1.6. Thus by the
135+
// Gershgorin circle theorem, any eigen value of covar is at least 0.4 (i.e., positive).
136+
std::uniform_real_distribution<double> uniform_dist(0.01, 0.1);
137+
Eigen::MatrixXd covar = Eigen::MatrixXd::NullaryExpr(9, 9, [&]() { return uniform_dist(gen); });
138+
covar += covar.transpose().eval();
139+
covar.diagonal().array() += 2.0;
140+
141+
MultiVarNormalDist dist(covar);
142+
143+
const Eigen::MatrixXd samples = dist.Sample(gen, 1 << 23);
144+
const Eigen::Matrix<double, 9, 9> calculated_covar = Covariance(samples);
145+
146+
for (int col = 0; col < 9; col++) {
147+
for (int row = 0; row < 9; row++) {
148+
const double covar_rc = covar(row, col);
149+
const double difference = covar_rc - calculated_covar(row, col);
150+
// NOTE(brad): trade-off between sample count (time) and accuracy of calculated_covar
151+
CHECK(difference * difference < 9e-3 * covar_rc * covar_rc);
152+
}
153+
}
154+
}
155+
156+
namespace example {
157+
static const Eigen::Vector3d kAccelBias = {3.4, 1.6, -5.9};
158+
static const Eigen::Vector3d kGyroBias = {1.2, -2.4, 0.5};
159+
static const Eigen::Vector3d kAccelCov(7e-5, 7e-5, 7e-5);
160+
static const Eigen::Vector3d kGyroCov(1e-3, 1e-3, 1e-3);
161+
static const Eigen::Vector3d kTrueAccel = Eigen::Vector3d::Constant(4.3);
162+
static const Eigen::Vector3d kTrueGyro = Eigen::Vector3d::Constant(10.2);
163+
} // namespace example
164+
165+
TEST_CASE("Test ImuPreintegator.covariance", "[slam]") {
166+
// In order to test that the computed covariance is correct, we sample the values many
167+
// times to numerically calculate the covariance to compare.
168+
using M99 = Eigen::Matrix<double, 9, 9>;
169+
// Parameters
170+
const double dt = 1e-3;
171+
const int kMeasurementsPerSample = 70;
172+
const int kSampleCount = 1 << 18;
173+
174+
// Multithreading
175+
const int kThreadCount = 1;
176+
const int kStepsPerThread = kSampleCount / kThreadCount;
177+
// NOTE(brad): I assume I can equally distribute samples among the threads
178+
CHECK(kSampleCount % kThreadCount == 0);
179+
std::vector<std::thread> threads;
180+
std::array<Eigen::Matrix<double, 9, 9>, kThreadCount> covariance_sums;
181+
covariance_sums.fill(M99::Zero());
182+
183+
Eigen::MatrixXd samples(9, kSampleCount);
184+
185+
sym::ImuPreintegrator<double> noiseless_integrator(example::kAccelBias, example::kGyroBias);
186+
for (int k = 0; k < kMeasurementsPerSample; k++) {
187+
noiseless_integrator.IntegrateMeasurement(
188+
example::kAccelBias + example::kTrueAccel, example::kGyroBias + example::kTrueGyro,
189+
example::kAccelCov, example::kGyroCov, dt, sym::kDefaultEpsilond);
190+
}
191+
192+
for (int i = 0; i < kThreadCount; i++) {
193+
threads.emplace_back([&samples, &covariance_sums, &noiseless_integrator, dt, i]() {
194+
std::mt19937 gen(45816 + i);
195+
// Each thread needs its own distribution as they are not thread safe
196+
MultiVarNormalDist accel_dist(example::kAccelCov.asDiagonal() * (1 / dt));
197+
MultiVarNormalDist gyro_dist(example::kGyroCov.asDiagonal() * (1 / dt));
198+
199+
Eigen::MatrixXd accel_noise;
200+
Eigen::MatrixXd gyro_noise;
201+
202+
for (int j = kStepsPerThread * i; j < kStepsPerThread * (i + 1); j++) {
203+
sym::ImuPreintegrator<double> integrator(example::kAccelBias, example::kGyroBias);
204+
205+
accel_noise = accel_dist.Sample(gen, kMeasurementsPerSample);
206+
gyro_noise = gyro_dist.Sample(gen, kMeasurementsPerSample);
207+
208+
for (int k = 0; k < kMeasurementsPerSample; k++) {
209+
integrator.IntegrateMeasurement(
210+
example::kAccelBias + example::kTrueAccel + accel_noise.col(k),
211+
example::kGyroBias + example::kTrueGyro + gyro_noise.col(k), example::kAccelCov,
212+
example::kGyroCov, dt, sym::kDefaultEpsilond);
213+
}
214+
215+
samples.col(j).segment(0, 3) =
216+
noiseless_integrator.PreintegratedMeasurements().DR.LocalCoordinates(
217+
integrator.PreintegratedMeasurements().DR);
218+
samples.col(j).segment(3, 3) = integrator.PreintegratedMeasurements().Dv;
219+
samples.col(j).segment(6, 3) = integrator.PreintegratedMeasurements().Dp;
220+
221+
covariance_sums[i] += integrator.Covariance();
222+
}
223+
});
224+
}
225+
for (std::thread& t : threads) {
226+
t.join();
227+
}
228+
229+
const M99 calculated_covariance =
230+
(1.0 / kSampleCount) *
231+
std::accumulate(covariance_sums.begin(), covariance_sums.end(), M99::Zero().eval());
232+
233+
const Eigen::MatrixXd sampled_covariance = Covariance(samples);
234+
const double sampled_covariance_max = sampled_covariance.array().abs().maxCoeff();
235+
236+
const Eigen::MatrixXd weighted_relative_error = sampled_covariance.binaryExpr(
237+
calculated_covariance, [=](const double x, const double y) -> double {
238+
// NOTE(brad): 6e-2 is partially arbitrary. Seemed like a reasonable value
239+
return std::abs((x - y) / (std::abs(x) + 6e-2 * sampled_covariance_max));
240+
});
241+
242+
// NOTE(brad): 0.05 is also somewhat arbitrary. Seemed reasonable.
243+
CHECK(weighted_relative_error.maxCoeff() < 0.05);
244+
}
245+
246+
TEST_CASE("Test preintegrated derivatives wrt IMU biases", "[slam]") {
247+
using M33 = Eigen::Matrix<double, 3, 3>;
248+
using M96 = Eigen::Matrix<double, 9, 6>;
249+
250+
const double dt = 1e-3;
251+
const int iterations = 100;
252+
253+
sym::ImuPreintegrator<double> integrator(example::kAccelBias, example::kGyroBias);
254+
255+
for (int k_ = 0; k_ < iterations; k_++) {
256+
integrator.IntegrateMeasurement(example::kAccelBias + example::kTrueAccel,
257+
example::kGyroBias + example::kTrueGyro, example::kAccelCov,
258+
example::kGyroCov, dt, sym::kDefaultEpsilond);
259+
}
260+
261+
Eigen::Matrix<double, 9, 6> state_D_bias =
262+
(Eigen::Matrix<double, 9, 6>() << integrator.PreintegratedMeasurements().DR_D_gyro_bias,
263+
M33::Zero(), integrator.PreintegratedMeasurements().Dv_D_gyro_bias,
264+
integrator.PreintegratedMeasurements().Dv_D_accel_bias,
265+
integrator.PreintegratedMeasurements().Dp_D_gyro_bias,
266+
integrator.PreintegratedMeasurements().Dp_D_accel_bias)
267+
.finished();
268+
269+
// Perturb inputs and calculate derivatives numerically from them
270+
const double perturbation = 1e-5;
271+
M96 numerical_state_D_bias;
272+
for (int i = 0; i < 6; i++) {
273+
// Create biases and perturb one of their coefficients
274+
Eigen::Vector3d perturbed_accel_bias = example::kAccelBias;
275+
Eigen::Vector3d perturbed_gyro_bias = example::kGyroBias;
276+
if (i < 3) {
277+
perturbed_gyro_bias[i] += perturbation;
278+
} else {
279+
perturbed_accel_bias[i - 3] += perturbation;
280+
}
281+
282+
sym::ImuPreintegrator<double> perturbed_integrator(perturbed_accel_bias, perturbed_gyro_bias);
283+
for (int k_ = 0; k_ < iterations; k_++) {
284+
perturbed_integrator.IntegrateMeasurement(
285+
example::kAccelBias + example::kTrueAccel, example::kGyroBias + example::kTrueGyro,
286+
example::kAccelCov, example::kGyroCov, dt, sym::kDefaultEpsilond);
287+
}
288+
289+
numerical_state_D_bias.col(i).segment(0, 3) =
290+
integrator.PreintegratedMeasurements().DR.LocalCoordinates(
291+
perturbed_integrator.PreintegratedMeasurements().DR) /
292+
perturbation;
293+
numerical_state_D_bias.col(i).segment(3, 3) =
294+
(perturbed_integrator.PreintegratedMeasurements().Dv -
295+
integrator.PreintegratedMeasurements().Dv) /
296+
perturbation;
297+
numerical_state_D_bias.col(i).segment(6, 3) =
298+
(perturbed_integrator.PreintegratedMeasurements().Dp -
299+
integrator.PreintegratedMeasurements().Dp) /
300+
perturbation;
301+
}
302+
303+
const Eigen::MatrixXd relative_error =
304+
numerical_state_D_bias.binaryExpr(state_D_bias, [](const double x, const double y) -> double {
305+
return std::abs((x - y) / (std::abs(x) + 1e-10));
306+
});
307+
308+
CHECK(relative_error.maxCoeff() < 0.05);
309+
}
310+
311+
TEST_CASE("Verify handwritten and auto-derivative impls are equivalent", "[slam]") {
312+
// NOTE(Brad): To explain, some derivatives are needed in order to calculate the expressions
313+
// for the new covariance and the new derivatives of the state w.r.t. the IMU biases in the
314+
// symbolic formulation of ImuManifoldPreintegrationUpdate. These derivatives can be
315+
// be automatically computed using tangent_jacobians, but the expressions can also be obtained
316+
// by hand. For whatever reason (likely having to do with CSE) the handwritten expressions use
317+
// fewer ops. To verify that the handwritten implementations are correct, we compare them to
318+
// the version of ImuManifoldPreintegrationUpdate using the automatic derivatives.
319+
using M99 = Eigen::Matrix<double, 9, 9>;
320+
std::mt19937 gen(1104);
321+
322+
for (int i_ = 0; i_ < 10; i_++) {
323+
// Generate sample inputs to preintegration update functions
324+
const sym::Rot3d DR = sym::Rot3d::Random(gen);
325+
const Eigen::Vector3d Dv = sym::Random<Eigen::Vector3d>(gen);
326+
const Eigen::Vector3d Dp = sym::Random<Eigen::Vector3d>(gen);
327+
328+
const M99 covariance =
329+
(sym::Random<M99>(gen) + 10 * M99::Identity()).selfadjointView<Eigen::Lower>();
330+
const Eigen::Matrix3d DR_D_gyro_bias = sym::Random<Eigen::Matrix3d>(gen);
331+
const Eigen::Matrix3d Dv_D_accel_bias = sym::Random<Eigen::Matrix3d>(gen);
332+
const Eigen::Matrix3d Dv_D_gyro_bias = sym::Random<Eigen::Matrix3d>(gen);
333+
const Eigen::Matrix3d Dp_D_accel_bias = sym::Random<Eigen::Matrix3d>(gen);
334+
const Eigen::Matrix3d Dp_D_gyro_bias = sym::Random<Eigen::Matrix3d>(gen);
335+
336+
const Eigen::Vector3d accel = sym::Random<Eigen::Vector3d>(gen);
337+
const Eigen::Vector3d gyro = sym::Random<Eigen::Vector3d>(gen);
338+
339+
const double dt = 1.24;
340+
341+
const Eigen::Vector3d accel_bias = sym::Random<Eigen::Vector3d>(gen);
342+
const Eigen::Vector3d gyro_bias = sym::Random<Eigen::Vector3d>(gen);
343+
const Eigen::Vector3d accel_cov = sym::Random<Eigen::Vector3d>(gen);
344+
const Eigen::Vector3d gyro_cov = sym::Random<Eigen::Vector3d>(gen);
345+
346+
// Calculate outputs of handwritten derivative version
347+
Eigen::Matrix<double, 9, 9> new_covariance;
348+
Eigen::Matrix3d new_DR_D_gyro_bias;
349+
Eigen::Matrix3d new_Dv_D_accel_bias;
350+
Eigen::Matrix3d new_Dv_D_gyro_bias;
351+
Eigen::Matrix3d new_Dp_D_accel_bias;
352+
Eigen::Matrix3d new_Dp_D_gyro_bias;
353+
354+
sym::ImuManifoldPreintegrationUpdate<double>(
355+
DR, Dv, Dp, covariance, DR_D_gyro_bias, Dv_D_accel_bias, Dv_D_gyro_bias, Dp_D_accel_bias,
356+
Dp_D_gyro_bias, accel_bias, gyro_bias, accel_cov, gyro_cov, accel, gyro, dt,
357+
sym::kDefaultEpsilond, nullptr, nullptr, nullptr, &new_covariance, &new_DR_D_gyro_bias,
358+
&new_Dv_D_accel_bias, &new_Dv_D_gyro_bias, &new_Dp_D_accel_bias, &new_Dp_D_gyro_bias);
359+
360+
// Calculate outputs of auto derivative version
361+
Eigen::Matrix<double, 9, 9> new_covariance_auto;
362+
Eigen::Matrix3d new_DR_D_gyro_bias_auto;
363+
Eigen::Matrix3d new_Dv_D_accel_bias_auto;
364+
Eigen::Matrix3d new_Dv_D_gyro_bias_auto;
365+
Eigen::Matrix3d new_Dp_D_accel_bias_auto;
366+
Eigen::Matrix3d new_Dp_D_gyro_bias_auto;
367+
368+
sym::ImuManifoldPreintegrationUpdateAutoDerivative<double>(
369+
DR, Dv, Dp, covariance, DR_D_gyro_bias, Dv_D_accel_bias, Dv_D_gyro_bias, Dp_D_accel_bias,
370+
Dp_D_gyro_bias, accel_bias, gyro_bias, accel_cov, gyro_cov, accel, gyro, dt,
371+
sym::kDefaultEpsilond, nullptr, nullptr, nullptr, &new_covariance_auto,
372+
&new_DR_D_gyro_bias_auto, &new_Dv_D_accel_bias_auto, &new_Dv_D_gyro_bias_auto,
373+
&new_Dp_D_accel_bias_auto, &new_Dp_D_gyro_bias_auto);
374+
375+
// Verify they're equivalent
376+
CHECK(sym::IsClose(new_covariance, new_covariance_auto, 1e-8));
377+
CHECK(sym::IsClose(new_DR_D_gyro_bias, new_DR_D_gyro_bias_auto, 1e-8));
378+
CHECK(sym::IsClose(new_Dv_D_accel_bias, new_Dv_D_accel_bias_auto, 1e-8));
379+
CHECK(sym::IsClose(new_Dv_D_gyro_bias, new_Dv_D_gyro_bias_auto, 1e-8));
380+
CHECK(sym::IsClose(new_Dp_D_accel_bias, new_Dp_D_accel_bias_auto, 1e-8));
381+
CHECK(sym::IsClose(new_Dp_D_gyro_bias, new_Dp_D_gyro_bias_auto, 1e-8));
382+
}
383+
}

0 commit comments

Comments
 (0)