Skip to content

Incremental Approximate Dynamic Programming (iADP)

iADP is an online adaptive reinforcement-learning flight-control law built on top of an incremental plant model. It combines

  • online incremental plant identification via recursive least squares (RLS),
  • an approximate quadratic cost-to-go \(V_\pi(X_t) = X_t^T \tilde{P} X_t\) fitted by batch least-squares to Bellman residuals, and
  • a closed-form policy improvement derived from Bellman's optimality principle.

Because the model is identified online, the controller is model-free at deployment and tolerates vehicle configuration changes, actuator degradation, and unmodelled aerodynamics. Flight-tested on the Cessna Citation II (PH-LAB) at TU Delft / DLR; see the F-16 nonlinear model: NonlinearLongitudinalF16.

Reference: Konatala, Milz, Weiser, Looye, van Kampen, "Flight Testing Reinforcement Learning based Online Adaptive Flight Control Laws on CS-25 Class Aircraft", AIAA SCITECH 2024, DOI 10.2514/6.2024-2402.

Key ideas

  • Incremental model. Linearising the nonlinear plant around the latest operating point gives \(\Delta X_{t+1} \approx \tilde{F}_t \Delta X_t + \tilde{G}_t \Delta\delta_t\). Stacking \(\tilde{\Theta}_t = [\tilde{F}_t; \tilde{G}_t]^T\) and \(W_t = [\Delta X_t; \Delta \delta_t]\), the recursive update is a standard fixed-forgetting RLS driving \(\tilde{\Theta}^T W \to \Delta X_{t+1}\).
  • Quadratic value function. Following the linear-quadratic tracking (LQT) relaxation, \(V_\pi(X) = X^T \tilde{P} X\) with the augmented state \(X_t = [x_t; x_t^r]\) stacking system and reference dynamics — this makes the value function applicable across the entire reachable set rather than only visited samples.
  • Batch LS policy evaluation. On a sliding window of transitions, every sample contributes one scalar Bellman equation \((X_t \otimes X_t)^T \mathrm{vec}(\tilde{P}^{j+1}) = c_t + \gamma X_{t+1}^T \tilde{P}^{j} X_{t+1}\); stacking and solving gives a new kernel matrix, which is symmetrised and optionally projected to the PSD cone for stability.
  • Closed-form improved policy. Minimising the Bellman RHS with respect to \(\Delta \delta_t\) gives paper eq. (11) — no neural network, no gradient descent: \(\Delta \delta_t = -(R + \gamma \tilde{G}^T \tilde{P} \tilde{G})^{-1} [R \delta_{t-1} + \gamma \tilde{G}^T \tilde{P} X_t + \gamma \tilde{G}^T \tilde{P} \tilde{F} \Delta X_t]\).
Aspect IHDP IM-GDHP ET-DHP iADP
Model Online LS on actor/critic gradients Online RLS (dual) Online RLS (triggered) Online RLS (paper eq. (9))
Critic Neural network Two heads \(J, \lambda\) \(\lambda\)-critic Parametric quadratic \(X^T P X\)
Actor NN + gradient updates NN + GDHP gradients NN + triggered updates Closed form eq. (11)
Update schedule Every step Every step Event-triggered Model 1 kHz / policy 20 Hz
Hyperparameter count Many (layers, lrs, …) Many Many Few: \(Q\), \(R\), \(\gamma\), \(\gamma_{RLS}\)

iADP's chief attraction is interpretability — LQT-style cost, a matrix that's PSD by construction, and a policy you can write on a napkin. It also has no training-data distribution, no replay buffer and no stochastic gradient descent.

iADP components

Component Role Implementation
IncrementalRLS Identify \(\tilde{\Theta} = [\tilde{F}; \tilde{G}]^T\) online with fixed forgetting tensoraerospace.agent.iadp.IncrementalRLS
Policy-evaluation LS Fit the kernel matrix \(\tilde{P}\) by batch LS on a sliding window Inline in IADPAgent._policy_evaluation
Policy improvement Closed-form eq. (11) IADPAgent._compute_policy_increment
IADPAgent Orchestrates identification, evaluation, improvement tensoraerospace.agent.iadp.IADPAgent

Algorithm

