Files
tensor/signal/kalman_test.go
T

740 lines
28 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package signal
import (
"errors"
"math"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// scalarKFRun is the scalar Kalman recursion written out by hand, the
// closed-form reference the library's matrix machinery is pinned
// against: the same predict-and-correct cycle in plain arithmetic.
func scalarKFRun(z []float64, f, q, h, r, x0, p0 float64) (xs, ps, inns, ss []float64, logLik float64) {
x, p := x0, p0
xs = make([]float64, len(z))
ps = make([]float64, len(z))
inns = make([]float64, len(z))
ss = make([]float64, len(z))
for t, zt := range z {
xp := f * x
pp := f*p*f + q
s := h*pp*h + r
k := pp * h / s
inn := zt - h*xp
x = xp + k*inn
p = pp - k*s*k // the standard form: (1−k·h)·pp
xs[t], ps[t], inns[t], ss[t] = x, p, inn, s
logLik += -0.5 * (math.Log(2*math.Pi*s) + inn*inn/s)
}
return xs, ps, inns, ss, logLik
}
// cholPD reports whether a symmetric row-major block factors as
// positive definite.
func cholPD(a []float64, n int) bool {
l := make([]float64, n*n)
for i := range n {
for j := range i + 1 {
total := a[i*n+j]
for k := range j {
total -= l[i*n+k] * l[j*n+k]
}
if i == j {
if !(total > 0) {
return false
}
l[i*n+i] = math.Sqrt(total)
} else {
l[i*n+j] = total / l[j*n+j]
}
}
}
return true
}
// kalmanResultBlock reads one (rows × cols) block out of a result
// stack at step t.
func kalmanResultBlock(t *testing.T, a *core.Array, step, rows, cols int) []float64 {
t.Helper()
block := make([]float64, rows*cols)
for i := range rows * cols {
block[i] = a.FloatAt(step*rows*cols + i)
}
return block
}
// TestKalmanScalarSteadyState pins the Riccati recursion against the
// scalar steady state solved by hand: for x_{t+1} = x_t + w,
// z_t = x_t + v with unit variances the fixed point of p⁻ = p + 1,
// p = p⁻·r/(p⁻ + r) is the golden ratio, and the filtered covariance
// settles on its reciprocal.
func TestKalmanScalarSteadyState(t *testing.T) {
const n = 400
g := core.NewGenerator(3)
z := make([]float64, n)
for i := range z {
z[i] = g.NormalUnit()
}
f, err := KalmanFilter(mustFloats(t, z), mustFloats(t, []float64{1}, 1, 1), mustFloats(t, []float64{1}, 1, 1),
KalmanOptions{ProcessNoise: mustFloats(t, []float64{1}, 1, 1), MeasurementNoise: mustFloats(t, []float64{1}, 1, 1)})
if err != nil {
t.Fatalf("KalmanFilter: %v", err)
}
wantP := (math.Sqrt(5) - 1) / 2 // 1/φ
if got := f.Covariances.FloatAt(n - 1); math.Abs(got-wantP) > 1e-9 {
t.Fatalf("steady filtered covariance = %.12f, want %.12f", got, wantP)
}
// The state path must match the hand-run scalar recursion.
xs, _, _, _, _ := scalarKFRun(z, 1, 1, 1, 1, 0, 1)
if got, want := f.States.FloatAt(n-1), xs[n-1]; math.Abs(got-want) > 1e-9 {
t.Fatalf("steady filtered state = %.12f, hand recursion %.12f", got, want)
}
}
// TestKalmanAR1Reference pins the filtered mean of a scalar AR(1) plus
// noise against the closed-form recursion, and the accumulated
// log-likelihood against the direct sum of the Gaussian log densities
// of the innovations and innovation variances the result itself
// publishes.
func TestKalmanAR1Reference(t *testing.T) {
const (
phi = 0.7
q = 0.04
r = 0.25
n = 80
)
g := core.NewGenerator(5)
z := make([]float64, n)
for i := range z {
z[i] = g.NormalUnit()
}
f, err := KalmanFilter(mustFloats(t, z), mustFloats(t, []float64{phi}, 1, 1),
mustFloats(t, []float64{1}, 1, 1),
KalmanOptions{
ProcessNoise: mustFloats(t, []float64{q}, 1, 1),
MeasurementNoise: mustFloats(t, []float64{r}, 1, 1),
})
if err != nil {
t.Fatalf("KalmanFilter: %v", err)
}
xs, ps, inns, ss, wantLL := scalarKFRun(z, phi, q, 1, r, 0, 1)
for step := range n {
if got := f.States.FloatAt(step); math.Abs(got-xs[step]) > 1e-9 {
t.Fatalf("filtered mean at %d = %.12f, want %.12f", step, got, xs[step])
}
if got := f.Covariances.FloatAt(step); math.Abs(got-ps[step]) > 1e-9 {
t.Fatalf("filtered variance at %d = %.12f, want %.12f", step, got, ps[step])
}
if got := f.Innovations.FloatAt(step); math.Abs(got-inns[step]) > 1e-9 {
t.Fatalf("innovation at %d = %.12f, want %.12f", step, got, inns[step])
}
if got := f.InnovationCovariances.FloatAt(step); math.Abs(got-ss[step]) > 1e-9 {
t.Fatalf("innovation variance at %d = %.12f, want %.12f", step, got, ss[step])
}
}
if math.Abs(f.LogLikelihood-wantLL) > 1e-7 {
t.Fatalf("log-likelihood = %.10f, closed form %.10f", f.LogLikelihood, wantLL)
}
// The published likelihood must be the sum of the published
// innovations' own Gaussian log densities.
total := 0.0
for t := range n {
inn := f.Innovations.FloatAt(t)
s := f.InnovationCovariances.FloatAt(t)
total += -0.5 * (math.Log(2*math.Pi*s) + inn*inn/s)
}
if math.Abs(total-f.LogLikelihood) > 1e-9 {
t.Fatalf("log-likelihood %.12f is not the innovations' own sum %.12f", f.LogLikelihood, total)
}
}
// TestKalmanMatchesUKFLinear runs the unscented filter on an exactly
// linear model: the sigma-point transforms are exact there, so the
// UKF must degenerate to the Kalman answer to rounding.
func TestKalmanMatchesUKFLinear(t *testing.T) {
const n = 120
fMat := mustFloats(t, []float64{1, 1, 0, 1}, 2, 2)
hMat := mustFloats(t, []float64{1, 0}, 1, 2)
opts := KalmanOptions{
InitialState: mustFloats(t, []float64{1, 0}),
InitialCovariance: mustFloats(t, []float64{4, 0, 0, 1}, 2, 2),
ProcessNoise: mustFloats(t, []float64{1e-4, 0, 0, 0.01}, 2, 2),
MeasurementNoise: mustFloats(t, []float64{0.25}, 1, 1),
}
g := core.NewGenerator(9)
z := make([]float64, n)
for i := range z {
z[i] = g.NormalUnit()
}
zArr := mustFloats(t, z)
kf, err := KalmanFilter(zArr, fMat, hMat, opts)
if err != nil {
t.Fatalf("KalmanFilter: %v", err)
}
linear := func(m *core.Array) StateFunc {
return func(x *core.Array) (*core.Array, error) {
rows := m.Len() / x.Len()
out := make([]float64, rows)
for i := range out {
total := 0.0
for j := range x.Len() {
total += m.FloatAt(i*x.Len()+j) * x.FloatAt(j)
}
out[i] = total
}
return core.FromFloats(out, len(out))
}
}
ukf, err := UnscentedKalmanFilter(zArr, linear(fMat), linear(hMat), opts)
if err != nil {
t.Fatalf("UnscentedKalmanFilter: %v", err)
}
for step := range n {
for i := range 2 {
got, want := ukf.States.FloatAt(step*2+i), kf.States.FloatAt(step*2+i)
if math.Abs(got-want) > 1e-8 {
t.Fatalf("UKF state (%d, %d) = %.10f against KF %.10f", step, i, got, want)
}
}
for i := range 4 {
got, want := ukf.Covariances.FloatAt(step*4+i), kf.Covariances.FloatAt(step*4+i)
if math.Abs(got-want) > 1e-8 {
t.Fatalf("UKF covariance (%d, %d) = %.10f against KF %.10f", step, i, got, want)
}
}
}
if math.Abs(ukf.LogLikelihood-kf.LogLikelihood) > 1e-6 {
t.Fatalf("UKF log-likelihood %.10f against KF %.10f", ukf.LogLikelihood, kf.LogLikelihood)
}
// The extended filter with central-difference Jacobians sees the
// same linear maps exactly, so it must land on the KF too.
ekf, err := ExtendedKalmanFilter(zArr, linear(fMat), linear(hMat), opts)
if err != nil {
t.Fatalf("ExtendedKalmanFilter: %v", err)
}
for step := range n {
for i := range 2 {
got, want := ekf.States.FloatAt(step*2+i), kf.States.FloatAt(step*2+i)
if math.Abs(got-want) > 1e-8 {
t.Fatalf("EKF state (%d, %d) = %.10f against KF %.10f", step, i, got, want)
}
}
}
}
// TestUnscentedQuadraticMoment pins the sigma-point transform on a
// genuinely nonlinear map: with x ~ N(0, 2) and f(x) = x², the exact
// predicted second moment is 2, and with α = 1, κ = 0 the symmetric
// sigma pair reproduces it exactly. The inert measurement noise keeps
// the correction from moving the answer.
func TestUnscentedQuadraticMoment(t *testing.T) {
square := func(x *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{x.FloatAt(0) * x.FloatAt(0)}, 1)
}
identity := func(x *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{x.FloatAt(0)}, 1)
}
f, err := UnscentedKalmanFilter(mustFloats(t, []float64{0}), square, identity, KalmanOptions{
InitialState: mustFloats(t, []float64{0}),
InitialCovariance: mustFloats(t, []float64{2}, 1, 1),
ProcessNoise: mustFloats(t, []float64{0}, 1, 1),
MeasurementNoise: mustFloats(t, []float64{1e18}, 1, 1),
SigmaAlpha: 1,
SigmaBeta: 2,
SigmaKappa: 0,
})
if err != nil {
t.Fatalf("UnscentedKalmanFilter: %v", err)
}
if got, want := f.States.FloatAt(0), 2.0; math.Abs(got-want) > 1e-9 {
t.Fatalf("predicted second moment = %.12f, want %.12f", got, want)
}
}
// TestExtendedKalmanBearingOnly tracks a constant-velocity target from
// bearing-only measurements, the classical case where the observation
// map is genuinely nonlinear and the posterior is not Gaussian. The
// honest pin is Monte Carlo: a bootstrap particle filter over the same
// model and data approximates the true posterior mean, and both the
// extended and the unscented filter must land within a few posterior
// standard deviations of it, and of each other.
func TestExtendedKalmanBearingOnly(t *testing.T) {
const (
n = 60
sigmaB = 0.005
truthX0 = 2000.0
truthY0 = 100.0
truthVX = -15.0
truthVY = 3.0
)
// Truth and bearings, through the house generator.
g := core.NewGenerator(101)
z := make([]float64, n)
for t := range n {
px := truthX0 + truthVX*float64(t+1)
py := truthY0 + truthVY*float64(t+1)
z[t] = math.Atan2(py, px) + sigmaB*g.NormalUnit()
}
cv := func(x *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{
x.FloatAt(0) + x.FloatAt(2),
x.FloatAt(1) + x.FloatAt(3),
x.FloatAt(2),
x.FloatAt(3),
}, 4)
}
bearing := func(x *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{math.Atan2(x.FloatAt(1), x.FloatAt(0))}, 1)
}
bearingJac := func(x *core.Array) (*core.Array, error) {
px, py := x.FloatAt(0), x.FloatAt(1)
r2 := px*px + py*py
return core.FromFloats([]float64{-py / r2, px / r2, 0, 0}, 1, 4)
}
opts := KalmanOptions{
InitialState: mustFloats(t, []float64{1900, 80, 0, 0}),
InitialCovariance: mustFloats(t, []float64{1e4, 0, 0, 0, 0, 1e4, 0, 0, 0, 0, 25, 0, 0, 0, 0, 25}, 4, 4),
ProcessNoise: mustFloats(t, []float64{1e-4, 0, 0, 0, 0, 1e-4, 0, 0, 0, 0, 0.01, 0, 0, 0, 0, 0.01}, 4, 4),
MeasurementNoise: mustFloats(t, []float64{sigmaB * sigmaB}, 1, 1),
ObservationJacobian: bearingJac,
}
zArr := mustFloats(t, z)
ekf, err := ExtendedKalmanFilter(zArr, cv, bearing, opts)
if err != nil {
t.Fatalf("ExtendedKalmanFilter: %v", err)
}
ukf, err := UnscentedKalmanFilter(zArr, cv, bearing, opts)
if err != nil {
t.Fatalf("UnscentedKalmanFilter: %v", err)
}
// The bootstrap particle filter: 20000 particles propagated
// through the same dynamics, weighted by the bearing likelihood,
// systematically resampled every step.
const particles = 20000
pg := core.NewGenerator(202)
px := make([]float64, particles)
py := make([]float64, particles)
vx := make([]float64, particles)
vy := make([]float64, particles)
weights := make([]float64, particles)
for i := range particles {
px[i] = 1900 + 100*pg.NormalUnit()
py[i] = 80 + 100*pg.NormalUnit()
vx[i] = 0 + 5*pg.NormalUnit()
vy[i] = 0 + 5*pg.NormalUnit()
}
var meanX, meanY, varX, varY float64
for t := range n {
for i := range particles {
px[i] += vx[i] + 0.01*pg.NormalUnit()
py[i] += vy[i] + 0.01*pg.NormalUnit()
vx[i] += 0.1 * pg.NormalUnit()
vy[i] += 0.1 * pg.NormalUnit()
}
// Log weights against this step's bearing.
maxLog := math.Inf(-1)
for i := range particles {
res := (z[t] - math.Atan2(py[i], px[i])) / sigmaB
weights[i] = -0.5 * res * res
maxLog = max(maxLog, weights[i])
}
total := 0.0
for i := range particles {
weights[i] = math.Exp(weights[i] - maxLog)
total += weights[i]
}
// The weighted posterior moments, recorded at the last step.
if t == n-1 {
for i := range particles {
w := weights[i] / total
meanX += w * px[i]
meanY += w * py[i]
}
for i := range particles {
w := weights[i] / total
varX += w * (px[i] - meanX) * (px[i] - meanX)
varY += w * (py[i] - meanY) * (py[i] - meanY)
}
}
// Systematic resampling: one uniform start, N evenly spaced
// pointers walked through the cumulative weights.
ancestorsX := make([]float64, particles)
ancestorsY := make([]float64, particles)
ancestorsVX := make([]float64, particles)
ancestorsVY := make([]float64, particles)
copy(ancestorsX, px)
copy(ancestorsY, py)
copy(ancestorsVX, vx)
copy(ancestorsVY, vy)
u := pg.Unit() / particles
cum := 0.0
j := 0
cum += weights[0] / total
for i := range particles {
pointer := u + float64(i)/particles
for cum < pointer && j < particles-1 {
j++
cum += weights[j] / total
}
px[i] = ancestorsX[j]
py[i] = ancestorsY[j]
vx[i] = ancestorsVX[j]
vy[i] = ancestorsVY[j]
}
}
// The EKF and UKF positions against the Monte Carlo posterior,
// with a tolerance of five posterior standard deviations.
for name, state := range map[string]float64{
"ekf x": ekf.States.FloatAt((n-1)*4 + 0),
"ekf y": ekf.States.FloatAt((n-1)*4 + 1),
"ukf x": ukf.States.FloatAt((n-1)*4 + 0),
"ukf y": ukf.States.FloatAt((n-1)*4 + 1),
} {
want, spread := meanX, 5*math.Sqrt(varX)
if strings.HasSuffix(name, "y") {
want, spread = meanY, 5*math.Sqrt(varY)
}
if math.Abs(state-want) > spread {
t.Fatalf("%s = %.3f, Monte Carlo posterior mean %.3f with sd %.3f",
name, state, want, math.Sqrt(spread*spread/25))
}
}
// The two filters agree with each other to well under a posterior
// standard deviation.
for i, name := range []string{"x", "y"} {
d := math.Abs(ekf.States.FloatAt((n-1)*4+i) - ukf.States.FloatAt((n-1)*4+i))
spread := math.Sqrt(varX)
if i == 1 {
spread = math.Sqrt(varY)
}
if d > spread {
t.Fatalf("EKF and UKF %s differ by %.3f, a posterior sd of %.3f", name, d, spread)
}
}
// Plausible posterior: the filtered position tracks the truth
// within four of its own standard deviations, and every published
// covariance stayed symmetric and positive definite.
cov := kalmanResultBlock(t, ekf.Covariances, n-1, 4, 4)
errX := math.Abs(ekf.States.FloatAt((n-1)*4+0) - (truthX0 + truthVX*float64(n)))
errY := math.Abs(ekf.States.FloatAt((n-1)*4+1) - (truthY0 + truthVY*float64(n)))
if errX > 4*math.Sqrt(cov[0]) || errY > 4*math.Sqrt(cov[5]) {
t.Fatalf("final position error (%.3f, %.3f) exceeds four filtered sds (%.3f, %.3f)",
errX, errY, 4*math.Sqrt(cov[0]), 4*math.Sqrt(cov[5]))
}
if !cholPD(cov, 4) {
t.Fatal("the final EKF covariance is not positive definite")
}
}
// TestKalmanMultiChannel pins the multi-channel path: two observed
// states with cross-coupled noise, against an independent two-by-two
// reference recursion written in the test.
func TestKalmanMultiChannel(t *testing.T) {
const n = 100
g := core.NewGenerator(13)
z := make([]float64, 2*n)
for i := range z {
z[i] = g.NormalUnit()
}
f, err := KalmanFilter(mustFloats(t, z, n, 2), mustFloats(t, []float64{1, 1, 0, 1}, 2, 2),
mustFloats(t, []float64{1, 0, 0, 1}, 2, 2),
KalmanOptions{
ProcessNoise: mustFloats(t, []float64{1e-4, 0, 0, 1e-2}, 2, 2),
MeasurementNoise: mustFloats(t, []float64{0.1, 0, 0, 0.4}, 2, 2),
})
if err != nil {
t.Fatalf("KalmanFilter: %v", err)
}
// The independent reference: the same recursion with a
// standard-form update and an explicit two-by-two solve.
x := []float64{0, 0}
p := []float64{1, 0, 0, 1}
logLik := 0.0
solve2 := func(s []float64, b []float64) []float64 {
det := s[0]*s[3] - s[1]*s[2]
return []float64{(s[3]*b[0] - s[1]*b[1]) / det, (s[0]*b[1] - s[2]*b[0]) / det}
}
// mat2 multiplies two row-major two-by-two matrices.
mat2 := func(x, y []float64) []float64 {
return []float64{
x[0]*y[0] + x[1]*y[2], x[0]*y[1] + x[1]*y[3],
x[2]*y[0] + x[3]*y[2], x[2]*y[1] + x[3]*y[3],
}
}
for step := range n {
xp := []float64{x[0] + x[1], x[1]}
pp := []float64{
p[0] + p[1] + p[2] + p[3] + 1e-4, p[1] + p[3],
p[2] + p[3], p[3] + 1e-2,
}
inn := []float64{z[2*step] - xp[0], z[2*step+1] - xp[1]}
s := []float64{pp[0] + 0.1, pp[1], pp[2], pp[3] + 0.4}
k := [][]float64{
solve2(s, []float64{pp[0], pp[2]}),
solve2(s, []float64{pp[1], pp[3]}),
}
solved := solve2(s, inn)
quad := inn[0]*solved[0] + inn[1]*solved[1]
det := s[0]*s[3] - s[1]*s[2]
logLik += -0.5 * (2*math.Log(2*math.Pi) + math.Log(det) + quad)
x = []float64{xp[0] + k[0][0]*inn[0] + k[0][1]*inn[1], xp[1] + k[1][0]*inn[0] + k[1][1]*inn[1]}
a := []float64{1 - k[0][0], -k[0][1], -k[1][0], 1 - k[1][1]}
at := []float64{a[0], a[2], a[1], a[3]}
kr := []float64{k[0][0] * 0.1, k[0][1] * 0.4, k[1][0] * 0.1, k[1][1] * 0.4}
kt := []float64{k[0][0], k[1][0], k[0][1], k[1][1]}
aph := mat2(a, pp)
joseph := mat2(aph, at)
krkt := mat2(kr, kt)
p = []float64{joseph[0] + krkt[0], joseph[1] + krkt[1], joseph[2] + krkt[2], joseph[3] + krkt[3]}
if got := f.States.FloatAt(2 * step); math.Abs(got-x[0]) > 1e-9 {
t.Fatalf("filtered mean 0 at %d = %.10f, want %.10f", step, got, x[0])
}
if got := f.States.FloatAt(2*step + 1); math.Abs(got-x[1]) > 1e-9 {
t.Fatalf("filtered mean 1 at %d = %.10f, want %.10f", step, got, x[1])
}
}
if math.Abs(f.LogLikelihood-logLik) > 1e-6 {
t.Fatalf("log-likelihood %.8f, reference %.8f", f.LogLikelihood, logLik)
}
}
// TestKalmanCovarianceGuards walks a long filter and demands every
// published covariance come back symmetric and positive definite,
// then pins the input gates and the step-named failure reports.
func TestKalmanCovarianceGuards(t *testing.T) {
const n = 200
g := core.NewGenerator(17)
z := make([]float64, n)
for i := range z {
z[i] = g.NormalUnit()
}
f, err := KalmanFilter(mustFloats(t, z), mustFloats(t, []float64{1, 1, 0, 1}, 2, 2),
mustFloats(t, []float64{1, 0}, 1, 2),
KalmanOptions{
ProcessNoise: mustFloats(t, []float64{1e-4, 0, 0, 1e-2}, 2, 2),
MeasurementNoise: mustFloats(t, []float64{0.25}, 1, 1),
})
if err != nil {
t.Fatalf("KalmanFilter: %v", err)
}
for step := range n {
cov := kalmanResultBlock(t, f.Covariances, step, 2, 2)
if math.Abs(cov[1]-cov[2]) > 1e-12*math.Max(math.Abs(cov[1]), math.Abs(cov[2])) {
t.Fatalf("covariance at %d is not symmetric: %g against %g", step, cov[1], cov[2])
}
if !cholPD(cov, 2) {
t.Fatalf("covariance at %d is not positive definite", step)
}
if !cholPD(kalmanResultBlock(t, f.InnovationCovariances, step, 1, 1), 1) {
t.Fatalf("innovation covariance at %d is not positive definite", step)
}
}
// The input gates.
good := mustFloats(t, []float64{1, 1, 0, 1}, 2, 2)
hGood := mustFloats(t, []float64{1, 0}, 1, 2)
zOne := mustFloats(t, []float64{0.5})
bad := []struct {
name string
run func() (*KalmanResult, error)
}{
{"nil transition", func() (*KalmanResult, error) { return KalmanFilter(zOne, nil, hGood, KalmanOptions{}) }},
{"non-square transition", func() (*KalmanResult, error) {
return KalmanFilter(zOne, mustFloats(t, []float64{1, 1}, 1, 2), hGood, KalmanOptions{})
}},
{"observation width mismatch", func() (*KalmanResult, error) {
return KalmanFilter(zOne, good, mustFloats(t, []float64{1, 0, 0}, 1, 3), KalmanOptions{})
}},
{"asymmetric process noise", func() (*KalmanResult, error) {
return KalmanFilter(zOne, good, hGood, KalmanOptions{ProcessNoise: mustFloats(t, []float64{1, 1, 0, 1}, 2, 2)})
}},
{"singular measurement noise", func() (*KalmanResult, error) {
return KalmanFilter(zOne, good, hGood, KalmanOptions{MeasurementNoise: mustFloats(t, []float64{0}, 1, 1)})
}},
{"non-positive-definite initial covariance", func() (*KalmanResult, error) {
return KalmanFilter(zOne, good, hGood, KalmanOptions{
InitialCovariance: mustFloats(t, []float64{1, 0, 0, -1}, 2, 2)})
}},
{"empty measurements", func() (*KalmanResult, error) {
return KalmanFilter(mustFloats(t, nil), good, hGood, KalmanOptions{})
}},
{"non-finite measurement", func() (*KalmanResult, error) {
return KalmanFilter(mustFloats(t, []float64{math.NaN()}), good, hGood, KalmanOptions{})
}},
{"rank-3 measurements", func() (*KalmanResult, error) {
return KalmanFilter(mustFloats(t, []float64{1, 0, 1, 0, 1, 0, 1, 0}, 2, 2, 2), good, hGood, KalmanOptions{})
}},
}
for _, b := range bad {
if _, err := b.run(); err == nil {
t.Errorf("%s accepted", b.name)
}
}
// The nonlinear filters need the state dimension spelled out.
cv := func(x *core.Array) (*core.Array, error) { return x, nil }
id := func(x *core.Array) (*core.Array, error) { return x, nil }
if _, err := ExtendedKalmanFilter(zOne, cv, id, KalmanOptions{}); err == nil {
t.Error("the extended filter accepted no initial state")
}
if _, err := UnscentedKalmanFilter(zOne, cv, id, KalmanOptions{}); err == nil {
t.Error("the unscented filter accepted no initial state")
}
if _, err := UnscentedKalmanFilter(zOne, cv, id, KalmanOptions{
InitialState: mustFloats(t, []float64{0}),
SigmaAlpha: -1,
}); err == nil {
t.Error("the unscented filter accepted a negative alpha")
}
if _, err := UnscentedKalmanFilter(zOne, cv, id, KalmanOptions{
InitialState: mustFloats(t, []float64{0}),
SigmaAlpha: 1e-3,
SigmaKappa: -2,
}); err == nil {
t.Error("the unscented filter accepted a kappa that kills the sigma scale")
}
// A singular state covariance mid-run is refused with the step
// named: a transition that annihilates every state with zero
// process noise collapses the predicted spread onto a point, and
// the next predict cannot factor it.
annihilate := func(x *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{0}, 1)
}
_, err = UnscentedKalmanFilter(mustFloats(t, []float64{0, 0}), annihilate, id, KalmanOptions{
InitialState: mustFloats(t, []float64{0}),
InitialCovariance: mustFloats(t, []float64{2}, 1, 1),
ProcessNoise: mustFloats(t, []float64{0}, 1, 1),
MeasurementNoise: mustFloats(t, []float64{1e18}, 1, 1),
SigmaAlpha: 1,
})
if err == nil || !strings.Contains(err.Error(), "at step 1") {
t.Fatalf("the collapsed sigma spread was not reported at step 1, got %v", err)
}
}
// TestKalmanNonlinearGates pins the validation branches the nonlinear
// filters own: callback presence, the state dimension's sources, the
// complex and shape gates on every array the filters read, and the
// step-named reports for failing or misbehaving callbacks.
func TestKalmanNonlinearGates(t *testing.T) {
identity := func(x *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{x.FloatAt(0)}, 1)
}
doubling := func(x *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{2 * x.FloatAt(0)}, 1)
}
base := KalmanOptions{
InitialState: mustFloats(t, []float64{1}),
InitialCovariance: mustFloats(t, []float64{1}, 1, 1),
MeasurementNoise: mustFloats(t, []float64{1}, 1, 1),
}
zOne := mustFloats(t, []float64{1})
if _, err := ExtendedKalmanFilter(zOne, nil, identity, base); err == nil {
t.Error("the extended filter accepted a nil transition")
}
if _, err := UnscentedKalmanFilter(zOne, identity, nil, base); err == nil {
t.Error("the unscented filter accepted a nil observation")
}
// The state dimension may come from the initial covariance alone.
withoutState := base
withoutState.InitialState = nil
run, err := UnscentedKalmanFilter(zOne, doubling, identity, withoutState)
if err != nil {
t.Fatalf("the unscented filter refused a dimension from the initial covariance: %v", err)
}
// The gain from a unit prior under the doubling map: pp = 4,
// s = 5, K = 4/5, and the measurement 1 lands the state at 4/5.
if math.Abs(run.States.FloatAt(0)-0.8) > 1e-12 {
t.Fatalf("state %.12g from a covariance-only start, want 0.8", run.States.FloatAt(0))
}
badRank, _ := core.FromFloats([]float64{1}, 1)
covBadRank := withoutState
covBadRank.InitialCovariance = badRank
if _, err := ExtendedKalmanFilter(zOne, doubling, identity, covBadRank); err == nil {
t.Error("a rank-1 initial covariance accepted")
}
// The complex gates on the arrays the filters read.
complexState, _ := core.ComplexFromArray([]complex128{1}, 1)
withComplex := base
withComplex.InitialState = complexState
if _, err := ExtendedKalmanFilter(zOne, doubling, identity, withComplex); err == nil {
t.Error("a complex initial state accepted")
}
complexCov, _ := core.ComplexFromArray([]complex128{1}, 1, 1)
withComplexCov := withoutState
withComplexCov.InitialCovariance = complexCov
if _, err := ExtendedKalmanFilter(zOne, doubling, identity, withComplexCov); err == nil {
t.Error("a complex initial covariance accepted")
}
complexZ, _ := core.ComplexFromArray([]complex128{1}, 1)
if _, err := ExtendedKalmanFilter(complexZ, doubling, identity, base); err == nil {
t.Error("complex measurements accepted")
}
if _, err := UnscentedKalmanFilter(complexZ, doubling, identity, base); err == nil {
t.Error("complex measurements accepted by the unscented filter")
}
// A measurement stack with zero width is refused before the walk.
empty, _ := core.FromFloats([]float64{}, 2, 0)
if _, err := ExtendedKalmanFilter(empty, doubling, identity, base); err == nil {
t.Error("zero-width measurements accepted")
}
// A misbehaving transition is reported with the step.
exploding := func(x *core.Array) (*core.Array, error) {
return nil, errors.New("no")
}
_, err = ExtendedKalmanFilter(mustFloats(t, []float64{1, 1}), exploding, identity, base)
if err == nil || !strings.Contains(err.Error(), "the transition") || !strings.Contains(err.Error(), "at step 1") {
t.Fatalf("a failing transition was not reported at its step, got %v", err)
}
// A transition with the wrong output length likewise.
wrong := func(x *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{1, 2}, 2)
}
if _, err := ExtendedKalmanFilter(mustFloats(t, []float64{1, 1}), wrong, identity, base); err == nil {
t.Error("a transition with the wrong output length accepted")
}
if _, err := UnscentedKalmanFilter(mustFloats(t, []float64{1, 1}), identity, wrong, base); err == nil {
t.Error("an observation with the wrong output length accepted")
}
// A non-finite transition output is refused where it appears.
nonFinite := func(x *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{math.NaN()}, 1)
}
if _, err := UnscentedKalmanFilter(mustFloats(t, []float64{1, 1}), nonFinite, identity, base); err == nil {
t.Error("a non-finite transition output accepted")
}
// A Jacobian of the wrong shape or a failing one is reported.
badJac := func(x *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{1, 1}, 2, 1)
}
withBadJac := base
withBadJac.ObservationJacobian = badJac
if _, err := ExtendedKalmanFilter(mustFloats(t, []float64{1, 1}), doubling, identity, withBadJac); err == nil {
t.Error("an observation Jacobian of the wrong shape accepted")
}
errJac := func(x *core.Array) (*core.Array, error) {
return nil, errors.New("no")
}
withErrJac := base
withErrJac.TransitionJacobian = errJac
_, err = ExtendedKalmanFilter(mustFloats(t, []float64{1, 1}), doubling, identity, withErrJac)
if err == nil || !strings.Contains(err.Error(), "the transition Jacobian") {
t.Fatalf("a failing transition Jacobian was not reported, got %v", err)
}
withErrObsJac := base
withErrObsJac.ObservationJacobian = errJac
_, err = ExtendedKalmanFilter(mustFloats(t, []float64{1, 1}), doubling, identity, withErrObsJac)
if err == nil || !strings.Contains(err.Error(), "the observation Jacobian") {
t.Fatalf("a failing observation Jacobian was not reported, got %v", err)
}
// The linear filter's rank gate on the transition matrix.
if _, err := KalmanFilter(mustFloats(t, []float64{1}), mustFloats(t, []float64{1, 1}, 1, 2),
mustFloats(t, []float64{1, 0}, 1, 2), KalmanOptions{}); err == nil {
t.Error("a rank-1 transition matrix accepted")
}
}