Skip to content

Commit 5538810

Browse files
author
jonas
committed
Improve unit -test coverage
1 parent d39a4e2 commit 5538810

File tree

8 files changed

+1609
-364
lines changed

8 files changed

+1609
-364
lines changed

docs/en/advanced_features/vision_target_estimator_advanced.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ This guide expands on the [Vision Target Estimator module overview](../advanced_
1717
- [Development and debugging tips](#development-and-debugging-tips)
1818
- [Adding new measurement sources](#adding-new-measurement-sources)
1919
- [Regenerating the symbolic model](#regenerating-the-symbolic-model)
20+
- [Unit test suites](#unit-test-suites)
2021

2122
## System architecture
2223

@@ -288,3 +289,22 @@ The generated headers (`predictState.h`, `predictCov.h`, `computeInnovCov.h`, `g
288289
3. If you need to refresh the committed defaults, set `-DVTEST_UPDATE_COMMITTED_DERIVATION=ON` and commit the regenerated files in `Position/vtest_derivation/generated*/` once vetted.
289290

290291
If the build fails during regeneration, inspect the CMake output for the SymForce invocation and rerun it manually inside `Position/vtest_derivation/` to catch Python errors. After regenerating, rebuild the module to ensure the Jacobians and code stay in sync.
292+
293+
## Unit test suites
294+
295+
- `TEST_VTE_KF_position`: Kalman filter math for the position state (prediction, NIS gating, bias-aware H, OOSM gold standard). Static model (state size 3) by default, moving-target tests (state size 5) build only when `CONFIG_VTEST_MOVING` is enabled.
296+
- `TEST_VTE_KF_orientation`: Kalman filter math for yaw/yaw-rate (wrap logic, process noise, dt edge cases, covariance symmetry).
297+
- `TEST_VTE_VTEPosition`: Module logic for vision/GNSS fusion, offsets, interpolation, ordering, and uORB innovation topics (static + moving gated by `CONFIG_VTEST_MOVING`).
298+
- `TEST_VTE_VTEOrientation`: Module logic for yaw fusion, noise models, resets, and OOSM handling.
299+
- `TEST_VTE_VTEOosm`: Generic OOSM manager behavior.
300+
301+
Run locally:
302+
303+
```sh
304+
make tests TESTFILTER=TEST_VTE*
305+
```
306+
307+
Timing constraints to keep in mind when exercising OOSM paths:
308+
309+
- OOSM activates when measurement lag exceeds 20 ms.
310+
- History accepts measurements up to 500 ms old before rejecting them.

src/modules/vision_target_estimator/test/TEST_VTE_KF_orientation.cpp

Lines changed: 143 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,20 @@
4343

4444
#include <cmath>
4545
#include <matrix/math.hpp>
46+
#include <drivers/drv_hrt.h>
4647

4748
#include "Orientation/KF_orientation.h"
4849

4950
namespace vte = vision_target_estimator;
5051

5152
namespace
5253
{
54+
using namespace time_literals;
55+
5356
static constexpr float kTolerance = 1e-4f;
5457
static constexpr float kMinVar = 1e-9f;
5558
static constexpr float kPi = 3.14159265358979323846f;
59+
static constexpr hrt_abstime kNowOffset = 10_us;
5660

5761
using StateVec = matrix::Vector<float, vte::State::size>;
5862

@@ -128,6 +132,27 @@ TEST(KFOrientationTest, PredictAddsProcessNoise)
128132
EXPECT_NEAR(cov_diag(vte::State::yaw_rate), expected_p11, kTolerance);
129133
}
130134

135+
TEST(KFOrientationTest, PredictAddsOffDiagonalCovariance)
136+
{
137+
// WHY: The white-noise yaw acceleration model introduces cross-covariance terms.
138+
// WHAT: Ensure off-diagonal terms match dt^2/2 * var and covariance remains symmetric.
139+
vte::KF_orientation filter;
140+
filter.setState(makeState(0.f, 0.f));
141+
filter.setStateCovarianceDiag(makeDiag(0.f, 0.f));
142+
filter.setYawAccVar(0.4f);
143+
144+
const float dt = 0.5f;
145+
filter.predict(dt);
146+
147+
const auto cov = filter.getStateCovariance();
148+
const float expected_off_diag = 0.5f * dt * dt * 0.4f;
149+
150+
EXPECT_NEAR(cov(vte::State::yaw, vte::State::yaw_rate), expected_off_diag, kTolerance);
151+
EXPECT_NEAR(cov(vte::State::yaw_rate, vte::State::yaw), expected_off_diag, kTolerance);
152+
EXPECT_NEAR(cov(vte::State::yaw, vte::State::yaw_rate),
153+
cov(vte::State::yaw_rate, vte::State::yaw), kTolerance);
154+
}
155+
131156
TEST(KFOrientationTest, PredictIgnoresInvalidYawAccVar)
132157
{
133158
// WHY: Invalid noise parameters must not corrupt the covariance.
@@ -145,6 +170,56 @@ TEST(KFOrientationTest, PredictIgnoresInvalidYawAccVar)
145170
EXPECT_NEAR(cov_diag(vte::State::yaw_rate), 2.f, kTolerance);
146171
}
147172

173+
TEST(KFOrientationTest, PredictZeroDtNoChange)
174+
{
175+
// WHY: Zero dt should not change state or covariance.
176+
// WHAT: Predict with dt=0 and ensure state/cov stay unchanged.
177+
vte::KF_orientation filter;
178+
filter.setState(makeState(1.2f, -0.5f));
179+
filter.setStateCovarianceDiag(makeDiag(0.3f, 0.2f));
180+
filter.setYawAccVar(0.4f);
181+
182+
const StateVec state_before = filter.getState();
183+
const auto cov_before = filter.getStateCovariance();
184+
185+
filter.predict(0.f);
186+
187+
const StateVec state_after = filter.getState();
188+
const auto cov_after = filter.getStateCovariance();
189+
190+
EXPECT_NEAR(state_after(vte::State::yaw), state_before(vte::State::yaw), kTolerance);
191+
EXPECT_NEAR(state_after(vte::State::yaw_rate), state_before(vte::State::yaw_rate), kTolerance);
192+
EXPECT_NEAR(cov_after(vte::State::yaw, vte::State::yaw), cov_before(vte::State::yaw, vte::State::yaw), kTolerance);
193+
EXPECT_NEAR(cov_after(vte::State::yaw_rate, vte::State::yaw_rate),
194+
cov_before(vte::State::yaw_rate, vte::State::yaw_rate), kTolerance);
195+
EXPECT_NEAR(cov_after(vte::State::yaw, vte::State::yaw_rate),
196+
cov_before(vte::State::yaw, vte::State::yaw_rate), kTolerance);
197+
}
198+
199+
TEST(KFOrientationTest, PredictNegativeDtReturnsEarly)
200+
{
201+
// WHY: Negative dt is invalid and should be ignored.
202+
// WHAT: Verify predict() does not change state or covariance for dt < 0.
203+
vte::KF_orientation filter;
204+
filter.setState(makeState(-1.0f, 0.7f));
205+
filter.setStateCovarianceDiag(makeDiag(0.6f, 0.4f));
206+
filter.setYawAccVar(0.4f);
207+
208+
const StateVec state_before = filter.getState();
209+
const auto cov_before = filter.getStateCovariance();
210+
211+
filter.predict(-0.1f);
212+
213+
const StateVec state_after = filter.getState();
214+
const auto cov_after = filter.getStateCovariance();
215+
216+
EXPECT_NEAR(state_after(vte::State::yaw), state_before(vte::State::yaw), kTolerance);
217+
EXPECT_NEAR(state_after(vte::State::yaw_rate), state_before(vte::State::yaw_rate), kTolerance);
218+
EXPECT_NEAR(cov_after(vte::State::yaw, vte::State::yaw), cov_before(vte::State::yaw, vte::State::yaw), kTolerance);
219+
EXPECT_NEAR(cov_after(vte::State::yaw_rate, vte::State::yaw_rate),
220+
cov_before(vte::State::yaw_rate, vte::State::yaw_rate), kTolerance);
221+
}
222+
148223
TEST(KFOrientationTest, InnovationWrapsAcrossPi)
149224
{
150225
// WHY: Innovations across the wrap should be small, not ~2pi.
@@ -154,15 +229,35 @@ TEST(KFOrientationTest, InnovationWrapsAcrossPi)
154229
filter.setStateCovarianceDiag(makeDiag(1.f, 1.f));
155230

156231
const StateVec H = makeH(vte::State::yaw);
157-
const vte::KF_orientation::ScalarMeas meas = makeMeas(1'000'000, -3.13f, 0.1f, H);
158-
const vte::FusionResult res = filter.fuseScalarAtTime(meas, 1'000'010, 100.f);
232+
static constexpr hrt_abstime kMeasTime = 1_s;
233+
const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, -3.13f, 0.1f, H);
234+
const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f);
159235

160236
const float expected_innov = matrix::wrap_pi(-3.13f - 3.13f);
161237

162238
EXPECT_EQ(res.status, vte::FusionStatus::FUSED_CURRENT);
163239
EXPECT_NEAR(res.innov, expected_innov, kTolerance);
164240
}
165241

242+
TEST(KFOrientationTest, InnovationDoesNotWrapYawRateMeasurement)
243+
{
244+
// WHY: Only yaw innovations should be wrapped; yaw-rate is linear.
245+
// WHAT: Ensure yaw-rate innovation uses the raw difference.
246+
vte::KF_orientation filter;
247+
filter.setState(makeState(0.f, 3.13f));
248+
filter.setStateCovarianceDiag(makeDiag(1.f, 1.f));
249+
250+
const StateVec H = makeH(vte::State::yaw_rate);
251+
static constexpr hrt_abstime kMeasTime = 1500_ms;
252+
const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, -3.13f, 0.1f, H);
253+
const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f);
254+
255+
const float expected_innov = -3.13f - 3.13f;
256+
257+
EXPECT_EQ(res.status, vte::FusionStatus::FUSED_CURRENT);
258+
EXPECT_NEAR(res.innov, expected_innov, kTolerance);
259+
}
260+
166261
TEST(KFOrientationTest, RejectsOutlierNis)
167262
{
168263
// WHY: NIS gating is critical to rejecting yaw outliers.
@@ -172,8 +267,9 @@ TEST(KFOrientationTest, RejectsOutlierNis)
172267
filter.setStateCovarianceDiag(makeDiag(0.01f, 0.01f));
173268

174269
const StateVec H = makeH(vte::State::yaw);
175-
const vte::KF_orientation::ScalarMeas meas = makeMeas(2'000'000, 3.14f, 0.01f, H);
176-
const vte::FusionResult res = filter.fuseScalarAtTime(meas, 2'000'010, 0.1f);
270+
static constexpr hrt_abstime kMeasTime = 2_s;
271+
const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, 3.14f, 0.01f, H);
272+
const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 0.1f);
177273

178274
const StateVec state = filter.getState();
179275

@@ -192,8 +288,9 @@ TEST(KFOrientationTest, ClampCovarianceToMin)
192288

193289
const StateVec H = makeH(vte::State::yaw);
194290
// Keep innovation small to avoid NIS rejection while exercising the clamp.
195-
const vte::KF_orientation::ScalarMeas meas = makeMeas(3'000'000, 0.f, 1e-4f, H);
196-
const vte::FusionResult res = filter.fuseScalarAtTime(meas, 3'000'010, 100.f);
291+
static constexpr hrt_abstime kMeasTime = 3_s;
292+
const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, 0.f, 1e-4f, H);
293+
const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f);
197294
const StateVec cov_diag = filter.getStateCovarianceDiag();
198295

199296
EXPECT_EQ(res.status, vte::FusionStatus::FUSED_CURRENT);
@@ -206,15 +303,51 @@ TEST(KFOrientationTest, CorrectionWrapsYaw)
206303
// WHY: Correction should normalize yaw after applying the gain.
207304
// WHAT: Force a correction that would exceed pi without wrapping.
208305
vte::KF_orientation filter;
209-
filter.setState(makeState(3.0f, 0.f));
210-
filter.setStateCovarianceDiag(makeDiag(1.f, 1.f));
306+
filter.setState(makeState(3.13f, 0.f));
307+
filter.setStateCovarianceDiag(makeDiag(0.5f, 0.2f));
211308

212309
const StateVec H = makeH(vte::State::yaw);
213-
const vte::KF_orientation::ScalarMeas meas = makeMeas(4'000'000, 3.14f, 0.1f, H);
214-
const vte::FusionResult res = filter.fuseScalarAtTime(meas, 4'000'010, 100.f);
310+
static constexpr hrt_abstime kMeasTime = 4_s;
311+
const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, -3.13f, 0.2f, H);
312+
const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f);
215313
const StateVec state = filter.getState();
216314

315+
const float expected_innov = matrix::wrap_pi(-3.13f - 3.13f);
316+
const float expected_k = 0.5f / (0.5f + 0.2f);
317+
const float expected_yaw = matrix::wrap_pi(3.13f + expected_k * expected_innov);
318+
217319
EXPECT_EQ(res.status, vte::FusionStatus::FUSED_CURRENT);
320+
EXPECT_NEAR(res.innov, expected_innov, kTolerance);
321+
EXPECT_NEAR(state(vte::State::yaw), expected_yaw, kTolerance);
218322
EXPECT_LE(state(vte::State::yaw), kPi);
219323
EXPECT_GE(state(vte::State::yaw), -kPi);
220324
}
325+
326+
TEST(KFOrientationTest, CorrectionMaintainsSymmetricCovariance)
327+
{
328+
// WHY: Corrections must keep covariance symmetric and bounded.
329+
// WHAT: Perform a correction and validate off-diagonal symmetry and magnitude.
330+
vte::KF_orientation filter;
331+
filter.setState(makeState(0.2f, 0.1f));
332+
filter.setStateCovarianceDiag(makeDiag(0.6f, 0.4f));
333+
filter.setYawAccVar(0.3f);
334+
335+
filter.predict(0.2f);
336+
const auto cov_before = filter.getStateCovariance();
337+
338+
const StateVec H = makeH(vte::State::yaw);
339+
static constexpr hrt_abstime kMeasTime = 5_s;
340+
const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, 0.25f, 0.1f, H);
341+
const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f);
342+
const auto cov_after = filter.getStateCovariance();
343+
344+
EXPECT_EQ(res.status, vte::FusionStatus::FUSED_CURRENT);
345+
EXPECT_NEAR(cov_after(vte::State::yaw, vte::State::yaw_rate),
346+
cov_after(vte::State::yaw_rate, vte::State::yaw), kTolerance);
347+
348+
const float bound = sqrtf(cov_after(vte::State::yaw, vte::State::yaw) *
349+
cov_after(vte::State::yaw_rate, vte::State::yaw_rate)) + kTolerance;
350+
EXPECT_LE(fabsf(cov_after(vte::State::yaw, vte::State::yaw_rate)), bound);
351+
EXPECT_LE(fabsf(cov_after(vte::State::yaw, vte::State::yaw_rate)),
352+
fabsf(cov_before(vte::State::yaw, vte::State::yaw_rate)) + 1.0f);
353+
}

0 commit comments

Comments
 (0)