// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package signal import ( "math" "slices" "sourcedock.dev/petrbalvin/tensor/internal/base" "sourcedock.dev/petrbalvin/tensor/internal/core" ) // State-space filtering: the Kalman family over the model // x_{t+1} = f(x_t) + w_t with w ~ N(0, Q), z_t = h(x_t) + v_t with // v ~ N(0, R). The linear filter runs the standard Riccati recursion // with a Joseph-form correction, the extended filter linearises f and // h at the current estimate (analytic Jacobians when supplied, the // house central-difference helper otherwise), and the unscented filter // carries the state distribution through the deterministic sigma-point // set. All three accumulate the exact Gaussian log-likelihood of the // innovation sequence, and every covariance they publish is mirrored // into its symmetric average, with positive definiteness enforced // where the mathematics demands it: a Cholesky factorisation that // cannot be taken names the step it failed at. // StateFunc maps one state vector to the image vector the model's // transition or observation applies: the f of x_{t+1} = f(x_t) + w_t, // the h of z_t = h(x_t) + v_t. The input array is private to the call // and may be kept until the call returns; the output must be a real // rank-1 array of one fixed length. type StateFunc func(x *core.Array) (*core.Array, error) // JacobianFunc returns the Jacobian of a StateFunc at x as an // (m × n) float array, row i holding the partials of output i. An // analytic Jacobian is always the better instrument; when the option // is left unset the extended filter builds one by central differences // at every step. type JacobianFunc func(x *core.Array) (*core.Array, error) // KalmanOptions carries the initial condition, the noise levels and // the filter-specific knobs. An unset (nil) array field takes its // documented default: a zero initial state, an identity initial // covariance, zero process noise, an identity measurement noise. // SigmaAlpha, SigmaBeta and SigmaKappa follow the same rule with the // defaults 0.001, 2 and 0, the standard scaled unscented choice for a // Gaussian prior. The extended and unscented filters cannot infer the // state dimension from their callbacks, so at least one of // InitialState and InitialCovariance must be set for them. // // TransitionJacobian and ObservationJacobian give the extended filter // the analytic partials it linearises with; a nil one is replaced by // central differences on the underlying map, two evaluations per // partial per step. type KalmanOptions struct { InitialState *core.Array InitialCovariance *core.Array ProcessNoise *core.Array MeasurementNoise *core.Array TransitionJacobian JacobianFunc ObservationJacobian JacobianFunc SigmaAlpha float64 SigmaBeta float64 SigmaKappa float64 } // KalmanResult holds one filtering pass over the measurement stack. type KalmanResult struct { // States is the (n × d) stack of filtered means x̂_{t|t}, one row // per measurement. States *core.Array // Covariances is the (n × d × d) stack of filtered covariances // P_{t|t}, one symmetric positive-definite block per measurement. Covariances *core.Array // Innovations is the (n × m) stack of one-step prediction errors // z_t − h(x̂_{t|t−1}). Innovations *core.Array // InnovationCovariances is the (n × m × m) stack of the innovation // covariances S_t the likelihood reads. InnovationCovariances *core.Array // LogLikelihood is Σ_t log N(z_t; h(x̂_{t|t−1}), S_t), the exact // Gaussian likelihood of the measurement sequence under the model // and the quantity noise and parameter estimation maximises. LogLikelihood float64 } // kalmanStepOut carries one step's outputs from a filter's step // closure into the result stack. type kalmanStepOut struct { mean []float64 covariance []float64 innovation []float64 innovationCov []float64 logLikelihood float64 } // kfSymmetryEps is the relative tolerance a covariance's mirror check // allows, the same reading the multivariate normal takes: matrices // assembled from products differ from their mirror by an ulp of // rounding, a genuinely asymmetric pair by far more. const kfSymmetryEps = 1e-12 // kfFinite refuses the non-finite values a filter would otherwise // carry silently through every recursion. func kfFinite(name, what string, vals []float64) error { for i, v := range vals { if math.IsNaN(v) || math.IsInf(v, 0) { return base.Errf("%s: %s holds the non-finite value %g at %d", name, what, v, i) } } return nil } // kfVector reads a finite real rank-1 array into a float slice, the // payload itself for a contiguous float64 array, // with copies made only where the payload cannot serve, of exactly want entries when want is non-negative. func kfVector(name, what string, a *core.Array, want int) ([]float64, error) { if a.NDim() != 1 { return nil, base.Errf("%s: %s must be a vector, got shape %s", name, what, base.ShapeText(a.Shape())) } if a.Dtype() == core.Complex { return nil, base.Errf("%s: %s must be real, got complex", name, what) } if want >= 0 && a.Len() != want { return nil, base.Errf("%s: %s holds %d entries, want %d", name, what, a.Len(), want) } vals := widenFloats(a) if err := kfFinite(name, what, vals); err != nil { return nil, err } return vals, nil } // kfMatrix reads a finite real rank-2 array into a fresh row-major // float slice of exactly rows×cols, either side wildcarded at −1. func kfMatrix(name, what string, a *core.Array, rows, cols int) ([]float64, error) { if a.NDim() != 2 { return nil, base.Errf("%s: %s must be rank 2, got shape %s", name, what, base.ShapeText(a.Shape())) } if a.Dtype() == core.Complex { return nil, base.Errf("%s: %s must be real, got complex", name, what) } shape := a.Shape() if (rows >= 0 && shape[0] != rows) || (cols >= 0 && shape[1] != cols) { return nil, base.Errf("%s: %s has shape %s, want %d×%d", name, what, base.ShapeText(shape), rows, cols) } vals := widenFloats(a) if err := kfFinite(name, what, vals); err != nil { return nil, err } return vals, nil } // kfSymmetric demands every mirror pair agree within a relative // tolerance, because only one triangle is ever read. func kfSymmetric(name, what string, a []float64, n int) error { for i := range n { for j := range i { lo, hi := a[i*n+j], a[j*n+i] if math.Abs(lo-hi) > kfSymmetryEps*math.Max(math.Abs(lo), math.Abs(hi)) { return base.Errf("%s: %s is not symmetric at (%d, %d): %g against %g", name, what, i+1, j+1, lo, hi) } } } return nil } // kfNoise reads one noise covariance: an unset array takes its // documented default (zeros for the process noise, whose only job is // to enter sums, an identity for the measurement noise, which is // factored every step and so must be positive definite), a set one // must be a finite symmetric matrix of the right size. func kfNoise(name, what string, a *core.Array, dim int, identityDefault bool) ([]float64, error) { if a == nil { if identityDefault { return kfIdentity(dim), nil } return make([]float64, dim*dim), nil } vals, err := kfMatrix(name, what, a, dim, dim) if err != nil { return nil, err } if err := kfSymmetric(name, what, vals, dim); err != nil { return nil, err } if identityDefault { if _, err := kfCholesky(what, vals, dim); err != nil { return nil, base.Errf("%s: %w", name, err) } } return vals, nil } // kfInitialCondition reads the initial mean and covariance, zeros and // identity where the options leave them unset. The initial covariance // must be symmetric positive definite: the first predict would // otherwise hand the recursion a structure it cannot factor. func kfInitialCondition(name string, opts KalmanOptions, d int) (x0, p0 []float64, err error) { if opts.InitialState == nil { x0 = make([]float64, d) } else if x0, err = kfVector(name, "the initial state", opts.InitialState, d); err != nil { return nil, nil, err } if opts.InitialCovariance == nil { p0 = kfIdentity(d) } else { if p0, err = kfMatrix(name, "the initial covariance", opts.InitialCovariance, d, d); err != nil { return nil, nil, err } if err := kfSymmetric(name, "the initial covariance", p0, d); err != nil { return nil, nil, err } if _, err := kfCholesky("the initial covariance", p0, d); err != nil { return nil, nil, base.Errf("%s: %w", name, err) } } return x0, p0, nil } // kfNonlinearInputs resolves the shared inputs of the extended and // unscented filters: the state dimension, which the callbacks cannot // carry, comes from the initial state or the initial covariance, at // least one of which must be set. func kfNonlinearInputs(name string, opts KalmanOptions, m int) (d int, x0, p0, q, r []float64, err error) { switch { case opts.InitialState != nil: if x0, err = kfVector(name, "the initial state", opts.InitialState, -1); err != nil { return 0, nil, nil, nil, nil, err } d = len(x0) case opts.InitialCovariance != nil: if opts.InitialCovariance.NDim() != 2 { return 0, nil, nil, nil, nil, base.Errf("%s: the initial covariance must be rank 2, got shape %s", name, base.ShapeText(opts.InitialCovariance.Shape())) } d = opts.InitialCovariance.Shape()[0] default: return 0, nil, nil, nil, nil, base.Errf("%s: the state dimension must come from an initial state or an initial covariance; neither is set", name) } if d < 1 { return 0, nil, nil, nil, nil, base.Errf("%s: the state dimension must be at least 1, got %d", name, d) } if x0, p0, err = kfInitialCondition(name, opts, d); err != nil { return 0, nil, nil, nil, nil, err } if q, err = kfNoise(name, "the process noise", opts.ProcessNoise, d, false); err != nil { return 0, nil, nil, nil, nil, err } if r, err = kfNoise(name, "the measurement noise", opts.MeasurementNoise, m, true); err != nil { return 0, nil, nil, nil, nil, err } return d, x0, p0, q, r, nil } // kfMeasurements reads the measurement stack: a rank-1 array holds n // scalar observations, a rank-2 array holds n rows of m channels. At // least one measurement is needed, every value must be finite. func kfMeasurements(name string, z *core.Array) (n, m int, rows []float64, err error) { if z.NDim() != 1 && z.NDim() != 2 { return 0, 0, nil, base.Errf("%s: the measurements must be rank 1 or rank 2, got shape %s", name, base.ShapeText(z.Shape())) } if z.Dtype() == core.Complex { return 0, 0, nil, base.Errf("%s: complex measurements are not supported", name) } if z.NDim() == 1 { m = 1 } else { m = z.Shape()[1] } if m < 1 { return 0, 0, nil, base.Errf("%s: the measurement width must be at least 1, got %d", name, m) } n = z.Len() / m if n < 1 { return 0, 0, nil, base.Errf("%s: at least one measurement is needed", name) } rows = widenFloats(z) if err := kfFinite(name, "the measurements", rows); err != nil { return 0, 0, nil, err } return n, m, rows, nil } // kfIdentity returns the n×n identity, row-major. func kfIdentity(n int) []float64 { a := make([]float64, n*n) for i := range n { a[i*n+i] = 1 } return a } // kfTranspose returns the transpose of the row-major rows×cols // matrix. func kfTranspose(a []float64, rows, cols int) []float64 { out := make([]float64, rows*cols) for i := range rows { for j := range cols { out[j*rows+i] = a[i*cols+j] } } return out } // kfMatVec returns A·x for the row-major rows×cols matrix A. func kfMatVec(a []float64, rows, cols int, x []float64) []float64 { out := make([]float64, rows) for i := range rows { total := 0.0 row := a[i*cols : (i+1)*cols] for j, v := range row { total += v * x[j] } out[i] = total } return out } // kfMatMul returns A·B for the row-major A of size ra×ca and B of // size ca×cb. func kfMatMul(a []float64, ra, ca int, b []float64, cb int) []float64 { out := make([]float64, ra*cb) for i := range ra { row := out[i*cb : (i+1)*cb] arow := a[i*ca : (i+1)*ca] for k, aik := range arow { brow := b[k*cb : (k+1)*cb] for j := range cb { row[j] += aik * brow[j] } } } return out } // kfCholesky factors the symmetric positive-definite row-major n×n // matrix into the lower triangular L with A = L·Lᵀ, only the lower // mirror read. A non-positive pivot names its row. func kfCholesky(what string, a []float64, n int) ([]float64, error) { 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 nil, base.Errf("%s is not positive definite at row %d", what, i+1) } l[i*n+i] = math.Sqrt(total) } else { l[i*n+j] = total / l[j*n+j] } } } return l, nil } // kfCholSolve solves L·Lᵀ·x = b through the forward and backward // substitutions. func kfCholSolve(l []float64, n int, b []float64) []float64 { x := make([]float64, n) for i := range n { total := b[i] for k := range i { total -= l[i*n+k] * x[k] } x[i] = total / l[i*n+i] } for i := n - 1; i >= 0; i-- { total := x[i] for k := i + 1; k < n; k++ { total -= l[k*n+i] * x[k] } x[i] = total / l[i*n+i] } return x } // kfCholSolveMatrix solves L·Lᵀ·X = B for the row-major B of size // n×cols, row by row. func kfCholSolveMatrix(l []float64, n int, b []float64, cols int) []float64 { x := make([]float64, len(b)) copy(x, b) for i := range n { row := x[i*cols : (i+1)*cols] for k := range i { lk := l[i*n+k] krow := x[k*cols : (k+1)*cols] for j := range cols { row[j] -= lk * krow[j] } } di := l[i*n+i] for j := range cols { row[j] /= di } } for i := n - 1; i >= 0; i-- { row := x[i*cols : (i+1)*cols] for k := i + 1; k < n; k++ { lk := l[k*n+i] krow := x[k*cols : (k+1)*cols] for j := range cols { row[j] -= lk * krow[j] } } di := l[i*n+i] for j := range cols { row[j] /= di } } return x } // kfLogDet returns the log determinant of the Cholesky factor: twice // the sum of the log diagonal. func kfLogDet(l []float64, n int) float64 { total := 0.0 for i := range n { total += math.Log(l[i*n+i]) } return 2 * total } // kfSymmetrise replaces A by (A + Aᵀ)/2 in place: the rounding drift // of a recursion that touches a covariance only through symmetric // expressions cannot survive the mirror average. func kfSymmetrise(a []float64, n int) { for i := range n { for j := range i { avg := (a[i*n+j] + a[j*n+i]) / 2 a[i*n+j] = avg a[j*n+i] = avg } } } // kalmanRun walks the measurement stack through the step closure, // which owns one predict-and-correct cycle: it receives the current // mean and covariance (read-only; it must return fresh slices) and // the step's measurement row, and returns everything the result stack // records, the Gaussian log-likelihood contribution included. func kalmanRun(nMeas, m, d int, meas []float64, x0, p0 []float64, step func(t int, zRow, mean, cov []float64) (kalmanStepOut, error), ) (*KalmanResult, error) { out := &KalmanResult{ States: core.New(core.Float, nMeas, d), Covariances: core.New(core.Float, nMeas, d, d), Innovations: core.New(core.Float, nMeas, m), InnovationCovariances: core.New(core.Float, nMeas, m, m), } mean := slices.Clone(x0) cov := slices.Clone(p0) total := 0.0 for t := range nMeas { res, err := step(t, meas[t*m:(t+1)*m], mean, cov) if err != nil { return nil, err } copy(out.States.RawFloats()[t*d:(t+1)*d], res.mean) copy(out.Covariances.RawFloats()[t*d*d:(t+1)*d*d], res.covariance) copy(out.Innovations.RawFloats()[t*m:(t+1)*m], res.innovation) copy(out.InnovationCovariances.RawFloats()[t*m*m:(t+1)*m*m], res.innovationCov) total += res.logLikelihood mean, cov = res.mean, res.covariance } out.LogLikelihood = total return out, nil } // kfCorrect runs the shared correction of the linear and extended // filters: the innovation against the predicted observation, its // covariance S = H·P⁻·Hᵀ + R, the gain K = P⁻·Hᵀ·S⁻¹ through the // Cholesky solve, the state update and the Joseph-form covariance // (I−K·H)·P⁻·(I−K·H)ᵀ + K·R·Kᵀ, mirrored into its symmetric average. // The Joseph form keeps the filtered covariance symmetric positive // definite by construction, where the plain (I−K·H)·P⁻ recursion // preserves it only in exact arithmetic. func kfCorrect(name string, t int, zRow, predicted, xp, pp []float64, d, m int, h, ht, r []float64, ) (kalmanStepOut, error) { innovation := make([]float64, m) for i := range m { innovation[i] = zRow[i] - predicted[i] } hp := kfMatMul(h, m, d, pp, d) s := kfMatMul(hp, m, d, ht, m) for i := range s { s[i] += r[i] } ls, err := kfCholesky("the innovation covariance", s, m) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t, err) } solved := kfCholSolve(ls, m, innovation) quad := 0.0 for i := range m { quad += innovation[i] * solved[i] } // K = P⁻·Hᵀ·S⁻¹ arrives through the transposed solve: S·X = // H·P⁻ gives X = S⁻¹·H·P⁻ = Kᵀ and K = Xᵀ. gain := kfTranspose(kfCholSolveMatrix(ls, m, hp, d), m, d) mean := make([]float64, d) copy(mean, xp) for i := range d { total := 0.0 grow := gain[i*m : (i+1)*m] for j, y := range innovation { total += grow[j] * y } mean[i] += total } kh := kfMatMul(gain, d, m, h, d) a := make([]float64, d*d) for i := range d { for j := range d { a[i*d+j] = -kh[i*d+j] } a[i*d+i]++ } at := kfTranspose(a, d, d) cov := kfMatMul(kfMatMul(a, d, d, pp, d), d, d, at, d) krkt := kfMatMul(kfMatMul(gain, d, m, r, m), d, m, kfTranspose(gain, d, m), d) for i := range cov { cov[i] += krkt[i] } kfSymmetrise(cov, d) logLik := -0.5 * (float64(m)*math.Log(2*math.Pi) + kfLogDet(ls, m) + quad) return kalmanStepOut{mean: mean, covariance: cov, innovation: innovation, innovationCov: s, logLikelihood: logLik}, nil } // KalmanFilter runs the linear Kalman filter over the measurement // stack z under the model x_{t+1} = F·x_t + w_t, z_t = H·x_t + v_t: // transition is the (d × d) state matrix F, observation the (m × d) // matrix H. Every step predicts with F and corrects with a // Joseph-form update, and the exact Gaussian log-likelihood of the // innovation sequence accumulates into the result. // // A rank-1 z holds n scalar observations, a rank-2 z holds n rows of // m channels. Nil option fields take their defaults (see // KalmanOptions). A non-square transition, a shape mismatch, an // asymmetric noise covariance, a singular measurement noise, a // non-finite value anywhere, and an innovation covariance that loses // positive definiteness (naming the step) are errors. func KalmanFilter(z *core.Array, transition, observation *core.Array, opts KalmanOptions) (*KalmanResult, error) { const name = "KalmanFilter" if transition == nil || observation == nil { return nil, base.Errf("%s: the transition and observation matrices are required", name) } if transition.NDim() != 2 { return nil, base.Errf("%s: the transition matrix must be rank 2, got shape %s", name, base.ShapeText(transition.Shape())) } shape := transition.Shape() if shape[0] != shape[1] { return nil, base.Errf("%s: the transition matrix must be square, got %d×%d", name, shape[0], shape[1]) } d := shape[0] if d < 1 { return nil, base.Errf("%s: the state dimension must be at least 1, got %d", name, d) } nMeas, m, meas, err := kfMeasurements(name, z) if err != nil { return nil, err } f, err := kfMatrix(name, "the transition matrix", transition, d, d) if err != nil { return nil, err } h, err := kfMatrix(name, "the observation matrix", observation, m, d) if err != nil { return nil, err } q, err := kfNoise(name, "the process noise", opts.ProcessNoise, d, false) if err != nil { return nil, err } r, err := kfNoise(name, "the measurement noise", opts.MeasurementNoise, m, true) if err != nil { return nil, err } x0, p0, err := kfInitialCondition(name, opts, d) if err != nil { return nil, err } ft := kfTranspose(f, d, d) ht := kfTranspose(h, m, d) return kalmanRun(nMeas, m, d, meas, x0, p0, func(t int, zRow, x, p []float64) (kalmanStepOut, error) { // Predict: x⁻ = F·x, P⁻ = F·P·Fᵀ + Q. xp := kfMatVec(f, d, d, x) pp := kfMatMul(kfMatMul(f, d, d, p, d), d, d, ft, d) for i := range pp { pp[i] += q[i] } predicted := kfMatVec(h, m, d, xp) return kfCorrect(name, t+1, zRow, predicted, xp, pp, d, m, h, ht, r) }) } // ExtendedKalmanFilter runs the extended Kalman filter over the // measurement stack z under the nonlinear model x_{t+1} = f(x_t) + // w_t, z_t = h(x_t) + v_t: every step predicts by propagating the // mean through f and the covariance through the linearisation F = // ∂f/∂x at x̂_{t|t}, then corrects through the Jacobian H = ∂h/∂x at // the predicted mean, with the same Joseph-form update and the same // exact log-likelihood the linear filter carries. The Jacobians come // from the options when supplied analytically and from central // differences otherwise (see JacobianFunc). // // The extended filter is the linear one applied to local linear // models: it inherits the Kalman recursions and with them the first // order's blindness to the curvature of f and h, so a strongly bent // observation map wants the unscented filter instead. The state // dimension must be fixed by an initial state or covariance (see // KalmanOptions); everything else follows the linear filter's // contract, with a failing callback or Jacobian reported with the // step it failed at. func ExtendedKalmanFilter(z *core.Array, transition, observation StateFunc, opts KalmanOptions) (*KalmanResult, error) { const name = "ExtendedKalmanFilter" if transition == nil || observation == nil { return nil, base.Errf("%s: the transition and observation maps are required", name) } nMeas, m, meas, err := kfMeasurements(name, z) if err != nil { return nil, err } d, x0, p0, q, r, err := kfNonlinearInputs(name, opts, m) if err != nil { return nil, err } fjac := opts.TransitionJacobian if fjac == nil { fjac = func(x *core.Array) (*core.Array, error) { return core.Jacobian(transition, x, core.JacobianOptions{}) } } hjac := opts.ObservationJacobian if hjac == nil { hjac = func(x *core.Array) (*core.Array, error) { return core.Jacobian(observation, x, core.JacobianOptions{}) } } return kalmanRun(nMeas, m, d, meas, x0, p0, func(t int, zRow, x, p []float64) (kalmanStepOut, error) { xArr, err := core.FromFloats(x, d) if err != nil { return kalmanStepOut{}, err } fOut, err := transition(xArr) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: the transition: %w", name, t+1, err) } xp, err := kfVector(name, "the transition output", fOut, d) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err) } fJacArr, err := fjac(xArr) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: the transition Jacobian: %w", name, t+1, err) } fMat, err := kfMatrix(name, "the transition Jacobian", fJacArr, d, d) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err) } // Predict through the linearisation at the filtered mean. pp := kfMatMul(kfMatMul(fMat, d, d, p, d), d, d, kfTranspose(fMat, d, d), d) for i := range pp { pp[i] += q[i] } // Correct through the linearisation at the predicted mean, but // innovate against the true observation map. xpArr, err := core.FromFloats(xp, d) if err != nil { return kalmanStepOut{}, err } hOut, err := observation(xpArr) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: the observation: %w", name, t+1, err) } predicted, err := kfVector(name, "the observation output", hOut, m) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err) } hJacArr, err := hjac(xpArr) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: the observation Jacobian: %w", name, t+1, err) } hMat, err := kfMatrix(name, "the observation Jacobian", hJacArr, m, d) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err) } return kfCorrect(name, t+1, zRow, predicted, xp, pp, d, m, hMat, kfTranspose(hMat, m, d), r) }) } // UnscentedKalmanFilter runs the unscented Kalman filter over the // measurement stack z under the nonlinear model x_{t+1} = f(x_t) + // w_t, z_t = h(x_t) + v_t: the state distribution N(x̂, P) is carried // through f and h exactly to second order by the deterministic // sigma-point set x̂ ± sqrt(d+λ)·L[:, i], L the Cholesky factor of P, // whose weighted moments reconstruct the predicted mean and // covariance. The update re-draws the set from the predicted // distribution, so the state-observation cross-covariance carries the // process noise the innovation covariance does. λ = α²(d+κ) − d, with // d the state dimension the sigma-point set spans, comes from // KalmanOptions' SigmaAlpha, SigmaBeta and SigmaKappa; the // weights are the standard scaled set, with the covariance weight of // the centre point carrying the 1 − α² + β prior correction. // // The correction carries no Joseph form: the gain comes from the // explicit cross-covariance of state and observation rather than an // observation matrix, so the covariance leaves the update as // P⁻ − K·S·Kᵀ mirrored into its symmetric average, and positive // definiteness is enforced by the next predict's Cholesky // factorisation, which names the step when it fails. The log-likelihood // accumulates exactly as in the linear filter. On a linear model the // sigma transforms are exact and the filter degenerates to the // Kalman answer to rounding; the state dimension must be fixed by an // initial state or covariance (see KalmanOptions). func UnscentedKalmanFilter(z *core.Array, transition, observation StateFunc, opts KalmanOptions) (*KalmanResult, error) { const name = "UnscentedKalmanFilter" if transition == nil || observation == nil { return nil, base.Errf("%s: the transition and observation maps are required", name) } nMeas, m, meas, err := kfMeasurements(name, z) if err != nil { return nil, err } d, x0, p0, q, r, err := kfNonlinearInputs(name, opts, m) if err != nil { return nil, err } alpha := opts.SigmaAlpha if alpha == 0 { alpha = 0.001 } beta := opts.SigmaBeta if beta == 0 { beta = 2 } kappa := opts.SigmaKappa if math.IsNaN(alpha) || alpha < 0 { return nil, base.Errf("%s: the sigma alpha must be unset (the default 0.001) or positive, got %g", name, opts.SigmaAlpha) } if math.IsNaN(beta) || beta < 0 { return nil, base.Errf("%s: the sigma beta must be unset (the default 2) or positive, got %g", name, opts.SigmaBeta) } if math.IsNaN(kappa) { return nil, base.Errf("%s: the sigma kappa must not be NaN, got %g", name, kappa) } scale := alpha * alpha * (float64(d) + kappa) if scale <= 0 { return nil, base.Errf("%s: the sigma spread vanishes: alpha %g and kappa %g leave no positive scale for %d states", name, alpha, kappa, d) } points := 2*d + 1 lambda := scale - float64(d) wm := make([]float64, points) wc := make([]float64, points) wm[0] = lambda / scale wc[0] = wm[0] + 1 - alpha*alpha + beta for i := 1; i < points; i++ { wm[i] = 1 / (2 * scale) wc[i] = wm[i] } spread := math.Sqrt(scale) sig := make([]float64, points*d) prop := make([]float64, points*d) obs := make([]float64, points*m) return kalmanRun(nMeas, m, d, meas, x0, p0, func(t int, zRow, x, p []float64) (kalmanStepOut, error) { // The sigma set: the mean plus each Cholesky column of the // covariance, pushed out by sqrt(d+λ) both ways. l, err := kfCholesky("the state covariance", p, d) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err) } copy(sig[:d], x) for i := range d { plus := sig[(1+2*i)*d : (2+2*i)*d] minus := sig[(2+2*i)*d : (3+2*i)*d] copy(plus, x) copy(minus, x) for j := range d { v := spread * l[j*d+i] plus[j] += v minus[j] -= v } } // Predict: every sigma through f, then the weighted moments. for s := range points { sa, err := core.FromFloats(sig[s*d:(s+1)*d], d) if err != nil { return kalmanStepOut{}, err } fa, err := transition(sa) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: the transition: %w", name, t+1, err) } fv, err := kfVector(name, "the transition output", fa, d) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err) } copy(prop[s*d:(s+1)*d], fv) } xp := make([]float64, d) for s := range points { for j := range d { xp[j] += wm[s] * prop[s*d+j] } } pp := make([]float64, d*d) for s := range points { w := wc[s] prows := prop[s*d : (s+1)*d] for a := range d { da := prows[a] - xp[a] for b := range d { pp[a*d+b] += w * da * (prows[b] - xp[b]) } } } for i := range pp { pp[i] += q[i] } kfSymmetrise(pp, d) // The update re-draws the sigma set from the predicted // distribution N(x⁻, P⁻), the process noise included: the // state-observation cross-covariance must carry the same // spread the innovation covariance does, or the gain loses the // Q·Hᵀ term and a linear model stops degenerating to the // Kalman answer. lp2, err := kfCholesky("the predicted state covariance", pp, d) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err) } copy(sig[:d], xp) for i := range d { plus := sig[(1+2*i)*d : (2+2*i)*d] minus := sig[(2+2*i)*d : (3+2*i)*d] copy(plus, xp) copy(minus, xp) for j := range d { v := spread * lp2[j*d+i] plus[j] += v minus[j] -= v } } // Observation: the predicted sigmas through h, the weighted // moments, and the state-observation cross-covariance. for s := range points { pa, err := core.FromFloats(sig[s*d:(s+1)*d], d) if err != nil { return kalmanStepOut{}, err } ha, err := observation(pa) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: the observation: %w", name, t+1, err) } hv, err := kfVector(name, "the observation output", ha, m) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err) } copy(obs[s*m:(s+1)*m], hv) } zp := make([]float64, m) for s := range points { for j := range m { zp[j] += wm[s] * obs[s*m+j] } } s := make([]float64, m*m) for k := range points { w := wc[k] orow := obs[k*m : (k+1)*m] for a := range m { da := orow[a] - zp[a] for b := range m { s[a*m+b] += w * da * (orow[b] - zp[b]) } } } for i := range s { s[i] += r[i] } kfSymmetrise(s, m) cross := make([]float64, d*m) for k := range points { w := wc[k] prow := sig[k*d : (k+1)*d] orow := obs[k*m : (k+1)*m] for a := range d { da := prow[a] - xp[a] for b := range m { cross[a*m+b] += w * da * (orow[b] - zp[b]) } } } ls, err := kfCholesky("the innovation covariance", s, m) if err != nil { return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err) } innovation := make([]float64, m) for i := range m { innovation[i] = zRow[i] - zp[i] } solved := kfCholSolve(ls, m, innovation) quad := 0.0 for i := range m { quad += innovation[i] * solved[i] } // K = P_xz·S⁻¹ through the transposed solve: S·X = P_xzᵀ // gives X = S⁻¹·P_xzᵀ and K = Xᵀ. gain := kfTranspose(kfCholSolveMatrix(ls, m, kfTranspose(cross, d, m), d), m, d) mean := make([]float64, d) copy(mean, xp) for i := range d { total := 0.0 grow := gain[i*m : (i+1)*m] for j, y := range innovation { total += grow[j] * y } mean[i] += total } kskt := kfMatMul(kfMatMul(gain, d, m, s, m), d, m, kfTranspose(gain, d, m), d) covariance := make([]float64, d*d) for i := range covariance { covariance[i] = pp[i] - kskt[i] } kfSymmetrise(covariance, d) logLik := -0.5 * (float64(m)*math.Log(2*math.Pi) + kfLogDet(ls, m) + quad) return kalmanStepOut{mean: mean, covariance: covariance, innovation: innovation, innovationCov: s, logLikelihood: logLik}, nil }) }