xo-expression2/utest/KalmanFilter.test.cpp

687 lines
26 KiB
C++

/* @file KalmanFilter.test.cpp */
#include "xo/kalmanfilter/KalmanFilter.hpp"
#include "xo/kalmanfilter/KalmanFilterEngine.hpp"
#include "xo/kalmanfilter/print_eigen.hpp"
#include "xo/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 <catch2/catch.hpp>
#include <fstream>
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::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<KalmanFilterState> const & sk,
rp<KalmanFilterInput> 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<xoshiro256ss>::make
(seed,
std::normal_distribution<double>(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<KalmanFilterStateExt> 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<KalmanFilterStateExt> 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]);
rp<KalmanFilterInput> inputk
= KalmanFilterInput::make_present(tkp1, z);
KalmanFilterStep step_spec = spec.make_step(sk, inputk);
rp<KalmanFilterStateExt> 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<xoshiro256ss>::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<KalmanFilterStateExt> 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<KalmanFilterStateExt> 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]);
rp<KalmanFilterInput> inputk
= KalmanFilterInput::make_present(tkp1, z);
KalmanFilterStep step_spec
= spec.make_step(sk, inputk);
rp<KalmanFilterStateExt> 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<KalmanFilterState> const & sk,
rp<KalmanFilterInput> 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<xoshiro256ss>::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<KalmanFilterStateExt> 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<KalmanFilterStateExt> 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)));
rp<KalmanFilterInput> inputk
= KalmanFilterInput::make_present(tkp1, z);
KalmanFilterStep step_spec = spec.make_step(sk, inputk);
rp<KalmanFilterStateExt> 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<KalmanFilterState> const & sk,
rp<KalmanFilterInput> 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<xoshiro256ss>::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<KalmanFilterStateExt> 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<KalmanFilterStateExt> 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]);
rp<KalmanFilterInput> inputk
= KalmanFilterInput::make_present(tkp1, z);
KalmanFilterStep step_spec = spec.make_step(sk, inputk);
rp<KalmanFilterStateExt> 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 */