/* @file KalmanFilter.test.cpp */ #include "xo/kalmanfilter/KalmanFilter.hpp" #include "xo/kalmanfilter/KalmanFilterEngine.hpp" #include "xo/kalmanfilter/print_eigen.hpp" #include "statistics/SampleStatistics.hpp" #include "xo/randomgen/normalgen.hpp" #include "xo/randomgen/xoshiro256.hpp" #include "xo/indentlog/scope.hpp" #include "xo/indentlog/log_level.hpp" #include #include namespace xo { using xo::kalman::KalmanFilterSpec; using xo::kalman::KalmanFilterStep; using xo::kalman::KalmanFilterEngine; using xo::kalman::KalmanFilterStateExt; using xo::kalman::KalmanFilterState; using xo::kalman::KalmanFilterTransition; using xo::kalman::KalmanFilterObservable; using xo::kalman::KalmanFilterInput; using xo::statistics::SampleStatistics; using xo::rng::normalgen; using xo::rng::xoshiro256ss; using xo::time::timeutil; using xo::time::utc_nanos; using xo::time::seconds; using xo::ref::rp; using xo::log_level; using logutil::matrix; using xo::print::ccs; using Eigen::MatrixXd; using Eigen::VectorXd; namespace ut { namespace { /* step for kalman filter with: * - single state variable x[0] * - identity process model - x(k+1) = F(k).x(k), with F(k) = | 1 | * - no process noise * - single observation z[0] * - identity coupling matrix - z(k) = H(k).x(k) + w(k), with H(k) = | 1 | */ KalmanFilterSpec::MkStepFn kalman_identity1_mkstep_fn() { /* kalman state transition matrix: use identity <--> state is constant */ MatrixXd F = MatrixXd::Identity(1, 1); /* state transition noise: set this to zero; * measuring something that's known to be constant */ MatrixXd Q = MatrixXd::Zero(1, 1); /* single direct observation */ MatrixXd H = MatrixXd::Identity(1, 1); /* observation errors understood to have * mean 0, sdev 1 * * This is consistent with normal_rng below, * so R is correctly specified */ MatrixXd R = MatrixXd::Identity(1, 1); return [F, Q, H, R](rp const & sk, rp const & zkp1) { KalmanFilterTransition Fk(F, Q); KalmanFilterObservable Hk = KalmanFilterObservable::keep_all(H, R); return KalmanFilterStep(sk, Fk, Hk, zkp1); }; } /*kalman_identity1_mkstep_fn*/ } /*namespace*/ /* example 1. * repeated direct observation of a scalar * use rng to generate observations */ TEST_CASE("kalman-identity", "[kalmanfilter]") { /* setting up trivial filter for repeated indept * measurements of a constant. * * True value of unknown set to 10, * utest observes filter converging toward that value */ /* seed for rng */ uint64_t seed = 14950319842636922572UL; /* N(0,1) random numbers */ auto normal_rng = (normalgen::make (seed, std::normal_distribution(0.0 /*mean*/, 1.0 /*sdev*/))); /* accumulate statistics on 'measurements', * use as reference implementation for filter */ SampleStatistics z_stats; utc_nanos t0 = timeutil::ymd_midnight(20220707); /* estimate x(0) = [0] */ VectorXd x0(1); x0 << 10.0 + normal_rng(); INFO(tostr("x0=", x0)); z_stats.include_sample(x0[0]); /* kalman prior : Variance = 1, sdev = 1 */ MatrixXd P0 = 1.0 * MatrixXd::Identity(1, 1); /* F, Q, K, j, zk not used for initial state */ rp s0 = KalmanFilterStateExt::make(0 /*step#*/, t0, x0, P0, KalmanFilterTransition(MatrixXd::Zero(1, 1) /*F*/, MatrixXd::Zero(1, 1) /*Q*/), MatrixXd::Zero(1, 1) /*K*/, -1 /*j*/, nullptr /*zk*/); auto mk_step_fn = kalman_identity1_mkstep_fn(); KalmanFilterSpec spec(s0, mk_step_fn); rp sk = spec.start_ext(); for(uint32_t i_step = 1; i_step < 100; ++i_step) { /* note: for this filter, measurement time doesn't matter */ utc_nanos tkp1 = sk->tm() + seconds(1); VectorXd z(1); z << 10.0 + normal_rng(); INFO(tostr("z=", z)); z_stats.include_sample(z[0]); ref::rp inputk = KalmanFilterInput::make_present(tkp1, z); KalmanFilterStep step_spec = spec.make_step(sk, inputk); rp skp1 = KalmanFilterEngine::step(step_spec); REQUIRE(skp1->step_no() == i_step); REQUIRE(skp1->tm() == tkp1); REQUIRE(skp1->n_state() == 1); REQUIRE(skp1->state_v().size() == 1); REQUIRE(skp1->state_v()[0] == Approx(z_stats.mean()).epsilon(1e-6)); REQUIRE(skp1->state_cov().rows() == 1); REQUIRE(skp1->state_cov().cols() == 1); REQUIRE(skp1->gain().rows() == 1); REQUIRE(skp1->gain().cols() == 1); REQUIRE(skp1->observable() == -1); /* z_stats reflects k = z_stats.n_sample() N(0,1) 'random' vars; * variance of sum (i.e. z_stats.mean() * k) is proportional to k; * variance of mean like 1/k * * kalman filter also should compute covariance estimate like 1/k */ REQUIRE(skp1->state_cov()(0, 0) == Approx(1.0 / z_stats.n_sample()).epsilon(1e-6)); REQUIRE(skp1->gain()(0, 0) == Approx(1.0 / z_stats.n_sample()).epsilon(1e-6)); /* estimate at each step should be (approximately) * average of measurements taken so far. * approximate because also affected by prior */ sk = skp1; } REQUIRE(sk->state_v()[0] == Approx(10.0).epsilon(1e-2)); REQUIRE(sk->state_cov()(0, 0) == Approx(0.01).epsilon(1e-6)); REQUIRE(sk->gain()(0, 0) == Approx(0.01).epsilon(1e-6)); } /*TEST_CASE(kalman-identity)*/ /* example 2. * like example 1, but using "separate observation" variants: * KalmanGain::correct1() // per observation * instead of * KalmanGain::correct() // per observation set */ TEST_CASE("kalman-identity1", "[kalmanfilter]") { /* setting up trivial filter for repeated indept * measurements of a constant. * * True value of unknown set to 10, * utest observes filter converging toward that value * */ /* seed for rng */ uint64_t seed = 14950319842636922572UL; /* N(0,1) random numbers */ auto normal_rng = (normalgen::make (seed, std::normal_distribution(0.0 /*mean*/, 1.0 /*sdev*/))); /* accumulate statistics on 'measurements', * use as reference implementation for filter */ SampleStatistics z_stats; utc_nanos t0 = timeutil::ymd_midnight(20220707); /* estimate x(0) = [0] */ VectorXd x0(1); x0 << 10.0 + normal_rng(); INFO(tostr("x0=", x0)); z_stats.include_sample(x0[0]); /* kalman prior : Variance = 1, sdev = 1 */ MatrixXd P0 = 1.0 * MatrixXd::Identity(1, 1); rp s0 = KalmanFilterStateExt::make(0 /*step#*/, t0, x0, P0, KalmanFilterTransition(MatrixXd::Zero(1, 1) /*F*/, MatrixXd::Zero(1, 1) /*Q*/), MatrixXd::Zero(1, 1) /*K*/, -1, nullptr /*zk*/); auto mk_step_fn = kalman_identity1_mkstep_fn(); KalmanFilterSpec spec(s0, mk_step_fn); rp sk = spec.start_ext(); for(uint32_t i_step = 1; i_step < 100; ++i_step) { /* note: for this filter, measurement time doesn't matter */ utc_nanos tkp1 = sk->tm() + seconds(1); VectorXd z(1); z << 10.0 + normal_rng(); INFO(tostr("z=", z)); z_stats.include_sample(z[0]); ref::rp inputk = KalmanFilterInput::make_present(tkp1, z); KalmanFilterStep step_spec = spec.make_step(sk, inputk); rp skp1 = KalmanFilterEngine::step1(step_spec, 0 /*j*/); REQUIRE(skp1->step_no() == i_step); REQUIRE(skp1->tm() == tkp1); REQUIRE(skp1->n_state() == 1); REQUIRE(skp1->state_v().size() == 1); REQUIRE(skp1->state_v()[0] == Approx(z_stats.mean()).epsilon(1e-6)); REQUIRE(skp1->state_cov().rows() == 1); REQUIRE(skp1->state_cov().cols() == 1); REQUIRE(skp1->gain().rows() == 1); REQUIRE(skp1->gain().cols() == 1); REQUIRE(skp1->observable() == 0); /* z_stats reflects k = z_stats.n_sample() N(0,1) 'random' vars; * variance of sum (i.e. z_stats.mean() * k) is proportional to k; * variance of mean like 1/k * * kalman filter also should compute covariance estimate like 1/k */ REQUIRE(skp1->state_cov()(0, 0) == Approx(1.0 / z_stats.n_sample()).epsilon(1e-6)); REQUIRE(skp1->gain()(0, 0) == Approx(1.0 / z_stats.n_sample()).epsilon(1e-6)); /* estimate at each step should be (approximately) * average of measurements taken so far. * approximate because also affected by prior */ sk = skp1; } REQUIRE(sk->state_v()[0] == Approx(10.0).epsilon(1e-2)); REQUIRE(sk->state_cov()(0, 0) == Approx(0.01).epsilon(1e-6)); REQUIRE(sk->gain()(0, 0) == Approx(0.01).epsilon(1e-6)); } /*TEST_CASE(kalman-identity1)*/ namespace { /* step for kalman filter with: * - single state variable x[0] * - identity process model: x(k+1) = F(k).x(k), with F(k) = | 1 | * - no process noise * - two observations z[0], z[1] * - identity coupling matrix: z(k) = H(k).x(k) + w(k), with * H(k) = | 1 | * | 1 | * * w(k) = | w1 | with w1 ~ N(0,1) * | w2 | */ KalmanFilterSpec::MkStepFn kalman_identity2_mkstep_fn() { /* kalman state transition matrix: use identity <-> state is constant */ MatrixXd F = MatrixXd::Identity(1, 1); /* state transition noise: set to 0 */ MatrixXd Q = MatrixXd::Zero(1, 1); /* two direct observations */ MatrixXd H = MatrixXd::Constant(2 /*#rows*/, 1 /*#cols*/, 1.0 /*M(i,j)*/); /* observation errors: N(0,1) */ MatrixXd R = MatrixXd::Identity(2, 2); return [F, Q, H, R](rp const & sk, rp const & zkp1) { KalmanFilterTransition Fk(F, Q); KalmanFilterObservable Hk = KalmanFilterObservable::keep_all(H, R); return KalmanFilterStep(sk, Fk, Hk, zkp1); }; } /*kalman_identity2_mkstep_fn*/ } /*namespace*/ TEST_CASE("kalman-identity2", "[kalmanfilter]") { /* variation on filter in kalman-identity1 utest above; * this time make 2 observations per step */ /* seed for rng */ uint64_t seed = 14950319842636922572UL; /* N(0,1) random numbers */ auto normal_rng = (normalgen::make (seed, std::normal_distribution(0.0 /*mean*/, 1.0 /*sdev*/))); /* accumulate statistics on 'measurements', * use as reference implementation for filter */ SampleStatistics z_stats; utc_nanos t0 = timeutil::ymd_midnight(20220707); /* estimate x(0) = [0] */ VectorXd x0(1); x0 << 10.0 + normal_rng(); INFO(tostr("x0=", x0)); z_stats.include_sample(x0[0]); /* kalman prior : Variance = 1, sdev = 1 */ MatrixXd P0 = 1.0 * MatrixXd::Identity(1, 1); rp s0 = KalmanFilterStateExt::make(0 /*step#*/, t0, x0, P0, KalmanFilterTransition(MatrixXd::Zero(1, 1) /*F*/, MatrixXd::Zero(1, 1) /*Q*/), MatrixXd::Zero(1, 1) /*K*/, -1 /*j*/, nullptr /*zk*/); auto mk_step_fn = kalman_identity2_mkstep_fn(); KalmanFilterSpec spec(s0, mk_step_fn); rp sk = spec.start_ext(); /* need 1/2 as many filter steps to reach same confidence * as in test "kalman-identity" */ for(uint32_t i_step = 1; i_step < 51; ++i_step) { INFO(tostr(xtag("i_step", i_step))); /* note: for this filter, measurement time doesn't affect behavior */ utc_nanos tkp1 = sk->tm() + seconds(1); VectorXd z(2); z << 10.0 + normal_rng(), 10.0 + normal_rng(); z_stats.include_sample(z[0]); z_stats.include_sample(z[1]); INFO(tostr(xtag("i_step", i_step), xtag("z", z))); ref::rp inputk = KalmanFilterInput::make_present(tkp1, z); KalmanFilterStep step_spec = spec.make_step(sk, inputk); rp skp1 = KalmanFilterEngine::step(step_spec); REQUIRE(skp1->step_no() == i_step); REQUIRE(skp1->tm() == tkp1); REQUIRE(skp1->n_state() == 1); REQUIRE(skp1->state_v().size() == 1); REQUIRE(skp1->state_v()[0] == Approx(z_stats.mean()).epsilon(1e-6)); REQUIRE(skp1->state_cov().rows() == 1); REQUIRE(skp1->state_cov().cols() == 1); REQUIRE(skp1->gain().rows() == 1); REQUIRE(skp1->gain().cols() == 2); REQUIRE(skp1->observable() == -1); /* z_stats reflects 2*k = z_stats.n_sample() N(0,1) 'random' vars * (since 2 vars per step) * variance of sum (i.e. z_stats.mean() * k) is proportional to k; * variance of mean like 1/k * * kalman filter also should compute covariance estimate like 1/k * */ REQUIRE(skp1->state_cov()(0, 0) == Approx(1.0 / z_stats.n_sample()).epsilon(1e-6)); REQUIRE(skp1->gain()(0, 0) == Approx(1.0 / z_stats.n_sample()).epsilon(1e-6)); REQUIRE(skp1->gain()(0, 1) == Approx(1.0 / z_stats.n_sample()).epsilon(1e-6)); /* estimate at each step should be (approximately) * average of measurements taken so far. * approximate because also affected by prior */ sk = skp1; } REQUIRE(sk->state_v()[0] == Approx(10.0).epsilon(1e-2)); REQUIRE(sk->state_cov()(0, 0) == Approx(1.0 / z_stats.n_sample()).epsilon(1e-3)); /* result is close but not identical, * because initial confidence P0 counts as one sample, * so have odd #of samples */ REQUIRE(sk->gain()(0, 0) == Approx(1.0 / z_stats.n_sample()).epsilon(1e-3)); REQUIRE(sk->gain()(0, 1) == Approx(1.0 / z_stats.n_sample()).epsilon(1e-3)); } /*TEST_CASE(kalman-identity2)*/ namespace { /* step for kalman filter with: * - two state variables x[0], x[1] * x[0] subject to random disturbances, reverts towards mean 1 * x[1] is 1 * - process model: x(k+1) = F(k).x(k) + v(k) with * F(k) = | 0.95 0.05 | v(k) = | v1 |, v ~ N(0, 0.25) * | 0 1 | | 0 | * - one observation z[0] * - coupling matrix: z(k) = H(k).x(k) + w(k), with * H(k) = | 1 0 | * * w(k) ~ N(0,1) */ KalmanFilterSpec::MkStepFn kalman_revert1_mkstep_fn() { /* kalman state transition matrix */ MatrixXd F(2,2); F << 0.95, 0.05, 0, 1; /* state transition noise */ MatrixXd Q(2,2); Q << 0.0001, 0.0, 0.0, 0.0; /* coupling matrix */ MatrixXd H(1,2); H << 1.0, 0.0; /* observation errors */ MatrixXd R(1,1); R << 0.25; return [F, Q, H, R](rp const & sk, rp const & zkp1) { KalmanFilterTransition Fk(F, Q); KalmanFilterObservable Hk = KalmanFilterObservable::keep_all(H, R); return KalmanFilterStep(sk, Fk, Hk, zkp1); }; } /*kalman_revert1_mkstep_fn*/ } /*namespace*/ TEST_CASE("kalman-revert1", "[kalmanfilter]") { /* like test "kalman-identity", * but introduce some system noise. */ constexpr bool c_debug_enabled = false; scope lscope(XO_DEBUG2(c_debug_enabled, "TEST(kalman_revert)")); /* seed for rng */ uint64_t seed = 14950139742636922572UL; /* N(0,1) random numbers */ auto normal_rng = (normalgen::make (seed, std::normal_distribution(0.0 /*mean*/, 1.0 /*sdev*/))); /* accumulate statistics on observations, * use as reference when assessing filter behavior */ SampleStatistics z_stats; /* write output to file - use as baseline for regression testing */ std::string self_test_name = Catch::getResultCapture().getCurrentTestName(); /* write space-delimited output, suitable for gnuplot * omit always-constant values, rely on unit test verifying these */ std::ofstream out(self_test_name); out << "step z0 x0 P00 K0" << std::endl; utc_nanos t0 = timeutil::ymd_midnight(20220707); /* estimate x(0). * x(0)[1] is constant 1, used to achieve mean reversion */ VectorXd x0(2); x0 << 1.0 + normal_rng(), 1.0; z_stats.include_sample(x0[0]); /* kalman prior : Variance = 1, sdev = 1 */ MatrixXd P0(2,2); P0 << 1.0, 0.0, 0.0, 0.0; rp s0 = KalmanFilterStateExt::make(0 /*step#*/, t0, x0, P0, KalmanFilterTransition(MatrixXd::Zero(1, 1) /*F*/, MatrixXd::Zero(1, 1) /*Q*/), MatrixXd::Zero(1, 1) /*K*/, -1 /*j*/, nullptr /*zk*/); auto mk_step_fn = kalman_revert1_mkstep_fn(); KalmanFilterSpec spec(s0, mk_step_fn); rp sk = spec.start_ext(); for(uint32_t i_step = 1; i_step < 100; ++i_step) { /* note: for this filter, measurement time doesn't affect behavior */ utc_nanos tkp1 = sk->tm() + seconds(1); VectorXd z(1); z << 1.0 + normal_rng(); z_stats.include_sample(z[0]); ref::rp inputk = KalmanFilterInput::make_present(tkp1, z); KalmanFilterStep step_spec = spec.make_step(sk, inputk); rp skp1 = KalmanFilterEngine::step(step_spec); if (c_debug_enabled) { lscope.log("filter", xtag("step", i_step), xtag("z", matrix(z)), xtag("x", matrix(skp1->state_v())), xtag("P", matrix(skp1->state_cov())), xtag("K", matrix(skp1->gain()))); } /* headings: step z0 x0 P00 K0 */ out << i_step << " " << z(0) << " " << skp1->state_v()(0) << " " << skp1->state_cov()(0, 0) << " " << skp1->gain()(0, 0) << "\n"; REQUIRE(skp1->step_no() == i_step); REQUIRE(skp1->tm() == tkp1); REQUIRE(skp1->n_state() == 2); // REQUIRE(skp1->state_v().size() == 2); REQUIRE(skp1->state_v()(1) == 1.0); // REQUIRE(skp1->state_cov().rows() == 2); REQUIRE(skp1->state_cov().cols() == 2); // test skp1->state_cov()(0,0) vs baseline REQUIRE(skp1->state_cov()(0, 0) >= 0.0); REQUIRE(skp1->state_cov()(1, 0) == 0.0); REQUIRE(skp1->state_cov()(0, 1) == 0.0); REQUIRE(skp1->state_cov()(1, 1) == 0.0); // REQUIRE(skp1->gain().rows() == 2); REQUIRE(skp1->gain().cols() == 1); REQUIRE(skp1->gain()(0, 0) > 0.0); REQUIRE(skp1->gain()(1, 0) == 0.0); // REQUIRE(skp1->observable() == -1); // sk = skp1; } out << std::flush; out.close(); /* compare output with regression baseline. * command like: * diff kalman-revert1 utestdata/filter/kalman-revert1 */ char cmd_buf[256]; snprintf(cmd_buf, sizeof(cmd_buf), "diff %s utestdata/filter/%s\n", self_test_name.c_str(), self_test_name.c_str()); INFO(tostr(self_test_name, xtag("cmd", ccs(cmd_buf)))); std::int32_t err = ::system(cmd_buf); REQUIRE(err == 0); } /*TEST_CASE(kalman-drift)*/ #ifdef NOT_IN_USE namespace { /* step for kalman filter with: * - two state variables x[0], x[1] * - identity process model: x(k+1) = F(k).x(k), with * F(k) = | 1 0 | * | 0 1 | * - no process noise * - two observations z[0], z[1] * - simple coupling matrix: z(k) = H(k).x(k) + w(k), with * H(k) = | 1 0 | * | 0 -1 | * (so sign of z[1] is reversed w.r.t x[1]) * * w(k) = | w1 | with w1 ~ N(0,1) * | w2 | */ KalmanFilterSpec::MkStepFn kalman_identity2x2_mkstep_fn() { /* kalman state transition matrix: use identity <-> state is constant */ MatrixXd F = MatrixXd::Identity(2, 2); /* state transition noise: set to 0 */ MatrixXd Q = MatrixXd::Zero(2, 2); /* two direct observations */ MatrixXd H = MatrixXd::Constant(2 /*#rows*/, 1 /*#cols*/, 1.0 /*M(i,j)*/); /* observation errors: N(0,1) */ MatrixXd R = MatrixXd::Identity(2, 2); return [F, Q, H, R](KalmanFilterState const & sk, KalmanFilterInput const & zkp1) { KalmanFilterTransition Fk(F, Q); KalmanFilterObservable Hk(H, R); return KalmanFilterStep(sk, Fk, Hk, zkp1); }; } /*kalman_identity2_mkstep_fn*/ } /*namespace*/ #endif } /*namespace ut*/ } /*namespace xo*/ /* end KalmanFilter.test.cpp */