On each control tick \(k\), given the measurement \(x_k\) and reference \(r_k\):

  1. Augment the state. [ X_k = \begin{pmatrix} x_k \ r_k \end{pmatrix}, \qquad \Delta X_k = X_k - X_{k-1}. ]

  2. Policy improvement (eq. (11)). Using the current model and kernel: [ \Delta \delta_k = -\bigl(R + \gamma \tilde{G}^T \tilde{P} \tilde{G}\bigr)^{-1} \bigl[R \delta_{k-1} + \gamma \tilde{G}^T \tilde{P} X_k + \gamma \tilde{G}^T \tilde{P} \tilde{F} \Delta X_k\bigr]. ] Rate-limit \(\Delta \delta_k\) to \(\pm \dot{u}_{\max} \cdot dt\), then \(\delta_k = \mathrm{clip}(\delta_{k-1} + \Delta \delta_k, \pm u_{\max})\).

  3. Apply to the plant and observe \(x_{k+1}\).

  4. Model update (RLS). Build \(W = [\Delta X_{k-1}; \Delta \delta_{k-1}]\) and target \(\Delta X_k\). Then [ \varepsilon = \Delta X_k - \tilde{\Theta}^T W, \quad K = \frac{\Phi W}{\gamma_{RLS} + W^T \Phi W}, \quad \tilde{\Theta} \leftarrow \tilde{\Theta} + K \varepsilon^T, \quad \Phi \leftarrow \tfrac{1}{\gamma_{RLS}}(\Phi - K W^T \Phi). ]

  5. Cost accumulation. Append \((X_k, X_{k+1}, c_k)\) to the window with \(c_k = (x_k - r_k)^T Q (x_k - r_k) + \delta_k^T R \delta_k\).

  6. Policy evaluation (every policy_eval_every steps). With the current \(\tilde{P}^j\), solve the ridge-regularised LS [ A \mathrm{vec}(\tilde{P}^{j+1}) = b, \quad A_i = \mathrm{vec}(X_i X_i^T)^T, \quad b_i = c_i + \gamma X_{i+1}^T \tilde{P}^j X_{i+1}. ] Symmetrise and optionally project to PSD.

Quick start

import numpy as np
from tensoraerospace.agent.iadp import IADPAgent, IADPConfig

# Warm-start from an onboard linearisation (trim-point model).
F_init = np.eye(2)                              # reference dynamics + integrator
G_init = np.array([[-0.5], [0.0]])              # pitch-rate / elevator sensitivity

cfg = IADPConfig(
    dt=0.01,
    Q=np.array([[10.0]]),                       # tracking-error weight
    R=np.array([[0.1]]),                        # control weight
    gamma=0.8,                                  # Bellman discount
    gamma_rls=0.995,                            # RLS forgetting factor
    policy_eval_window=200,
    policy_eval_every=50,                       # ≈ 20 Hz at dt = 0.01 s
    policy_eval_warmup_updates=30,
    F_init=F_init, G_init=G_init,
    u_magnitude_limit=15.0,
    u_rate_limit=60.0,
    seed=0,
)
agent = IADPAgent(n_state=1, n_control=1, config=cfg)

x = np.zeros(1)
for k in range(2000):
    ref = np.array([0.1 if k > 100 else 0.0])
    u = agent.predict(x, ref, k)
    # Plug your environment step here.
    x = x + cfg.dt * (-0.5 * u - 2.0 * x)
    agent.learn(x, ref, k)

Warm-start G_init matters

Policy eq. (11) needs a reasonable \(\tilde{G}\) on the first few ticks, otherwise \((R + \gamma \tilde{G}^T \tilde{P} \tilde{G}) \approx R\) gives only the control-regularisation response. Seed G_init from a linearised onboard model; the RLS will refine it online.

Warm-start P_init from the DARE for faster convergence

The online batch-LS fits \(\tilde{P}\) from transition windows, but its finite-window bias can leave the policy a few percent below optimal. Seed P_init from the analytical LQT DARE computed off the warm-start model:

from scipy.linalg import solve_discrete_are
Q_aug = Q_val * np.block([[np.eye(n_state), -np.eye(n_state)],
                          [-np.eye(n_state), np.eye(n_state)]])
P_init = solve_discrete_are(
    np.sqrt(gamma) * F_init, np.sqrt(gamma) * G_init,
    Q_aug, R,
)

Discount enters via \(\bar{F} = \sqrt{\gamma}F,\, \bar{G} = \sqrt{\gamma}G\), so solve_discrete_are applies directly. See the nonlinear-F-16 example.

Sequential vs Continuous learning

