Skip to content

Commit dfa5545

Browse files
bkanatorclaude
andauthored
Fix predict_to_current_time forward extrapolation and Omnidirectional3D jacobian (#35)
* fix(odometry_3d): restore predict_to_current_time forward extrapolation The forward prediction delta was computed as std::min(to_predict_to - stamp, 0.0), which is <= 0 in normal forward operation, forcing dt == 0 and silently disabling predict_to_current_time: the publisher emitted the stale latest-optimized pose stamped as "now". This manifested as a turn-rate-proportional yaw lag (~150 ms; up to ~4.4 deg at 30 deg/s on a mecanum base) that no configuration could fix. Regressed in #24. Clamp the delta to be non-negative (std::max, matching the 2D publisher) so forward prediction is preserved while backward prediction is still prevented. Extract the delta into detail::forwardPredictionDt() and add a regression test that would have caught this. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(omnidirectional_3d): compute prediction jacobian in RPY space The assembled state jacobian placed J[1] (d(state)/d(quaternion), 15x4) directly into the orientation columns and truncated the acceleration block (J[4].block<15,2>), leaving Ceres an incorrect orientation gradient during 3D rotation. Convert J[1] to RPY space (15x3) via the pseudo-inverse of the quat->rpy jacobian (chain rule). A rank-revealing CompleteOrthogonalDecomposition is used so it degrades gracefully at gimbal lock, where quaternion2rpy zeros rows of the quat->rpy jacobian and an explicit (A*A^T)^-1 would NaN. Add a predictJacobians test that compares the analytic 15x15 jacobian against ceres::Jet autodiff; it fails against the prior truncated assembly and passes with this fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2871986 commit dfa5545

6 files changed

Lines changed: 210 additions & 5 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Software License Agreement (BSD License)
3+
*
4+
* Copyright (c) 2026, PickNik Robotics, Inc.
5+
* All rights reserved.
6+
*
7+
* Redistribution and use in source and binary forms, with or without
8+
* modification, are permitted provided that the following conditions
9+
* are met:
10+
*
11+
* * Redistributions of source code must retain the above copyright
12+
* notice, this list of conditions and the following disclaimer.
13+
* * Redistributions in binary form must reproduce the above
14+
* copyright notice, this list of conditions and the following
15+
* disclaimer in the documentation and/or other materials provided
16+
* with the distribution.
17+
* * Neither the name of the copyright holder nor the names of its
18+
* contributors may be used to endorse or promote products derived
19+
* from this software without specific prior written permission.
20+
*
21+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
22+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
23+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
24+
* FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
25+
* COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
26+
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
27+
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
28+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29+
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
30+
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
31+
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32+
* POSSIBILITY OF SUCH DAMAGE.
33+
*/
34+
#ifndef FUSE_MODELS__DETAIL__PREDICTION_TIME_HPP_
35+
#define FUSE_MODELS__DETAIL__PREDICTION_TIME_HPP_
36+
37+
#include <algorithm>
38+
39+
namespace fuse_models::detail
40+
{
41+
42+
/**
43+
* @brief Compute the forward time delta used to extrapolate the latest optimized state up to the
44+
* current time in the odometry publishers (predict_to_current_time).
45+
*
46+
* The publisher predicts from the latest optimized state (at @p stamp_sec) forward to the wall-clock
47+
* time it is publishing for (@p to_predict_to_sec). That delta must be clamped to be non-negative:
48+
* we never want to predict *backwards* if the optimized state is momentarily ahead of the publish
49+
* clock. It must NOT be clamped to be non-positive -- doing so forces dt == 0 and silently disables
50+
* forward prediction, so the publisher emits a stale pose stamped as "now" (a latency bug; see the
51+
* regression test). Hence std::max(..., 0.0), never std::min(..., 0.0).
52+
*
53+
* @param[in] to_predict_to_sec Time to predict the state to (seconds), typically wall-clock now.
54+
* @param[in] stamp_sec Timestamp of the latest optimized state (seconds).
55+
* @return The non-negative forward prediction interval in seconds.
56+
*/
57+
inline double forwardPredictionDt(double const to_predict_to_sec, double const stamp_sec)
58+
{
59+
return std::max(to_predict_to_sec - stamp_sec, 0.0);
60+
}
61+
62+
} // namespace fuse_models::detail
63+
64+
#endif // FUSE_MODELS__DETAIL__PREDICTION_TIME_HPP_

fuse_models/include/fuse_models/omnidirectional_3d_predict.hpp

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
#define FUSE_MODELS__OMNIDIRECTIONAL_3D_PREDICT_HPP_
3636

3737
#include <Eigen/Core>
38+
#include <Eigen/QR>
3839

3940
#include <fuse_core/util.hpp>
4041
#include <fuse_core/eigen.hpp>
@@ -508,9 +509,16 @@ inline void predict(fuse_core::Vector3d const& position1, Eigen::Quaterniond con
508509
vel_linear2.y(), vel_linear2.z(), vel_angular2.x(), vel_angular2.y(), vel_angular2.z(), acc_linear2.x(),
509510
acc_linear2.y(), acc_linear2.z(), jacobians.data(), jacobian_quat2rpy, velocity_decay);
510511

511-
// TODO(henrygerardmoore): figure out how to fix this
512-
// see https://github.com/locusrobotics/fuse/pull/354#discussion_r1884288806
513-
jacobian << J[0], J[1], J[2], J[3], J[4].block<15, 2>(0, 0);
512+
// Convert J[1] from quaternion space (15x4) to RPY space (15x3) via the chain rule:
513+
// d(state2)/d(rpy1) = d(state2)/d(quat1) * d(quat1)/d(rpy1) = J[1] * pinv(d(rpy)/d(quat)).
514+
// (see https://github.com/locusrobotics/fuse/pull/354#discussion_r1884288806)
515+
// Use a rank-revealing pseudo-inverse: at gimbal lock, quaternion2rpy zeros rows of the
516+
// quat->rpy Jacobian, so (A*A^T) is singular and an explicit A^T(A*A^T)^-1 would yield NaN.
517+
Eigen::Map<fuse_core::Matrix<double, 3, 4>> const j_quat2rpy_map(jacobian_quat2rpy);
518+
fuse_core::Matrix<double, 4, 3> const j_quat2rpy_pinv =
519+
Eigen::CompleteOrthogonalDecomposition<fuse_core::Matrix<double, 3, 4>>(j_quat2rpy_map).pseudoInverse();
520+
fuse_core::Matrix<double, 15, 3> const J1_rpy = J[1] * j_quat2rpy_pinv;
521+
jacobian << J[0], J1_rpy, J[2], J[3], J[4];
514522

515523
// Convert back to quaternion
516524
orientation2 = Eigen::AngleAxisd(rpy[2], Eigen::Vector3d::UnitZ()) *

fuse_models/src/odometry_3d_publisher.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
#include <fuse_core/eigen.hpp>
4949
#include <fuse_core/uuid.hpp>
5050
#include <fuse_models/common/sensor_proc.hpp>
51+
#include <fuse_models/detail/prediction_time.hpp>
5152
#include <fuse_models/odometry_3d_publisher.hpp>
5253
#include <fuse_models/omnidirectional_3d_predict.hpp>
5354
#include <geometry_msgs/msg/accel_with_covariance_stamped.hpp>
@@ -441,7 +442,8 @@ void Odometry3DPublisher::predict(tf2::Transform& pose, nav_msgs::msg::Odometry&
441442
geometry_msgs::msg::AccelWithCovarianceStamped acceleration_output,
442443
bool latest_covariance_valid) const
443444
{
444-
double const dt = std::min(to_predict_to.seconds() - rclcpp::Time(odom_output.header.stamp).seconds(), 0.);
445+
double const dt =
446+
detail::forwardPredictionDt(to_predict_to.seconds(), rclcpp::Time(odom_output.header.stamp).seconds());
445447
// Convert pose in Eigen representation
446448
fuse_core::Vector3d position;
447449
fuse_core::Vector3d velocity_linear;

fuse_models/test/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ set(TEST_TARGETS
99
test_omnidirectional_3d
1010
test_omnidirectional_3d_predict
1111
test_omnidirectional_3d_state_cost_function
12-
test_omnidirectional_3d_ignition)
12+
test_omnidirectional_3d_ignition
13+
test_prediction_time)
1314

1415
foreach(test_name ${TEST_TARGETS})
1516
ament_add_gtest("${test_name}" "${test_name}.cpp")

fuse_models/test/test_omnidirectional_3d_predict.cpp

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
#include <vector>
3939

4040
#include <fuse_core/eigen_gtest.hpp>
41+
#include <fuse_core/util.hpp>
4142
#include <fuse_models/omnidirectional_3d_predict.hpp>
4243
#include <ceres/jet.h>
4344

@@ -711,3 +712,63 @@ TEST(Predict, VelocityDecaysGeometricallyOverMultipleSteps)
711712
// AND after 1 second velocity has decayed to vel0 * exp(-k * 1.0)
712713
EXPECT_NEAR(vel_linear.x(), 0.5 * std::exp(-k * 1.0), 1e-6);
713714
}
715+
716+
TEST(Predict, predictJacobians)
717+
{
718+
// GIVEN a state away from gimbal lock (small angles keep the RPY parameterization well conditioned)
719+
double const dt = 0.1;
720+
const fuse_core::Vector3d position1(0.1, -0.2, 0.3);
721+
const fuse_core::Vector3d vel_linear1(1.0, 0.2, -0.1);
722+
const fuse_core::Vector3d vel_angular1(0.3, -0.2, 0.5);
723+
const fuse_core::Vector3d acc_linear1(0.5, -0.4, 0.2);
724+
const Eigen::Quaterniond orientation1 = Eigen::AngleAxisd(0.3, Eigen::Vector3d::UnitZ()) *
725+
Eigen::AngleAxisd(-0.1, Eigen::Vector3d::UnitY()) *
726+
Eigen::AngleAxisd(0.2, Eigen::Vector3d::UnitX());
727+
728+
// Extract RPY with the same convention predict() uses so the autodiff input matches the analytic input.
729+
double const quat[4] = { orientation1.w(), orientation1.x(), orientation1.y(), orientation1.z() };
730+
double rpy[3];
731+
fuse_core::quaternion2rpy(quat, rpy);
732+
733+
// WHEN computing the analytic 15x15 state Jacobian (orientation columns in RPY space)
734+
fuse_core::Vector3d position2;
735+
fuse_core::Vector3d vel_linear2;
736+
fuse_core::Vector3d vel_angular2;
737+
fuse_core::Vector3d acc_linear2;
738+
Eigen::Quaterniond orientation2;
739+
fuse_core::Matrix15d jacobian_analytic;
740+
fuse_models::predict(position1, orientation1, vel_linear1, vel_angular1, acc_linear1, dt, position2, orientation2,
741+
vel_linear2, vel_angular2, acc_linear2, jacobian_analytic);
742+
743+
// AND the same Jacobian by autodiff through the templated (RPY-in, RPY-out) overload
744+
using Jet = ceres::Jet<double, 15>;
745+
const std::array<Jet, 15> x{
746+
Jet(position1.x(), 0), Jet(position1.y(), 1), Jet(position1.z(), 2), Jet(rpy[0], 3),
747+
Jet(rpy[1], 4), Jet(rpy[2], 5), Jet(vel_linear1.x(), 6), Jet(vel_linear1.y(), 7),
748+
Jet(vel_linear1.z(), 8), Jet(vel_angular1.x(), 9), Jet(vel_angular1.y(), 10), Jet(vel_angular1.z(), 11),
749+
Jet(acc_linear1.x(), 12), Jet(acc_linear1.y(), 13), Jet(acc_linear1.z(), 14)
750+
};
751+
std::array<Jet, 3> position2_jet;
752+
std::array<Jet, 3> orientation2_jet;
753+
std::array<Jet, 3> vel_linear2_jet;
754+
std::array<Jet, 3> vel_angular2_jet;
755+
std::array<Jet, 3> acc_linear2_jet;
756+
fuse_models::predict(x.data(), x.data() + 3, x.data() + 6, x.data() + 9, x.data() + 12, Jet(dt), position2_jet.data(),
757+
orientation2_jet.data(), vel_linear2_jet.data(), vel_angular2_jet.data(),
758+
acc_linear2_jet.data());
759+
760+
fuse_core::Matrix15d jacobian_autodiff;
761+
const std::array<std::array<Jet, 3> const*, 5> outputs{ &position2_jet, &orientation2_jet, &vel_linear2_jet,
762+
&vel_angular2_jet, &acc_linear2_jet };
763+
for (int block = 0; block < 5; ++block)
764+
{
765+
for (int row = 0; row < 3; ++row)
766+
{
767+
jacobian_autodiff.row(block * 3 + row) = (*outputs[block])[row].v.transpose();
768+
}
769+
}
770+
771+
// THEN the analytic Jacobian matches autodiff. This guards the quaternion->RPY conversion of the
772+
// orientation columns: the prior truncated assembly would fail this check.
773+
EXPECT_MATRIX_NEAR(jacobian_autodiff, jacobian_analytic, 1e-9);
774+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Software License Agreement (BSD License)
3+
*
4+
* Copyright (c) 2026, PickNik Robotics, Inc.
5+
* All rights reserved.
6+
*
7+
* Redistribution and use in source and binary forms, with or without
8+
* modification, are permitted provided that the following conditions
9+
* are met:
10+
*
11+
* * Redistributions of source code must retain the above copyright
12+
* notice, this list of conditions and the following disclaimer.
13+
* * Redistributions in binary form must reproduce the above
14+
* copyright notice, this list of conditions and the following
15+
* disclaimer in the documentation and/or other materials provided
16+
* with the distribution.
17+
* * Neither the name of the copyright holder nor the names of its
18+
* contributors may be used to endorse or promote products derived
19+
* from this software without specific prior written permission.
20+
*
21+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
22+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
23+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
24+
* FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
25+
* COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
26+
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
27+
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
28+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29+
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
30+
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
31+
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32+
* POSSIBILITY OF SUCH DAMAGE.
33+
*/
34+
#include <gtest/gtest.h>
35+
36+
#include <fuse_models/detail/prediction_time.hpp>
37+
38+
using fuse_models::detail::forwardPredictionDt;
39+
40+
// Regression test for the predict_to_current_time latency bug.
41+
//
42+
// The odometry publishers extrapolate the latest optimized state forward to the current time.
43+
// A prior change computed this delta as std::min(to - stamp, 0.0), which is always <= 0 in normal
44+
// forward operation, forcing dt == 0 and silently disabling forward prediction: the publisher then
45+
// emitted a stale pose stamped as "now". The correct behavior clamps the delta to be non-negative
46+
// (std::max(..., 0.0)) so forward prediction is preserved while backward prediction is prevented.
47+
48+
TEST(PredictionTime, ForwardIntervalIsPreserved)
49+
{
50+
// The whole point of predict_to_current_time: a future target must yield a positive dt so the
51+
// state is actually extrapolated forward. The std::min bug returned 0.0 here.
52+
// (EXPECT_NEAR, not EXPECT_DOUBLE_EQ: 10.1 - 10.0 is not exactly 0.1 in floating point.)
53+
EXPECT_NEAR(0.1, forwardPredictionDt(10.1, 10.0), 1e-12);
54+
EXPECT_DOUBLE_EQ(0.5, forwardPredictionDt(100.5, 100.0)); // 0.5 is exactly representable
55+
EXPECT_GT(forwardPredictionDt(10.1, 10.0), 0.0);
56+
}
57+
58+
TEST(PredictionTime, BackwardIntervalIsClampedToZero)
59+
{
60+
// If the optimized state is momentarily ahead of the publish clock, do not predict backwards.
61+
EXPECT_DOUBLE_EQ(0.0, forwardPredictionDt(9.9, 10.0));
62+
EXPECT_DOUBLE_EQ(0.0, forwardPredictionDt(10.0, 10.0));
63+
}
64+
65+
int main(int argc, char** argv)
66+
{
67+
testing::InitGoogleTest(&argc, argv);
68+
return RUN_ALL_TESTS();
69+
}

0 commit comments

Comments
 (0)