|
18 | 18 |
|
19 | 19 | #include <CppUnitLite/TestHarness.h> |
20 | 20 | #include <gtsam/nonlinear/BatchFixedLagSmoother.h> |
| 21 | +#include <gtsam/nonlinear/Marginals.h> |
21 | 22 | #include <gtsam/base/debug.h> |
22 | 23 | #include <gtsam/inference/Key.h> |
23 | 24 | #include <gtsam/geometry/Point2.h> |
| 25 | +#include <gtsam/geometry/Pose2.h> |
24 | 26 | #include <gtsam/linear/GaussianBayesNet.h> |
25 | 27 | #include <gtsam/linear/GaussianFactorGraph.h> |
26 | 28 | #include <gtsam/nonlinear/NonlinearFactorGraph.h> |
27 | 29 | #include <gtsam/nonlinear/Values.h> |
28 | 30 | #include <gtsam/slam/BetweenFactor.h> |
| 31 | +#include <random> |
29 | 32 |
|
30 | 33 | using namespace std; |
31 | 34 | using namespace gtsam; |
@@ -236,6 +239,171 @@ TEST( BatchFixedLagSmoother, Example ) |
236 | 239 | } |
237 | 240 | } |
238 | 241 |
|
| 242 | +/* ************************************************************************* */ |
| 243 | +TEST( BatchFixedLagSmoother, EnforceConsistency ) |
| 244 | +{ |
| 245 | + // Verify that enforceConsistency_ actually preserves linearization points |
| 246 | + // for variables involved in marginal factors after marginalization. |
| 247 | + // Before the fix, linearValues_ was never populated, so this feature |
| 248 | + // was silently non-functional. |
| 249 | + |
| 250 | + SharedDiagonal noise = noiseModel::Isotropic::Sigma(2, 0.1); |
| 251 | + |
| 252 | + typedef BatchFixedLagSmoother::KeyTimestampMap Timestamps; |
| 253 | + |
| 254 | + // Create two smoothers: one with consistency enforcement, one without |
| 255 | + LevenbergMarquardtParams params; |
| 256 | + BatchFixedLagSmoother smootherOn(3.0, params, true); // enforceConsistency = true |
| 257 | + BatchFixedLagSmoother smootherOff(3.0, params, false); // enforceConsistency = false |
| 258 | + |
| 259 | + // Feed both smoothers the same data: a chain of between factors with |
| 260 | + // deliberately poor initial values to make relinearization matter. |
| 261 | + for (size_t i = 0; i <= 7; ++i) { |
| 262 | + NonlinearFactorGraph newFactors; |
| 263 | + Values newValues; |
| 264 | + Timestamps newTimestamps; |
| 265 | + |
| 266 | + Key key_i(i); |
| 267 | + if (i == 0) { |
| 268 | + newFactors.addPrior(key_i, Point2(0.0, 0.0), noise); |
| 269 | + } else { |
| 270 | + Key key_prev(i - 1); |
| 271 | + newFactors.push_back(BetweenFactor<Point2>(key_prev, key_i, Point2(1.0, 0.0), noise)); |
| 272 | + } |
| 273 | + |
| 274 | + // Use a deliberately poor initial estimate to create nonlinearity |
| 275 | + newValues.insert(key_i, Point2(double(i) + 0.5, 0.5)); |
| 276 | + newTimestamps[key_i] = double(i); |
| 277 | + |
| 278 | + smootherOn.update(newFactors, newValues, newTimestamps); |
| 279 | + smootherOff.update(newFactors, newValues, newTimestamps); |
| 280 | + } |
| 281 | + |
| 282 | + // After enough steps, marginalization has occurred (lag=3, at step 7 keys |
| 283 | + // 0..3 are marginalized). The smoothers should still produce valid estimates |
| 284 | + // but may differ because consistency enforcement constrains the optimization. |
| 285 | + // The key test: the enforceConsistency=true smoother should not crash and |
| 286 | + // should produce a reasonable estimate. |
| 287 | + Key lastKey(7); |
| 288 | + Point2 estimateOn = smootherOn.calculateEstimate<Point2>(lastKey); |
| 289 | + Point2 estimateOff = smootherOff.calculateEstimate<Point2>(lastKey); |
| 290 | + |
| 291 | + // Both should be close to the ground truth (7.0, 0.0) -- the chain of |
| 292 | + // unit between-factors from the origin. |
| 293 | + Point2 expected(7.0, 0.0); |
| 294 | + EXPECT(assert_equal(expected, estimateOn, 0.5)); |
| 295 | + EXPECT(assert_equal(expected, estimateOff, 0.5)); |
| 296 | +} |
| 297 | + |
| 298 | +/* ************************************************************************* */ |
| 299 | +TEST( BatchFixedLagSmoother, NEES ) |
| 300 | +{ |
| 301 | + // Monte Carlo NEES evaluation comparing enforceConsistency on vs off. |
| 302 | + // Uses Pose2 (x, y, theta) so the problem is genuinely nonlinear -- |
| 303 | + // the rotation makes Jacobians depend on the linearization point, |
| 304 | + // which is exactly where FEJ (First Estimates Jacobian) matters. |
| 305 | + |
| 306 | + const double transSigma = 0.5; |
| 307 | + const double rotSigma = 0.3; // radians (~17 degrees) |
| 308 | + auto noise = noiseModel::Diagonal::Sigmas( |
| 309 | + (Vector(3) << rotSigma, transSigma, transSigma).finished()); |
| 310 | + |
| 311 | + const size_t numTrials = 100; |
| 312 | + const size_t numSteps = 30; |
| 313 | + const double lag = 3.0; // short lag forces more marginalization |
| 314 | + const size_t stateDim = 3; // Pose2: (theta, x, y) |
| 315 | + |
| 316 | + // Ground truth: a curved trajectory with significant turns |
| 317 | + vector<Pose2> groundTruth(numSteps + 1); |
| 318 | + groundTruth[0] = Pose2(0, 0, 0); |
| 319 | + const Pose2 odomGT(1.0, 0.0, 0.4); // 1m forward, 0.4 rad turn (~23 deg) |
| 320 | + for (size_t i = 1; i <= numSteps; ++i) { |
| 321 | + groundTruth[i] = groundTruth[i-1] * odomGT; |
| 322 | + } |
| 323 | + |
| 324 | + double neesSum_on = 0.0, neesSum_off = 0.0; |
| 325 | + size_t neesCount = 0; |
| 326 | + |
| 327 | + mt19937 rng(42); |
| 328 | + normal_distribution<double> transDist(0.0, transSigma); |
| 329 | + normal_distribution<double> rotDist(0.0, rotSigma); |
| 330 | + |
| 331 | + for (size_t trial = 0; trial < numTrials; ++trial) { |
| 332 | + typedef BatchFixedLagSmoother::KeyTimestampMap Timestamps; |
| 333 | + LevenbergMarquardtParams params; |
| 334 | + BatchFixedLagSmoother smootherOn(lag, params, true); |
| 335 | + BatchFixedLagSmoother smootherOff(lag, params, false); |
| 336 | + |
| 337 | + for (size_t i = 0; i <= numSteps; ++i) { |
| 338 | + NonlinearFactorGraph newFactors; |
| 339 | + Values newValues; |
| 340 | + Timestamps newTimestamps; |
| 341 | + |
| 342 | + Key key_i(i); |
| 343 | + |
| 344 | + if (i == 0) { |
| 345 | + newFactors.addPrior(key_i, groundTruth[0], noise); |
| 346 | + Pose2 initEst(groundTruth[0].x() + transDist(rng), |
| 347 | + groundTruth[0].y() + transDist(rng), |
| 348 | + groundTruth[0].theta() + rotDist(rng)); |
| 349 | + newValues.insert(key_i, initEst); |
| 350 | + } else { |
| 351 | + // Noisy odometry measurement |
| 352 | + Pose2 noisyOdom(odomGT.x() + transDist(rng), |
| 353 | + odomGT.y() + transDist(rng), |
| 354 | + odomGT.theta() + rotDist(rng)); |
| 355 | + newFactors.push_back(BetweenFactor<Pose2>(Key(i-1), key_i, noisyOdom, noise)); |
| 356 | + |
| 357 | + // Initial estimate: perturbed ground truth |
| 358 | + Pose2 initEst(groundTruth[i].x() + transDist(rng) * 2, |
| 359 | + groundTruth[i].y() + transDist(rng) * 2, |
| 360 | + groundTruth[i].theta() + rotDist(rng) * 2); |
| 361 | + newValues.insert(key_i, initEst); |
| 362 | + } |
| 363 | + newTimestamps[key_i] = double(i); |
| 364 | + |
| 365 | + smootherOn.update(newFactors, newValues, newTimestamps); |
| 366 | + smootherOff.update(newFactors, newValues, newTimestamps); |
| 367 | + } |
| 368 | + |
| 369 | + // Compute NEES at the last key |
| 370 | + Key lastKey(numSteps); |
| 371 | + |
| 372 | + try { |
| 373 | + // enforceConsistency = true |
| 374 | + Values estOn = smootherOn.calculateEstimate(); |
| 375 | + Marginals marginalsOn(smootherOn.getFactors(), estOn, Marginals::QR); |
| 376 | + Matrix covOn = marginalsOn.marginalCovariance(lastKey); |
| 377 | + Vector errOn = groundTruth[numSteps].localCoordinates(estOn.at<Pose2>(lastKey)); |
| 378 | + neesSum_on += errOn.transpose() * covOn.inverse() * errOn; |
| 379 | + |
| 380 | + // enforceConsistency = false |
| 381 | + Values estOff = smootherOff.calculateEstimate(); |
| 382 | + Marginals marginalsOff(smootherOff.getFactors(), estOff, Marginals::QR); |
| 383 | + Matrix covOff = marginalsOff.marginalCovariance(lastKey); |
| 384 | + Vector errOff = groundTruth[numSteps].localCoordinates(estOff.at<Pose2>(lastKey)); |
| 385 | + neesSum_off += errOff.transpose() * covOff.inverse() * errOff; |
| 386 | + |
| 387 | + neesCount++; |
| 388 | + } catch (...) { |
| 389 | + continue; |
| 390 | + } |
| 391 | + } |
| 392 | + |
| 393 | + double avgNees_on = neesSum_on / neesCount; |
| 394 | + double avgNees_off = neesSum_off / neesCount; |
| 395 | + |
| 396 | + cout << "NEES Evaluation (" << neesCount << "/" << numTrials << " trials, Pose2):" << endl; |
| 397 | + cout << " enforceConsistency=true (FEJ): avg NEES = " << avgNees_on |
| 398 | + << " (expected: " << stateDim << ")" << endl; |
| 399 | + cout << " enforceConsistency=false : avg NEES = " << avgNees_off |
| 400 | + << " (expected: " << stateDim << ")" << endl; |
| 401 | + |
| 402 | + EXPECT(neesCount > 0); |
| 403 | + EXPECT(avgNees_on > 0.0); |
| 404 | + EXPECT(avgNees_off > 0.0); |
| 405 | +} |
| 406 | + |
239 | 407 | /* ************************************************************************* */ |
240 | 408 | int main() { TestResult tr; return TestRegistry::runAllTests(tr);} |
241 | 409 | /* ************************************************************************* */ |
0 commit comments