James Kuszmaul | a60aee1 | 2024-02-02 22:57:47 -0800 | [diff] [blame] | 1 | #ifndef FRC971_IMU_IMU_CALIBRATOR_H_ |
| 2 | #define FRC971_IMU_IMU_CALIBRATOR_H_ |
| 3 | |
| 4 | #include <optional> |
| 5 | #include <span> |
| 6 | #include <tuple> |
| 7 | #include <vector> |
| 8 | |
Austin Schuh | 99f7c6a | 2024-06-25 22:07:44 -0700 | [diff] [blame] | 9 | #include "absl/log/check.h" |
| 10 | #include "absl/log/log.h" |
James Kuszmaul | a60aee1 | 2024-02-02 22:57:47 -0800 | [diff] [blame] | 11 | #include "absl/strings/str_format.h" |
| 12 | #include "absl/strings/str_join.h" |
| 13 | #include "ceres/ceres.h" |
James Kuszmaul | a60aee1 | 2024-02-02 22:57:47 -0800 | [diff] [blame] | 14 | #include <Eigen/Core> |
| 15 | #include <Eigen/Geometry> |
| 16 | |
| 17 | #include "aos/time/time.h" |
| 18 | |
| 19 | namespace frc971::imu { |
| 20 | |
| 21 | // Contains a reading that comes directly from an IMU. |
| 22 | // These should not be zeroed or corrected for yet. |
| 23 | struct RawImuReading { |
| 24 | aos::monotonic_clock::time_point capture_time; |
| 25 | // gyro readings are in radians / sec; accelerometer readings are in g's. |
| 26 | const Eigen::Vector3d gyro; |
| 27 | const Eigen::Vector3d accel; |
| 28 | }; |
| 29 | |
| 30 | // Note on how we manage populating ceres parameters: |
| 31 | // * We use dynamically-sized parameter lists for convenience. |
| 32 | // * For every struct that we have that corresponds to problem parameters, |
| 33 | // there is a PopulateParameters() method that takes a: |
| 34 | // * ceres::Problem* that is used for setting parameter constraints. |
| 35 | // * ceres::DynamicCostFunction* that is used to mark that we have added |
| 36 | // a parameter block. |
| 37 | // * A std::vector<double*> that will later be passed to AddResidualBlock. |
| 38 | // We add any parameter blocks which we add to the problem. |
| 39 | // * post_populate_methods is a list of std::function's that will get called |
| 40 | // after everything is populated and added (this is necessary because |
| 41 | // we cannot add constraints to the Problem until after everything |
| 42 | // has been added). |
| 43 | // * Additionally, there is a PopulateFromParameters() method which is used |
| 44 | // in the ceres cost functor and: |
| 45 | // * Takes a parameters double-pointer where parameters[X] is the |
| 46 | // parameter block X. |
| 47 | // * Returns a pair where the first value is a populated instance of the |
| 48 | // struct and an updated parameters pointer which points to the next |
| 49 | // set of pointers. |
| 50 | // * All the Populate* methods must be called in the same order every |
| 51 | // time so that they result in the raw pointers getting populated |
| 52 | // consistently. |
| 53 | |
| 54 | // These are the parameters corresponding to things which will vary at every |
| 55 | // power-on of the IMU. |
| 56 | template <typename Scalar> |
| 57 | struct DynamicImuParameters { |
| 58 | // A scalar to represent the current magnitude of gravity, in g's. |
| 59 | // This currently compensates both for any variations in local gravity as well |
| 60 | // as for some amount of variations in the IMU itself. In the future we may |
| 61 | // expand this to have a local gravity number that is global to all IMUs while |
| 62 | // separately calibrating per-axis information. |
| 63 | Scalar gravity; |
| 64 | // Current gyro zero, in radians / sec. |
| 65 | // These are the per-axis values that the gyro will read when it is sitting |
| 66 | // still. Technically these zeroes will drift over time; however, the |
| 67 | // time-windows that we expect to use for calibration are short enough that |
| 68 | // this should be a non-issue. |
| 69 | Eigen::Matrix<Scalar, 3, 1> gyro_zero; |
| 70 | void PopulateParameters( |
| 71 | ceres::Problem *problem, ceres::DynamicCostFunction *cost_function, |
| 72 | std::vector<double *> *parameters, |
| 73 | std::vector<std::function<void()>> *post_populate_methods) { |
| 74 | cost_function->AddParameterBlock(1); |
| 75 | parameters->push_back(&gravity); |
| 76 | cost_function->AddParameterBlock(3); |
| 77 | parameters->push_back(gyro_zero.data()); |
| 78 | post_populate_methods->emplace_back([this, problem]() { |
| 79 | // Gravity shouldn't vary by much (these bounds are significantly larger |
| 80 | // than any real variations which we will experience). |
| 81 | problem->SetParameterLowerBound(&gravity, 0, 0.95); |
| 82 | problem->SetParameterUpperBound(&gravity, 0, 1.05); |
| 83 | for (int i = 0; i < 3; ++i) { |
| 84 | problem->SetParameterLowerBound(gyro_zero.data(), i, -0.05); |
| 85 | problem->SetParameterUpperBound(gyro_zero.data(), i, 0.05); |
| 86 | } |
| 87 | }); |
| 88 | } |
| 89 | static std::tuple<DynamicImuParameters<Scalar>, const Scalar *const *> |
| 90 | PopulateFromParameters(const Scalar *const *parameters) { |
| 91 | const Scalar *const gravity = parameters[0]; |
| 92 | ++parameters; |
| 93 | const Scalar *const gyro = parameters[0]; |
| 94 | ++parameters; |
| 95 | return std::make_tuple( |
| 96 | DynamicImuParameters<Scalar>{ |
| 97 | *gravity, Eigen::Matrix<Scalar, 3, 1>(gyro[0], gyro[1], gyro[2])}, |
| 98 | parameters); |
| 99 | } |
| 100 | std::string ToString() const { |
| 101 | std::stringstream out; |
| 102 | out << "gravity: " << gravity << " gyro_zero: " << gyro_zero.transpose(); |
| 103 | return out.str(); |
| 104 | } |
| 105 | }; |
| 106 | |
| 107 | // These parameters correspond to IMU parameters which will not vary between |
| 108 | // boots. Namely, the relative positions and time offsets of the IMU(s). |
| 109 | template <typename Scalar> |
| 110 | struct StaticImuParameters { |
| 111 | // If you have a point p_imu in this IMU's frame then (rotation * |
| 112 | // p_imu + position) will give you that point's position in the board frame |
| 113 | // (the "board" frame refers to the frame associated with the entire PCB, |
| 114 | // where the PCB itself contains multiple IMUs. The board frame will be |
| 115 | // attached to whichever IMU is being treated as the origin). |
| 116 | Eigen::Quaternion<Scalar> rotation; |
| 117 | // position is currently unused because it is only observeable when there |
| 118 | // are coriolis effects, and we currently only make use of accelerometer |
| 119 | // readings from when the IMU is sat still. |
| 120 | // As such, we currently assume that all position offsets are zero. |
| 121 | // Eigen::Matrix<Scalar, 3, 1> position; |
| 122 | // The "true" time at which the event occurred is equal to the capture time - |
| 123 | // time_offset. I.e., a more positive time offset implies that the there is a |
| 124 | // large delay between the imu readings being taken and us observing them. |
| 125 | Scalar time_offset; |
| 126 | |
| 127 | void PopulateParameters( |
Austin Schuh | 31f99a2 | 2024-06-25 18:30:13 -0700 | [diff] [blame] | 128 | ceres::EigenQuaternionManifold *quaternion_local_parameterization, |
James Kuszmaul | a60aee1 | 2024-02-02 22:57:47 -0800 | [diff] [blame] | 129 | ceres::Problem *problem, ceres::DynamicCostFunction *cost_function, |
| 130 | std::vector<double *> *parameters, |
| 131 | std::vector<std::function<void()>> *post_populate_methods) { |
| 132 | cost_function->AddParameterBlock(4); |
| 133 | parameters->push_back(rotation.coeffs().data()); |
| 134 | cost_function->AddParameterBlock(1); |
| 135 | parameters->push_back(&time_offset); |
| 136 | post_populate_methods->emplace_back( |
| 137 | [this, problem, quaternion_local_parameterization]() { |
Austin Schuh | 31f99a2 | 2024-06-25 18:30:13 -0700 | [diff] [blame] | 138 | problem->SetManifold(rotation.coeffs().data(), |
| 139 | quaternion_local_parameterization); |
James Kuszmaul | a60aee1 | 2024-02-02 22:57:47 -0800 | [diff] [blame] | 140 | problem->SetParameterLowerBound(&time_offset, 0, -0.03); |
| 141 | problem->SetParameterUpperBound(&time_offset, 0, 0.03); |
| 142 | }); |
| 143 | } |
| 144 | static std::tuple<StaticImuParameters, const Scalar *const *> |
| 145 | PopulateFromParameters(const Scalar *const *parameters) { |
| 146 | const Scalar *const quat = parameters[0]; |
| 147 | ++parameters; |
| 148 | const Scalar *const time = parameters[0]; |
| 149 | ++parameters; |
| 150 | return std::make_tuple( |
| 151 | StaticImuParameters{ |
| 152 | Eigen::Quaternion<Scalar>(quat[3], quat[0], quat[1], quat[2]), |
| 153 | *time}, |
| 154 | parameters); |
| 155 | } |
| 156 | std::string ToString() const { |
| 157 | std::stringstream out; |
| 158 | out << "quat: " << rotation.coeffs().transpose() |
| 159 | << " time_offset: " << time_offset; |
| 160 | return out.str(); |
| 161 | } |
| 162 | }; |
| 163 | |
| 164 | // Represents the calibration for a single IMU. |
| 165 | template <typename Scalar> |
| 166 | struct ImuConfig { |
| 167 | // Set to true if this IMU is to be considered the origin of the coordinate |
| 168 | // system. This will also mean that this IMU is treated as the source-of-truth |
| 169 | // for clock offsets. |
| 170 | bool is_origin; |
| 171 | // Will be nullopt if is_origin is true (the corresponding rotations and |
| 172 | // offsets will all be the identity/zero as appropriate). |
| 173 | std::optional<StaticImuParameters<Scalar>> parameters; |
| 174 | |
| 175 | DynamicImuParameters<Scalar> dynamic_params{ |
| 176 | .gravity = static_cast<Scalar>(1.0), |
| 177 | .gyro_zero = Eigen::Matrix<Scalar, 3, 1>::Zero()}; |
| 178 | std::string ToString() const { |
| 179 | return absl::StrFormat( |
| 180 | "is_origin: %d params: %s dynamic: %s", is_origin, |
| 181 | parameters.has_value() ? parameters->ToString() : std::string("<null>"), |
| 182 | dynamic_params.ToString()); |
| 183 | } |
| 184 | }; |
| 185 | |
| 186 | // Represents all of the configuration parameters for the entire system. |
| 187 | template <typename Scalar> |
| 188 | struct AllParameters { |
| 189 | // Each entry in the imus vector will be a single imu. |
| 190 | std::vector<ImuConfig<Scalar>> imus; |
| 191 | std::tuple<std::vector<double *>, std::vector<std::function<void()>>> |
| 192 | PopulateParameters( |
Austin Schuh | 31f99a2 | 2024-06-25 18:30:13 -0700 | [diff] [blame] | 193 | ceres::EigenQuaternionManifold *quaternion_local_parameterization, |
James Kuszmaul | a60aee1 | 2024-02-02 22:57:47 -0800 | [diff] [blame] | 194 | ceres::Problem *problem, ceres::DynamicCostFunction *cost_function) { |
| 195 | std::vector<std::function<void()>> post_populate_methods; |
| 196 | std::vector<double *> parameters; |
| 197 | for (auto &imu : imus) { |
| 198 | if (imu.parameters.has_value()) { |
| 199 | imu.parameters.value().PopulateParameters( |
| 200 | quaternion_local_parameterization, problem, cost_function, |
| 201 | ¶meters, &post_populate_methods); |
| 202 | } |
| 203 | imu.dynamic_params.PopulateParameters(problem, cost_function, ¶meters, |
| 204 | &post_populate_methods); |
| 205 | } |
| 206 | return std::make_tuple(parameters, post_populate_methods); |
| 207 | } |
| 208 | static AllParameters PopulateFromParameters( |
| 209 | const std::vector<ImuConfig<double>> &nominal_configs, |
| 210 | const Scalar *const *parameters) { |
| 211 | std::vector<ImuConfig<Scalar>> result; |
| 212 | for (size_t imu_index = 0; imu_index < nominal_configs.size(); |
| 213 | ++imu_index) { |
| 214 | ImuConfig<Scalar> config; |
| 215 | config.is_origin = nominal_configs[imu_index].is_origin; |
| 216 | if (!config.is_origin) { |
| 217 | std::tie(config.parameters, parameters) = |
| 218 | StaticImuParameters<Scalar>::PopulateFromParameters(parameters); |
| 219 | } |
| 220 | std::tie(config.dynamic_params, parameters) = |
| 221 | DynamicImuParameters<Scalar>::PopulateFromParameters(parameters); |
| 222 | result.emplace_back(std::move(config)); |
| 223 | } |
| 224 | return {.imus = std::move(result)}; |
| 225 | } |
| 226 | std::string ToString() const { |
| 227 | std::vector<std::string> imu_strings; |
| 228 | for (const auto &imu : imus) { |
| 229 | std::vector<std::string> dynamic_params; |
| 230 | imu_strings.push_back(absl::StrFormat("config: %s", imu.ToString())); |
| 231 | } |
| 232 | return absl::StrJoin(imu_strings, "\n"); |
| 233 | } |
| 234 | }; |
| 235 | |
| 236 | // This class does the hard work to support calibrating multiple IMU's |
| 237 | // orientations relative to one another. It is set up to readily be used with a |
| 238 | // ceres solver (see imu_calibrator.*). |
| 239 | // |
| 240 | // The current theory of operation is to have some number of imus, one of which |
| 241 | // we will consider to be fixed in position. We have a fixed set of data that we |
| 242 | // feed into this class, which can be parameterized based on: |
| 243 | // * The current zeroes for each IMU. |
| 244 | // * The orientation of each non-fixed IMU. |
| 245 | // * The time-offset of each non-fixed IMU. |
| 246 | // |
| 247 | // When run under ceres, ceres can then vary these parameters to solve for the |
| 248 | // current calibrations of the IMUs. |
| 249 | // |
| 250 | // When solving, the main complexity is that some internal state has to be |
| 251 | // tracked to try to determine when we should be calculating zeroes and when we |
| 252 | // can calibrate out the magnitude of gravity. This is done by tracking when the |
| 253 | // IMUs are "stationary". For a reading to be stationary, all values with |
| 254 | // FLAGS_imu_zeroing_buffer readings of the reading must be "plausibly |
| 255 | // stationary". Readings are plausibly stationary if they have sufficiently low |
| 256 | // gyro values. |
| 257 | // |
| 258 | // TODO: Provide some utilities to plot the results of a calibration. |
| 259 | template <typename Scalar> |
| 260 | class ImuCalibrator { |
| 261 | public: |
| 262 | ImuCalibrator(const std::vector<ImuConfig<Scalar>> &imu_configs) |
| 263 | : imu_configs_(imu_configs), imu_readings_(imu_configs.size()) { |
| 264 | origin_index_ = -1; |
| 265 | for (size_t imu_index = 0; imu_index < imu_configs_.size(); ++imu_index) { |
| 266 | if (imu_configs_[imu_index].is_origin) { |
| 267 | CHECK_EQ(origin_index_, -1) |
| 268 | << ": Can't have more than one IMU specified as the origin."; |
| 269 | origin_index_ = imu_index; |
| 270 | } |
| 271 | } |
| 272 | CHECK_NE(origin_index_, -1) |
| 273 | << ": Must have at least one IMU specified as the origin."; |
| 274 | } |
| 275 | |
| 276 | // These gyro readings will be "raw"---i.e., they still need to get |
| 277 | // transformed by nominal_transform. |
| 278 | // gyro readings are in radians / sec; accelerometer readings are in g's. |
| 279 | // Within a given imu, must be called in time order. |
| 280 | void InsertImu(size_t imu_index, const RawImuReading &reading); |
| 281 | |
| 282 | // Populates all the residuals that we use for the cost in ceres. |
| 283 | void CalculateResiduals(std::span<Scalar> residuals); |
| 284 | |
| 285 | // Returns the total number of residuals that this problem will have, given |
| 286 | // the number of readings for each IMU. |
| 287 | static size_t CalculateNumResiduals(const std::vector<size_t> &num_readings); |
| 288 | |
| 289 | private: |
| 290 | // Represents an imu reading. The values in this are generally already |
| 291 | // adjusted for the provided parameters. |
| 292 | struct ImuReading { |
| 293 | // The "actual" provided capture time. Used for debugging. |
| 294 | aos::monotonic_clock::time_point capture_time_raw; |
| 295 | // The capture time, adjusted for this IMU's time offset. |
| 296 | Scalar capture_time_adjusted; |
| 297 | // gyro reading, adjusted for gyro zero but NOT rotation. |
| 298 | // In radians / sec. |
| 299 | Eigen::Matrix<Scalar, 3, 1> gyro; |
| 300 | // accelerometer reading, adjusted for gravity value but NOT rotation. |
| 301 | // In g's. |
| 302 | Eigen::Matrix<Scalar, 3, 1> accel; |
| 303 | // Residuals associated with the DynamicImuParameters for this imu. |
| 304 | DynamicImuParameters<Scalar> parameter_residuals; |
| 305 | // Set if this measurement *could* be part of a segment of time where the |
| 306 | // IMU is stationary. |
| 307 | bool plausibly_stationary; |
| 308 | // Set to true if all values with FLAGS_imu_zeroing_buffer of this reading |
| 309 | // are plausibly_stationary. |
| 310 | bool stationary; |
| 311 | // We set stationary_is_locked once we have enough readings to either side |
| 312 | // of this value to guarantee that it is stationary. |
| 313 | bool stationary_is_locked; |
| 314 | // Residuals that are used for calibrating the rotation values. These are |
| 315 | // nullopt if we can't calibrate for some reason (e.g., this is the origin |
| 316 | // IMU, or for the accelerometer residual, we don't populate it if we are |
| 317 | // not stationary). |
| 318 | std::optional<Eigen::Matrix<Scalar, 3, 1>> gyro_residual; |
| 319 | std::optional<Eigen::Matrix<Scalar, 3, 1>> accel_residual; |
| 320 | }; |
| 321 | void EvaluateRelativeResiduals(); |
| 322 | |
| 323 | const std::vector<ImuConfig<Scalar>> imu_configs_; |
| 324 | // Index of the IMU which is the origin IMU. |
| 325 | int origin_index_; |
| 326 | std::vector<std::vector<ImuReading>> imu_readings_; |
| 327 | }; |
| 328 | |
| 329 | } // namespace frc971::imu |
| 330 | |
| 331 | #include "frc971/imu/imu_calibrator-tmpl.h" |
| 332 | #endif // FRC971_IMU_IMU_CALIBRATOR_H_ |