4343
4444#include < cmath>
4545#include < matrix/math.hpp>
46+ #include < drivers/drv_hrt.h>
4647
4748#include " Orientation/KF_orientation.h"
4849
4950namespace vte = vision_target_estimator;
5051
5152namespace
5253{
54+ using namespace time_literals ;
55+
5356static constexpr float kTolerance = 1e-4f ;
5457static constexpr float kMinVar = 1e-9f ;
5558static constexpr float kPi = 3 .14159265358979323846f ;
59+ static constexpr hrt_abstime kNowOffset = 10_us;
5660
5761using 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+
131156TEST (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+
148223TEST (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+
166261TEST (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