Nonlinear MHE with Constrained Parameter Estimation¶
This example estimates both state and a physical parameter from noisy
measurements using Moving Horizon Estimation. The parameter (damping
coefficient) is kept positive throughout optimization by representing it in an
unconstrained raw form and applying positive_softplus() inside the dynamics.
Runnable script: examples/nonlinear_mhe.py
Problem Setup¶
A damped pendulum is driven by a small random torque:
The damping coefficient \(b > 0\) is unknown. The estimator must recover both the state \((\theta, \dot\theta)\) and \(b\) jointly from angle measurements alone.
Augmented State and Constrained Parameterization¶
import jax
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp
import contrax as cx
DT = 0.05 # sample period (s)
WINDOW = 12 # MHE window length
G_ACCEL = 9.81 # gravitational acceleration (m/s²)
L = 1.0 # pendulum length (m), assumed known
DAMPING_TRUE = 0.3 # true damping coefficient (kg·m²/s)
N_AUG = 3 # augmented state dimension: [theta, omega, b_raw]
def aug_dynamics(x_aug, u):
"""Euler step for augmented state z = [theta, omega, b_raw].
The damping parameter b_raw is treated as a slowly-varying state:
its dynamics are an identity (random-walk model). The physical damping
b = positive_softplus(b_raw) is always positive.
"""
theta, omega, b_raw = x_aug[0], x_aug[1], x_aug[2]
b = cx.positive_softplus(b_raw)
torque = u[0]
alpha = -(G_ACCEL / L) * jnp.sin(theta) - b * omega + torque
theta_next = theta + DT * omega
omega_next = omega + DT * alpha
return jnp.array([theta_next, omega_next, b_raw]) # b_raw unchanged
def aug_observation(x_aug):
"""Observe angle only from augmented state."""
return x_aug[:1]
The model augments the state with \(b_\text{raw}\) using identity dynamics (random-walk model), then recovers the physical parameter with
The softplus map keeps \(b > 0\) for every raw parameter value, so the optimizer works over unconstrained \(b_\text{raw}\) values.
Solving MHE¶
def run_mhe(xs_phys, ys, us):
"""Solve MHE over the first window and report results."""
# Noise covariances in augmented state space
Q_noise = jnp.diag(jnp.array([1e-4, 1e-3, 1e-5])) # small drift on b_raw
R_noise = jnp.array([[0.04]])
# Prior: start near the first measurement; b_raw=0 → b≈0.69 (soft overestimate)
x_prior = jnp.array([float(ys[0, 0]), 0.0, 0.0])
P_prior = jnp.diag(jnp.array([0.1, 0.5, 2.0]))
ys_win = ys[: WINDOW + 1] # (WINDOW+1, 1)
us_win = us[:WINDOW] # (WINDOW, 1)
# Warm-start: replicate prior across window
xs_init = jnp.tile(x_prior, (WINDOW + 1, 1))
model = cx.nonlinear_system(
lambda t, x, u: aug_dynamics(x, u),
output=lambda t, x, u: aug_observation(x),
dt=DT,
)
result = cx.mhe(
model,
xs_init,
us_win,
ys_win,
x_prior,
P_prior,
Q_noise,
R_noise,
max_steps=512,
)
return result
mhe() minimizes mhe_objective() over the full window trajectory using
LBFGS and accepts the nonlinear system model directly. The window has N states
and measurements, with N−1 input transitions. The Kalman-style prior term (x_prior, P_prior) regularizes the
start of the window, and Q_noise controls how much the parameter is allowed
to drift across the horizon.
What the Script Prints¶
The script reports convergence, status, gradient norm, final cost, estimated damping and terminal angle alongside the ground truth. It also checks positive damping and terminal-angle accuracy.
The angle estimate is close to ground truth. The damping estimate is biased:
a 12-step window with a slowly varying random-walk prior gives a short
identification horizon for one scalar parameter. Longer windows or a tighter
prior on \(b_\text{raw}\) dynamics may improve the estimate. Here
solver_converged=False; inspect status, gradient norm, and cost reduction
before using the estimate or interpreting implicit gradients.
Related Pages¶
- How-to: Build an MHE objective
- FOH estimation for a complementary EKF workflow on a continuous nonlinear model
- Estimation API