| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| using GaussianMarkovRandomFields |
| using GaussianMarkovRandomFields: mean, var, precision_matrix, GMRF |
| using LinearAlgebra |
| using SparseArrays |
|
|
| export MomentMatchedState, EKFSolution, TransitionCache |
| export build_2block_precision, build_2block_gmrf, solve_ekf_sequential |
| export mean_trajectory, var_trajectory, std_trajectory |
|
|
| |
| |
| |
|
|
| """ |
| MomentMatchedState{T,V<:AbstractVector{T}} |
| |
| Gaussian approximation via matched moments (mean and marginal variances). |
| |
| This is the minimal representation needed to propagate uncertainty between |
| time steps while preserving sparsity in the precision matrix. |
| """ |
| struct MomentMatchedState{T,V<:AbstractVector{T}} |
| μ::V |
| σ²::V |
| end |
|
|
| MomentMatchedState(μ::AbstractVector{T}, σ²::AbstractVector{T}) where T = |
| MomentMatchedState{T,Vector{T}}(collect(μ), collect(σ²)) |
|
|
| Base.length(s::MomentMatchedState) = length(s.μ) |
|
|
| """ |
| EKFSolution |
| |
| Container for EKF-style sequential solution. |
| """ |
| struct EKFSolution{S,L} |
| history::Vector{S} |
| n_timesteps::Int |
| layout::L |
| end |
|
|
| |
| mean_trajectory(sol::EKFSolution) = [s.μ for s in sol.history] |
| var_trajectory(sol::EKFSolution) = [s.σ² for s in sol.history] |
| std_trajectory(sol::EKFSolution) = [sqrt.(s.σ²) for s in sol.history] |
|
|
| function Base.getindex(sol::EKFSolution, sym::Symbol, t::Int) |
| idx = indices(sol.layout, sym) |
| return sol.history[t].μ[idx] |
| end |
|
|
| |
| |
| |
|
|
| """ |
| TransitionCache |
| |
| Precomputed matrices for EKF time stepping that are constant across all timesteps. |
| Computing these once before the time loop significantly improves performance. |
| """ |
| struct TransitionCache{T<:AbstractMatrix} |
| A::T |
| Q_ε::T |
| Q_ε_A::T |
| At_Q_ε_A::T |
| At_Q_ε::T |
| end |
|
|
| """ |
| TransitionCache(A, Q_ε) |
| |
| Precompute transition-related matrix products for EKF solver. |
| """ |
| function TransitionCache(A::AbstractMatrix, Q_ε::AbstractMatrix) |
| Q_ε_A = Q_ε * A |
| At_Q_ε_A = sparse(A' * Q_ε_A) |
| At_Q_ε = sparse(A' * Q_ε) |
| return TransitionCache(A, Q_ε, sparse(Q_ε_A), At_Q_ε_A, At_Q_ε) |
| end |
|
|
| |
| |
| |
|
|
| """ |
| build_2block_precision(σ²_prev, cache::TransitionCache, Q_spatial) -> SparseMatrixCSC |
| |
| Build the 2-block joint precision for [x_{t-1}; x_t] using precomputed transition matrices. |
| |
| The structure is: |
| ``` |
| Q_2block = [ Q_spatial + diag(1/σ²) + A'Q_ε A -A'Q_ε ] |
| [ -Q_ε A Q_ε ] |
| ``` |
| """ |
| function build_2block_precision( |
| σ²_prev::AbstractVector, |
| cache::TransitionCache, |
| Q_spatial::AbstractMatrix |
| ) |
| |
| D_mm = spdiagm(1 ./ σ²_prev) |
|
|
| |
| Q11 = Q_spatial + D_mm + cache.At_Q_ε_A |
| Q12 = -cache.At_Q_ε |
| Q21 = -cache.Q_ε_A |
| Q22 = cache.Q_ε |
|
|
| |
| return [Q11 Q12; Q21 Q22] |
| end |
|
|
| """ |
| build_2block_gmrf(state::MomentMatchedState, cache::TransitionCache, Q_spatial) -> GMRF |
| |
| Build the 2-block GMRF prior for [x_{t-1}; x_t] using precomputed transition matrices. |
| |
| The mean vector is: |
| - x_{t-1} block: μ_prev (mean from previous step) |
| - x_t block: A * μ_prev (predicted mean from transition) |
| """ |
| function build_2block_gmrf( |
| state::MomentMatchedState, |
| cache::TransitionCache, |
| Q_spatial::AbstractMatrix |
| ) |
| Q_2block = build_2block_precision(state.σ², cache, Q_spatial) |
|
|
| |
| μ_2block = [state.μ; cache.A * state.μ] |
|
|
| return GMRF(μ_2block, Symmetric(Q_2block)) |
| end |
|
|
| |
| |
| |
|
|
| """ |
| solve_ekf_sequential( |
| Q_spatial, state_layout, A, Q_ε, n_timesteps, x0, |
| build_fvm_constraint; |
| apply_bc!, store_history=true, verbose=false |
| ) -> EKFSolution |
| |
| Solve GP-FVM problem using EKF-style sequential updates. |
| |
| This processes time steps one at a time using a sliding 2-block window, |
| maintaining a moment-matched Gaussian approximation at each step. |
| |
| Memory is O(N²) per step vs O(T×N²) for full spacetime approach. |
| |
| # Arguments |
| - `Q_spatial`: Sparse spatial prior precision |
| - `state_layout`: Layout for named indexing into state vector |
| - `A`: State transition matrix |
| - `Q_ε`: Temporal noise precision |
| - `n_timesteps`: Total number of time steps |
| - `x0`: Initial state GMRF (after IC conditioning) |
| - `build_fvm_constraint`: Function `(μ_prev, t) -> likelihood` that builds FVM constraint |
| |
| # Keyword Arguments |
| - `apply_bc!`: Function `(gmrf, t) -> gmrf` to apply boundary conditions (optional) |
| - `store_history`: Whether to store all timestep states (default: true) |
| - `verbose`: Print progress (default: false) |
| - `max_iter`: Max Gauss-Newton iterations per step (default: 1 for true EKF) |
| - `σ²_floor`: Variance floor for numerical stability (default: 1e-12) |
| |
| # Returns |
| - `EKFSolution` containing mean/variance trajectories |
| """ |
| function solve_ekf_sequential( |
| Q_spatial::AbstractMatrix, |
| state_layout, |
| A::AbstractMatrix, |
| Q_ε::AbstractMatrix, |
| n_timesteps::Int, |
| x0::GMRF, |
| build_fvm_constraint::Function; |
| apply_bc!::Union{Function,Nothing} = nothing, |
| store_history::Bool = true, |
| verbose::Bool = false, |
| max_iter::Int = 1, |
| σ²_floor::Float64 = 1e-12 |
| ) |
| N = size(Q_spatial, 1) |
|
|
| |
| μ_0 = mean(x0) |
| σ²_0 = var(x0) |
|
|
| state = MomentMatchedState(μ_0, σ²_0) |
| history = store_history ? [state] : MomentMatchedState{eltype(μ_0)}[] |
|
|
| |
| verbose && print("Precomputing transition matrices... ") |
| t1 = time() |
| cache = TransitionCache(A, Q_ε) |
| verbose && println("$(round(time()-t1, digits=2))s") |
|
|
| verbose && println("EKF Sequential Solver: $n_timesteps timesteps, N=$N") |
|
|
| for t in 2:n_timesteps |
| verbose && println(" t=$t/$n_timesteps:") |
|
|
| |
| verbose && print(" build_2block_gmrf... ") |
| t1 = time() |
| x_2block = build_2block_gmrf(state, cache, Q_spatial) |
| verbose && println("$(round(time()-t1, digits=2))s") |
|
|
| |
| if apply_bc! !== nothing |
| verbose && print(" apply_bc!... ") |
| t1 = time() |
| x_2block = apply_bc!(x_2block, t) |
| verbose && println("$(round(time()-t1, digits=2))s") |
| end |
|
|
| |
| verbose && print(" build_fvm_constraint... ") |
| t1 = time() |
| fvm_lik = build_fvm_constraint(state.μ, t) |
| verbose && println("$(round(time()-t1, digits=2))s") |
|
|
| verbose && print(" gaussian_approximation... ") |
| t1 = time() |
| x_post = gaussian_approximation(x_2block, fvm_lik; verbose=false, max_iter=max_iter) |
| verbose && println("$(round(time()-t1, digits=2))s") |
|
|
| |
| verbose && print(" extract mean... ") |
| t1 = time() |
| μ_full = mean(x_post) |
| verbose && println("$(round(time()-t1, digits=2))s") |
|
|
| verbose && print(" extract var... ") |
| t1 = time() |
| σ²_full = var(x_post) |
| verbose && println("$(round(time()-t1, digits=2))s") |
|
|
| μ_curr = μ_full[N+1:2N] |
| σ²_curr = σ²_full[N+1:2N] |
|
|
| |
| σ²_curr = max.(σ²_curr, σ²_floor) |
|
|
| |
| state = MomentMatchedState(copy(μ_curr), copy(σ²_curr)) |
| store_history && push!(history, state) |
|
|
| if verbose |
| max_μ = maximum(abs.(μ_curr)) |
| mean_σ = sqrt(sum(σ²_curr) / length(σ²_curr)) |
| println("max|μ|=$(round(max_μ, digits=4)), mean_σ=$(round(mean_σ, digits=4))") |
| end |
| end |
|
|
| return EKFSolution(history, n_timesteps, state_layout) |
| end |
|
|