Set model_learning_only_steps > 0 with excitation_signal to replicate the paper's Sequential Learning Approach — the first N steps run in open loop with a user-supplied multi-sine so the RLS converges before the policy engages. By default both phases run concurrently (Continuous Learning Approach).

Hyperparameters

Cost & discount

Parameter Default Description
Q I Tracking-error weight, shape (n_state, n_state)
R I Control weight, shape (n_control, n_control)
gamma 0.8 Bellman discount \(\gamma \in (0, 1)\)

RLS identifier

Parameter Default Description
gamma_rls 0.995 Constant forgetting factor, closer to 1 ⇒ longer memory
phi_init 1e2 Initial covariance scale
F_init None Warm-start for \(\tilde{F}\), shape (n_aug, n_aug)
G_init None Warm-start for \(\tilde{G}\), shape (n_aug, n_control)

Policy evaluation

Parameter Default Description
policy_eval_window 200 Sliding-window size of transitions used by batch LS
policy_eval_every 50 Stride between LS updates, in learn() ticks
policy_eval_iterations 1 Inner fixed-point sweeps per LS update
policy_eval_regularization 1e-4 Ridge term added to the normal equations
policy_eval_warmup_updates 20 Number of RLS updates to wait before the first LS
enforce_psd True Clip eigenvalues of \(\tilde{P}\) to stay positive-definite
psd_floor 1e-6 Lower bound used by the eigen-clip
policy_eval_blend 1.0 EMA coefficient for \(\tilde{P}\) updates. 1.0 replaces outright; smaller values (0.05–0.2) soft-update \(\tilde{P}\) like a target network and eliminate the control-trace sawtooth that otherwise appears at every LS tick.
P_init I Warm-start kernel matrix

Actuator bounds

