SR-UKF Performance — Architecture and Optimisations¶
The UnscentedKalmanFilter implements the
canonical Square-Root UKF after Wan & van der Merwe 2001
(Algorithm 3.1), in the variant that re-uses sigma points between
predict and measurement update. This page documents which optimisations
brought the code to that state and how their correctness is verified.
Current algorithm¶
Per filter step:
- Predict draws
2n+1sigma points around(x_hat, S),
propagates them throughprocess.step(σ_i, dt), and in the same loop readsh(plant, σ_i_propagated)for every observation channel. Predicted mean + Cholesky factor via QR-stack with√Q. - Update consumes the predict-side h-cache directly (column
selection over the active channels). No second sigma-point draw, no second plant pass. Cross-covarianceT_xyfrom(propagated − x_pred)differences.
ODE load per filter step: 2n+1 plant integrations (predict only).
The earlier "redraw" variant did 2 · (2n+1) = 4n+2 — twice the load
for no mathematical gain.
Optimisation waves (2026-06)¶
Wave 1 — lossless linalg refactors¶
All four changes are bit-stable to last-bit roundoff (atol=1e-12):
_sigma_pointsvectorised — Python loop → two broadcasts.T_xyvectorised —for i: outer(...)→ one matmul.cholesky(Q)cached — keyed ondt, hit whendtis constant.cholesky(R)cached — keyed on the active-channel tuple.
Wave 2 — sigma-point reuse¶
Structural change saving ~50 % of ODE calls. The earlier code drew a
fresh sigma set around (x_pred, S_pred) inside update() and stepped
the plant once per sigma — an extension that is not part of the
canonical Wan-VdM 2001 form. In a 24-hour ADM1 twin (fixed seed, simple
plant), the migration delivered 1.84× speedup (429 s → 234 s) with
comparable per-block accuracy.
The mathematical approximation it accepts: the H · Q · H^T
contribution to the innovation covariance S_y is dropped (propagated
sigma points cover sample_cov, not sample_cov + Q). Wu et al. 2005
show this is inferior to the state-augmented UKF — by an amount that
scales with ||Q|| / ||P||. For ADM1 at dt = 1 h with the spec's
typical Q/P ratio (≈ 1/250), the effect is two orders of magnitude
below the measurement-noise floor. Hellmann et al. 2024 (ECC, ADM1
specifically) find identical trajectories between their UKF-SR
variant (= our current form) and UKF-add without augmentation.
Literature¶
| Source | Statement |
|---|---|
| Wan & van der Merwe (2001) "The Square-Root Unscented Kalman Filter for State and Parameter-Estimation" | Algorithm 3.1, line 22: Y_{k|k-1} = H[X_{k|k-1}] — h is evaluated directly on the propagated sigma points, no redraw. |
| Wu, Hu, Wu & Hu (2005) "Unscented Kalman filtering for additive noise case: Augmented vs. non-augmented" | The augmented form (Q-dimensions in the sigma set) is theoretically superior because it propagates Q's odd-moment information through the measurement path. Effect scales with ||Q|| / ||P||. |
| Hellmann, Wilms, Streif & Weinrich (2024, ECC) "Comparison of Unscented Kalman Filter Design for Agricultural Anaerobic Digestion Model" | Direct ADM1 study, 9 UKF variants. UKF-SR (= our form) and UKF-aug deliver practically identical NRMSE under reduced sigma scaling, with lower runtime for UKF-SR. |
How verified¶
Bit-stable regression — nonlinear mock process¶
tests/test_ukf_regression.py
runs a 20-step twin with a nonlinear mock process (elementwise
quadratic + linear coupling) and compares the final posterior against
hardcoded golden values (atol=1e-12, rtol=0). Goldens were regenerated
after the main-path migration.
Regenerate goldens (after deliberate algorithm changes):
python - <<'PY'
import sys
sys.path.insert(0, ".")
import conftest # Windows DLL paths
sys.path.insert(0, "tests")
import test_ukf_regression as m
import numpy as np
np.set_printoptions(precision=16, floatmode="maxprec")
x, S = m._run_regression_trajectory()
print("X =", repr(x))
print("S =", repr(S))
PY
Linear-KF consistency¶
tests/test_ukf.py::TestUKFLinear
has two tests against the closed-form Kalman filter:
test_ukf_matches_classical_kf_with_negligible_Q— withQ ≈ 1e-16
(ten orders of magnitude belowP) the SR-UKF trajectory matches the KF toatol=1e-6. Pins the algebraic correctness of the reuse form.test_ukf_approximates_classical_kf_with_random_walk_Q— with
Q/P ≈ 1 %the trajectory diverges from the KF by a few percent, consistent with the droppedH Q H^Tterm.atol=0.05. Pins the magnitude of the approximation; a clear violation would signal an unintended algorithmic shift.
Cache invalidation¶
Four further tests in test_ukf_regression.py use counting wrappers:
test_sqrt_Q_cache_hits_under_constant_dttest_sqrt_Q_cache_invalidates_on_dt_changetest_sqrt_R_cache_hits_under_constant_active_settest_sqrt_R_cache_invalidates_on_active_set_change
Wave 3 — Reduced sigma scaling (opt-in)¶
Since 2026-06, UnscentedKalmanFilter accepts a gamma_override
parameter that replaces the canonical sigma-point radius
γ = √(n + λ) with a user-supplied value. The weights w_m,
w_c remain canonical (built from α, β, κ) — only the position
of the sigma points on the covariance ellipsoid changes.
Empirical motivation from Hellmann et al. 2024 §5.1.2: on
ADM1-R4-Core, γ = 1 (instead of canonical √(n+λ) ≈ 2.45) cuts
NRMSE_x from 0.85 to 0.37 — the single largest gain in the paper. The
intuition: for high-dimensional, weakly nonlinear systems with
Gaussian-ish measurement noise the tighter sigma cloud smooths better
than the canonical Julier–Uhlmann scaling.
Usage:
Default None leaves the sigma-point scaling unchanged — all
existing setups continue to behave exactly as before.
Empirical γ sweep on the simple plant (n=43)¶
24-hour twin, fixed seed (42), identical truth and measurements, only γ varies. Wall time per variant 229-242 s — γ is a pure quality and calibration knob, not a speed knob.
| γ | Mean NIS (target ≈ 5) | Avg. coverage | Worst block | Verdict |
|---|---|---|---|---|
| canonical (≈ 6.56) | 6.75 | 96.5 % | charge_balance 70 % | well calibrated |
| 5.0 | 9.79 | 91.2 % | charge_balance 60 % | mildly overconfident |
| 4.0 | 17.23 | 84.8 % | nitrogen 16 % | calibration collapse |
| 3.0 | 44.80 | 81.2 % | nitrogen 20 % | catastrophic |
| 2.0 | 17.80 | 84.0 % | charge_balance 50 % | bad |
| 1.5 | 9.06 | 87.4 % | charge_balance 48 % | mediocre |
| 1.0 | 6.32 | 88.4 % | charge_balance 50 % | best mean, overconfident |
RMSE: on well-observable blocks the point-estimate error drops by
40-73 % at γ=1; on the weakly-observable input_flow channels it
rises by 25 %. Intermediate γ values (2-5) are strictly worse than
either endpoint on both metrics.
Why non-monotonic¶
The weights w_m, w_c are derived from the canonical scaling
γ = √(n+λ) such that the unscented transform recovers mean and
covariance to third order — only when paired with that exact γ.
Hellmann's trick decouples γ from the weights: tight sigma cloud +
weights built for a wider cloud → the reconstructed posterior
covariance is systematically too small, NIS explodes.
At γ=1 a different dynamic (effective low-pass smoothing of the measurement) takes over and provides acceptable NIS — for formally "wrong" reasons. In the transition zone (γ ≈ 2-5) neither effect applies cleanly, so filter consistency collapses.
Recommendation¶
- Default
gamma_override=Nonefor UQ-relevant applications
(coverage calibration is the priority there). gamma_override=1.0as opt-in for modes that only consume
the posterior mean (e.g. MPC targets, control signals), where coverage is secondary.- Avoid values between 1 and 6 — no sweet spot, both metrics
degrade simultaneously. - For simultaneously good mean and calibration: state-augmented
form (Wave 4 on the roadmap) fixes theQinconsistency in the algorithm itself instead of compensating via γ.
Wave 4 — Process-pool parallelisation of sigma propagation¶
New class ParallelUKF
dispatches the 2n+1 plant integrations of a predict() step to
a multiprocessing.spawn worker pool. The linalg path (QR,
cholupdate, weights) stays on the main process; only the embarrassingly
parallel sigma-point propagation moves to workers.
Architecture¶
- Each worker builds its own
(process, obs, spec)triple at
pool startup via a user-provided top-level builder. This sidesteps the pickle issue withobsclosures referring to plant objects. - Per task only the tiny snapshot dict (~1 kB) crosses the IPC
boundary. The plant objects themselves cross only once at pool spawn. process.stepcallsrestore()internally — we extended
process_model.snapshot/restoreso that every component withadm1_stateis reset, not just the primary digester. This fixes a latent drift bug in the serial code (storage tank accumulated state across sigma points within a predict) and makes the UKF theoretically cleaner: every sigma point now sees an independent baseline.
Benchmark (simple plant, 24-h twin, fixed seed)¶
| Variant | Wall time | Speedup | s per filter step |
|---|---|---|---|
| serial | 238.6 s | 1.00× | 9.94 |
| parallel_2 | 207.7 s | 1.15× | 8.66 |
| parallel_4 | 111.9 s | 2.13× | 4.66 |
| parallel_8 | 80.6 s | 2.96× | 3.36 |
Block-RMSE and 2σ coverage are bit-identical across worker counts. Mean-trajectory delta vs. serial: exactly 0 σ on every channel. Mean NIS = 7.08 for all variants.
Sub-linear scaling expected (Amdahl):
Pool-spawn overhead (~2-3 s per worker on Windows spawn)
Linalg path stays sequential and grows in relative weight with more workers
* Beyond ~4 physical cores (likely hyperthreads on this setup) the gain plateaus
For a typical twin workflow (5 days × 24 h = 120 steps), that means serial ≈ 20 min → parallel_8 ≈ 7 min.
Usage¶
from pyadm1ode_estimation.estimation import (
InputSpec, build_filter_components,
)
from pyadm1ode_estimation.estimation.filters import ParallelUKF
from pyadm1ode_estimation.example_plants import build_simple_plant
# Top-level (importable) — multiprocessing.spawn pickles a reference,
# so NO lambdas or closure-bound methods here.
def make_components():
plant = build_simple_plant()
return build_filter_components(
plant,
digester_id="fermenter",
substrates=[
InputSpec("maize_silage", substrate_index=0, initial_flow=10.0),
InputSpec("cattle_slurry", substrate_index=1, initial_flow=5.0),
],
sensors=["q_gas", "q_ch4", "ph", "substrate_dose"],
)
process, obs, spec = make_components()
ukf = ParallelUKF(
process, obs, spec,
n_workers=4,
components_builder=make_components,
)
# ... normal predict/update loop ...
ukf.shutdown() # release the worker pool
n_workers=1 falls back to the serial main path — no pool
overhead, identical behaviour to the base class.
Wave 5 — Constrained UKF: implemented, negative result on ADM1¶
ConstrainedUKF
implements Hellmann 2024's cUKF-add: every propagated sigma point is
corrected through a per-sigma box-constrained QP instead of the
standard Kalman gain.
\chi^{\text{corr}}_i = \arg\min_\chi \;
\|y - h(\chi)\|^2_{R^{-1}}
+ \|\chi - \chi^-_i\|^2_{(P^-)^{-1}}
\quad \text{s.t.} \quad x_{\text{lo}} \le \chi \le x_{\text{hi}}
Implementation choices:
h linearised by least-squares fit from the cached propagated sigma
cloud (no extra plant evaluations)
Per-sigma QP via scipy.optimize.minimize(method="trust-constr")
with analytic gradient and Hessian
Posterior in square-root form per Hellmann eq. 11:
P = Σ Wᶜ (χᶜ - x̂)(χᶜ - x̂)^⊤ + Q + K R Kᵀ
Four unit tests pin correctness: with wide bounds and linear h,
cUKF ≡ UKF to atol=1e-6; with tight bounds the box constraint
is respected; smoke test on the ADM1 plant runs end-to-end.
Empirical result (24-hour twin, simple plant, n=43)¶
| Variant | Wall time | Mean NIS | Avg. block RMSE |
|---|---|---|---|
| UKF (baseline) | 232.1 s | 7.08 | reference |
| cUKF | 571.9 s (2.5× slower) | 9.32 (worse) | 2-5× worse on well-observable blocks |
Methanogenesis RMSE: 0.015 → 0.077 (5× worse). Disintegration: 0.20 → 0.95. Coverage uniformly down or flat. Per-channel mean trajectories diverge by up to 2390 σ from the UKF baseline.
Structural cause: sigma-spread collapse on a multi-scale state¶
ADM1 has 43 concentration channels spanning six orders of
magnitude — from substrate dosing (10 m³/d) to trace gases
(≈ 10⁻⁸ mol/L). The spec sets lower = 0 on all concentrations.
For small-magnitude channels (methanogenesis traces, acidogenesis
intermediates) the propagated sigma points sit close to the
lower bound. Per-sigma bounding pulls a substantial fraction of the
sigmas exactly onto the bound → the corrected sigma set clusters →
Σ Wᶜ (χᶜ - x̂)² collapses along that axis → posterior covariance
under-estimates dramatically → next predict starts with too tight a
sigma cloud → the filter goes "blind". The result is the ~10²-σ mean
drifts on the weakest channels.
Hellmann (2024) doesn't see this because their n=6 model keeps every
state in the kg/m³ range (x_0 = [4.09, 10.52, 11.04, 2.57, 0.96,
2.02]). The zero bound is effectively inactive there. ADM1's
multi-scale concentrations are a qualitatively different situation.
Finding: this is not an implementation or linearisation defect
but a structural limitation of per-sigma-point bounding on
bound-rich state spaces. For the doctoral work it stands on its own
as a negative result: Hellmann's cUKF-add — the best variant in
their n=6 benchmark — does not transfer directly to a realistic
multi-stage ADM1 with concentration channels across six orders of
magnitude.
The class stays in the repo as opt-in for use cases where the bounds are practically inactive (simpler AD models per Hellmann 2024, or bioreactor models with moderate state ranges).
What's next on the list¶
| Idea | Expected impact | Effort |
|---|---|---|
| Log-scaling of small channels | Parametrise x_i = exp(z_i) for trace-gas channels. The x_i > 0 bound becomes implicit in the transform, no per-sigma bounding needed. Likely route to recover Hellmann's cUKF gains on ADM1. |
Medium — spec extension (channel.log_scale) plumbed transparently through process/obs/spec. |
State-augmented form (Wu 2005, Hellmann's UKF-aug) |
Correct treatment of Q in the measurement path. Empirically small for ADM1 at small Q/P, but theoretically cleaner; could algorithmically resolve the Wave 3 γ-sweep disaster region. |
Medium — 2(n+m)+1 sigma points instead of 2n+1, with the Q-block efficiently groupable (Wu 2005 §IV). |
| Spherical-simplex sigma points (Julier 2003) | n+2 instead of 2n+1 sigma points → another ~2×, stacks with the parallelisation. |
Medium — recursive simplex construction, its own validation. |
| NLP cUKF with plant callback | Hellmann's true variant (evaluate h(χ) per QP iteration via plant.step instead of linearising). Removes the linearisation approximation but does NOT fix the sigma-spread collapse — so unlikely to beat the current Wave 5 result. |
High — ~10 plant equilibrations per QP iteration, parallelisable via the Wave 4 pool. |