Parameter Default Description
dt 0.01 Control step (s)
u_magnitude_limit 25.0 Hard magnitude clamp per channel
u_rate_limit 60.0 Max (
pinv_rcond 1e-8 Cutoff for np.linalg.pinv on the policy-improvement matrix

Sequential-learning phase

Parameter Default Description
model_learning_only_steps 0 Open-loop identification window length
excitation_signal None Optional \((T, n_\text{control})\) schedule of absolute control values

Supported environments

  • Any Gymnasium env whose observation vector contains the controlled states \(x_t\) and whose action space is a continuous control \(\delta_t\). The augmented state \(X_t = [x_t; x_t^r]\) is built internally, so the reference is passed alongside the observation in predict / learn.
  • Typical targets: NonlinearLongitudinalF16-v0 (pitch-rate or pitch-angle tracking via elevator), 6-DoF rate inner loops.

Persistence

run_dir = agent.save("./checkpoints")           # creates <date>_IADPAgent/
restored = IADPAgent.from_pretrained(run_dir)
agent.publish_to_hub("me/my-iadp", folder_path=run_dir, access_token="hf_...")

Saved artefacts:

  • config.json — full IADPConfig + n_state / n_control, arrays stored as lists.
  • rls.npz — RLS parameter matrix theta, covariance Phi, update counter, last residual.
  • value.npz — current kernel matrix \(\tilde{P}\).
  • weights.npz — active Q and R (including defaults filled in by the constructor).
  • loop_state.npz\(X_{t-1}\), last control, last increment, cached \(\Delta X\), step counter.
  • window.npz — transition buffer used by the policy evaluator, stored as stacked arrays so reload is bit-identical.

API reference

IADPAgent(n_state, n_control, config=None)

Incremental Approximate Dynamic Programming control agent.

The agent tracks a user-supplied reference on the observed state and continuously re-identifies the plant model online. See the module-level docstring for the full algorithm.

Parameters:

Name Type Description Default
n_state int

Number of controlled system states x_t. The augmented state X_t = [x_t; x_t^r] has dimension 2 · n_state.

required
n_control int

Number of control channels δ_t.

required
config Optional[IADPConfig]

:class:IADPConfig instance. Defaults fit a moderately fast fixed-wing inner loop at dt = 0.01 s.

None

F property

Current estimate, shape (n_aug, n_aug).

G property

Current estimate, shape (n_aug, n_control).

reset()

Clear per-episode rolling state (keeps learned , , ).

predict(x_obs, reference, time_step=0, *, deterministic=True)

Compute the commanded control for the current step.

Parameters:

Name Type Description Default
x_obs ndarray

System state x_t of shape (n_state,).

required
reference ndarray

Reference signal. Accepted shapes: scalar, (n_state,) (per-channel constant), (T,) (shared schedule across channels) or (n_state, T) (per-channel schedule).

required
time_step int

Index into the reference schedule.

0
deterministic bool

Unused — kept for API parity with stochastic agents.

True

Returns:

Type Description
ndarray

Control command δ of shape (n_control,), rate- and

ndarray

magnitude-limited per the configuration.

learn(next_x_obs, reference, time_step=0)

Update the online estimators from the newly observed state.

Must be called once per environment step, after :meth:predict and the corresponding env.step(δ).

Parameters:

Name Type Description Default
next_x_obs ndarray

System state measured at t + 1.

required
reference ndarray

Reference signal (same shape conventions as in :meth:predict).

required
time_step int

Same index passed to :meth:predict.

0

Returns:

Type Description
dict

Scalar diagnostics: RLS residual norm, //

dict

norms, and the one-step cost.

get_param_env()

Build a JSON-serialisable config dict for :meth:save.

save(path=None)

Write the agent to a directory.

Files produced
  • config.json — agent / config metadata with all arrays serialised as lists.
  • rls.npztheta, Phi and update counter.
  • value.npz — current kernel matrix .
  • weights.npzQ and R (as applied, including any defaults filled in by the constructor).
  • loop_state.npz — rolling state (X_prev, delta_prev, last-tick caches, step counter). With this present a saved checkpoint resumes bit-identically mid episode.
  • window.npz — the transition buffer used by the policy evaluator, stored as stacked X / Xnext / cost arrays.

Parameters:

Name Type Description Default
path Union[str, Path, None]

Base directory (None → CWD).

None

Returns:

Type Description
str

Absolute path to the created run directory.

from_pretrained(repo_name, access_token=None, version=None) classmethod

Load an agent from a local directory or Hugging Face Hub.

Parameters:

Name Type Description Default
repo_name str

Local folder path, or namespace/repo_name on the Hugging Face Hub.

required
access_token Optional[str]

Hub access token for private repos.

None
version Optional[str]

Hub revision / branch / tag.

None

Returns:

Name Type Description
IADPAgent 'IADPAgent'

Reconstructed agent.

publish_to_hub(repo_name, folder_path, access_token=None)

Upload a :meth:save directory to the Hugging Face Hub.

Parameters:

Name Type Description Default
repo_name str

Target repository id, e.g. "me/my-iadp".

required
folder_path Union[str, Path]

Local folder produced by :meth:save.

required
access_token Optional[str]

Hub access token.

None

IADPConfig(dt=0.01, Q=None, R=None, gamma=0.8, gamma_rls=0.995, phi_init=100.0, policy_eval_window=200, policy_eval_every=50, policy_eval_iterations=1, policy_eval_regularization=0.0001, policy_eval_warmup_updates=20, enforce_psd=True, psd_floor=1e-06, policy_eval_blend=1.0, model_learning_only_steps=0, excitation_signal=None, F_init=None, G_init=None, P_init=None, u_magnitude_limit=25.0, u_rate_limit=60.0, pinv_rcond=1e-08, seed=None, history=dict()) dataclass

Hyper-parameters for :class:IADPAgent.

Parameters:

Name Type Description Default
dt float

Control step [s].

0.01
Q Optional[ndarray]

Tracking-error weight matrix of shape (n_state, n_state). Defaults to the identity.

None
R Optional[ndarray]

Control weight matrix of shape (n_control, n_control). Defaults to the identity.

None
gamma float

Discount factor γ ∈ (0, 1) used in the Bellman equation of eq. (3). Smaller values keep the cost-to-go short-sighted; the paper uses values around 0.60.9 tuned offline via MOPS.

0.8
gamma_rls float

Constant RLS forgetting factor for the incremental model. See :class:IncrementalRLS.

0.995
phi_init float

Initial RLS covariance scale.

100.0
policy_eval_window int

Number of recent transitions used to fit at each policy-evaluation tick. Must be larger than n_aug^2 for the LS to be well-posed.

200
policy_eval_every int

Stride in learn() ticks between policy-evaluation updates. The paper reports a 20 Hz controller-training loop against a 1 kHz model-learning loop — policy_eval_every ≈ 50 at a dt = 0.01 sim replicates that.

50
policy_eval_iterations int

Inner fixed-point sweeps per policy-evaluation tick. One sweep is usually enough.

1
policy_eval_regularization float

Ridge term added to the LS normal equations for numerical stability.

0.0001
policy_eval_warmup_updates int

Skip policy evaluation until the RLS identifier has seen at least this many updates. Gives the incremental model a chance to settle before the LS sees noisy / estimates.

20
enforce_psd bool

When True, after each batch-LS solve for the eigenvalues are clipped to [psd_floor, +∞) and the matrix is reconstructed. The paper defines to be positive-definite so that the cost-to-go stays convex; in finite-window LS this isn't automatic, and a non-PSD can destabilise the closed-form policy eq. (11).

True
psd_floor float

Lower bound for the eigenvalue clip used when enforce_psd is True.

1e-06
policy_eval_blend float

Exponential-moving-average coefficient for updates. After each batch-LS solve the new kernel matrix P_solved is mixed with the previous one via P̃ ← blend·P_solved + (1-blend)·P̃. The default 1.0 replaces outright (the paper's behaviour) but causes a small step change in the policy every policy_eval_every ticks — visible as a sawtooth on the control trace. Values around 0.20.4 smooth the control output without measurably degrading tracking, and are analogous to soft-target updates used elsewhere in RL.

1.0
model_learning_only_steps int

Number of initial steps during which the agent ignores the policy and outputs excitation_signal (or zero if it is None). Replicates the paper's 20–25 s open-loop Sequential Learning phase.

0
excitation_signal Optional[ndarray]

Optional (T, n_control) schedule of absolute control values used during model_learning_only_steps. When None the agent outputs zero during the open-loop window.

None
F_init Optional[ndarray]

Optional warm-start for , shape (n_aug, n_aug).

None
G_init Optional[ndarray]

Optional warm-start for , shape (n_aug, n_control).

None
P_init Optional[ndarray]

Optional warm-start for the kernel matrix , shape (n_aug, n_aug). Defaults to the identity, which gives a non-trivial first policy output even before any policy-evaluation tick has fired.

None
u_magnitude_limit float

Hard magnitude clamp on the per-channel absolute control value (matches actuator envelope).

25.0
u_rate_limit float

Maximum |Δδ| per second per channel.

60.0
pinv_rcond float

Cut-off passed to numpy.linalg.pinv on the policy-improvement matrix inversion.

1e-08
seed Optional[int]

Optional RNG seed — unused by the deterministic update rules but forwarded to NumPy for parity with stochastic agents.

None

IncrementalRLS(n_output, n_regressor, gamma_rls=0.99, phi_init=100.0)

Fixed-forgetting RLS identifier for Θ̃ = [F̃; G̃]^T.

Parameters:

Name Type Description Default
n_output int

Size of the target vector ΔX̂_{t+1}.

required
n_regressor int

Size of the regressor W_t = [ΔX_t; Δδ_t], i.e. n_output + n_control.

required
gamma_rls float

Constant forgetting factor, in (0, 1]. Closer to 1 means a longer effective memory; the paper uses values around 0.950.999 for the PH-LAB flight test.

0.99
phi_init float

Initial covariance scale (Φ₀ = phi_init · I). Large values advertise high initial uncertainty and let the first handful of updates move the parameters aggressively.

100.0

predict(W)

One-step prediction ΔX̂_{t+1} = Θ̃^T · W.

update(W, y)

Run one RLS step.

Parameters:

Name Type Description Default
W ndarray

Regressor W_{t-1} = [ΔX_{t-1}; Δδ_{t-1}].

required
y ndarray

Observed target ΔX̂_t.

required

Returns:

Type Description
ndarray

Prediction residual ε_t = y − Θ̃_{t-1}^T · W before

ndarray

the update, for diagnostics and downstream gating.

reset_covariance()

Restore Φ to its initial large-variance state.

extract_F(n_state)

Return of shape (n_state, n_state) from the first n_state rows of Θ̃.

extract_G(n_state)

Return of shape (n_state, n_control) from the rows of Θ̃ after the state block.

Sources

  • Konatala, Milz, Weiser, Looye, van Kampen. "Flight Testing Reinforcement Learning based Online Adaptive Flight Control Laws on CS-25 Class Aircraft", AIAA SCITECH 2024, DOI 10.2514/6.2024-2402.
  • Sieberling, Chu, Mulder. "Robust Flight Control Using Incremental Nonlinear Dynamic Inversion and Angular Acceleration Prediction", J. Guid. Control Dyn., 2010.
  • Lewis, Vrabie, Syrmos. "Optimal Control", Wiley, 2012 — LQT theory underpinning the quadratic cost-to-go.