Skip to content

Commit c5b518e

Browse files
committed
Update to filter update and
Make filter_obj/filter_config mutually exclusive; add filter_state_dist compute_cuthbert_filter_update now takes filter_config: BaseFilterConfig | None and requires exactly one of filter_config/filter_obj, matching how DiscreteControlLoopSimulator.simulate() already calls it with a pre-built filter_obj. Also add filter_state_dist(state) -> provides a numpyro distribution from the states. This will try to infer the right distribution given the state (mean, cov -> Gaussian, particles -> weighted/uniform particles. Modified the control loop to take this into account as well as the MPPI definition and the example notebooks
1 parent 4196aed commit c5b518e

8 files changed

Lines changed: 249 additions & 132 deletions

File tree

docs/tutorials/control/controller_demo.ipynb

Lines changed: 52 additions & 52 deletions
Large diffs are not rendered by default.

docs/tutorials/control/mpc_demo.ipynb

Lines changed: 21 additions & 21 deletions
Large diffs are not rendered by default.

dynestyx/control/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
ControlledSimulatedResult,
55
DiscreteControlLoopSimulator,
66
PolicyCallable,
7+
filter_state_dist,
78
filter_state_mean,
89
)
910
from dynestyx.control.mppi import MPPI
@@ -13,5 +14,6 @@
1314
"DiscreteControlLoopSimulator",
1415
"MPPI",
1516
"PolicyCallable",
17+
"filter_state_dist",
1618
"filter_state_mean",
1719
]

dynestyx/control/discrete_controller_simulators.py

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@
2121
cuthbert's `Filter.filter_prepare`/`filter_combine` primitives one step at a
2222
time instead of over a whole pre-supplied trajectory. This works for any
2323
filter family exposed there (`KFConfig`, `EKFConfig`, `EnKFConfig`,
24-
`PFConfig`); the belief `x_hat` returned each step is therefore whatever
25-
state type that family produces (e.g. a Kalman-family state with a `.mean`
26-
property, or a `ParticleFilterState` with `.particles`/`.log_weights`) --
27-
see `filter_state_mean` below for a family-agnostic point estimate.
24+
`PFConfig`); the raw belief each step produces is family-specific (e.g. a
25+
Kalman-family state with a `.mean`/`.chol_cov`, or a `ParticleFilterState`
26+
with `.particles`/`.log_weights`), so before it's handed to `control_policy`
27+
it's converted via `filter_state_dist` into a family-agnostic NumPyro
28+
`Distribution` (`MultivariateNormal` for the Gaussian families,
29+
`WeightedParticles` for `PFConfig`) -- a policy can call `.mean` for a point
30+
estimate, or use the full distribution (e.g. `.sample`) for risk-aware
31+
planning.
2832
2933
Important: the control passed to `dynamics.observation_model` for
3034
`y_{k+1}` is `u_k` (the control that drove the transition into `x_{k+1}`),
@@ -44,20 +48,23 @@
4448
"""
4549

4650
import dataclasses
47-
from typing import Any, Protocol, runtime_checkable
51+
from typing import Protocol, runtime_checkable
4852

4953
import jax
5054
import jax.numpy as jnp
5155
import jax.random as jr
56+
import numpyro.distributions as dist
5257
from jax import Array
5358
from jaxtyping import PRNGKeyArray, PyTree, Real
5459
from numpyro.distributions import Distribution
5560

5661
from dynestyx.inference.configs.filter import BaseFilterConfig
5762
from dynestyx.inference.filters import _default_filter_config
5863
from dynestyx.inference.integrations.cuthbert.discrete_filter import (
64+
build_cuthbert_filter,
5965
compute_cuthbert_filter_update,
6066
)
67+
from dynestyx.inference.integrations.utils import WeightedParticles
6168
from dynestyx.models import DynamicalModel
6269
from dynestyx.simulation.base import BaseSimulator
6370
from dynestyx.simulation.utils import _ensure_trailing_dim, _tile_times
@@ -83,15 +90,38 @@ def filter_state_mean(state) -> Array:
8390
raise TypeError(f"Cannot summarize filter state of type {type(state).__name__}")
8491

8592

93+
def filter_state_dist(state) -> Distribution:
94+
"""Full-belief NumPyro distribution for a cuthbert filter state, any family.
95+
96+
Kalman-family states (`KFConfig`, `EKFConfig`, `EnKFConfig`) expose
97+
`.mean`/`.chol_cov`, giving an exact `MultivariateNormal`. `PFConfig`
98+
states have no such property -- their belief is a weighted particle
99+
cloud (`.particles`, `.log_weights`), represented via `WeightedParticles`
100+
(dynestyx's own `Distribution`; NumPyro has no built-in equivalent).
101+
Unlike `filter_state_mean`, this does not broadcast over a leading
102+
time/batch axis -- call it once per (unbatched) state.
103+
"""
104+
if hasattr(state, "chol_cov"):
105+
return dist.MultivariateNormal(state.mean, scale_tril=state.chol_cov)
106+
if hasattr(state, "particles") and hasattr(state, "log_weights"):
107+
log_weights = jax.nn.log_softmax(state.log_weights, axis=-1)
108+
return WeightedParticles(state.particles, log_weights)
109+
raise TypeError(
110+
f"Cannot build a distribution for filter state of type {type(state).__name__}"
111+
)
112+
113+
86114
@runtime_checkable
87115
class PolicyCallable(Protocol):
88116
r"""Structural protocol for a control policy $\pi$.
89117
90118
$$u_k, s_{k+1} = \pi(\hat x_{k|k}, t_k, t_{k+1}, s_k)$$
91119
92-
`x_hat` is whatever belief state the chosen `filter_config` family
93-
produces (see module docstring); use `filter_state_mean` for a
94-
family-agnostic point estimate. `t_now`/`t_next` are the current and next
120+
`x_hat` is a NumPyro `Distribution` -- `MultivariateNormal` for
121+
`KFConfig`/`EKFConfig`/`EnKFConfig`, `WeightedParticles` for `PFConfig`
122+
(see module docstring and `filter_state_dist`); use `x_hat.mean` for a
123+
family-agnostic point estimate, or the distribution itself for
124+
uncertainty-aware planning. `t_now`/`t_next` are the current and next
95125
times -- always passed, even to a policy that ignores them, so that a
96126
policy needing genuine time-dependence (e.g. `dynestyx.control.mppi.MPPI`,
97127
which plans forward from `t_now`) doesn't need special-casing. Any plain
@@ -110,7 +140,11 @@ class PolicyCallable(Protocol):
110140
"""
111141

112142
def __call__(
113-
self, x_hat: Any, t_now: Real[Array, ""], t_next: Real[Array, ""], s: PyTree
143+
self,
144+
x_hat: Distribution,
145+
t_now: Real[Array, ""],
146+
t_next: Real[Array, ""],
147+
s: PyTree,
114148
) -> tuple[Real[Array, " control_dim"], PyTree]:
115149
raise NotImplementedError()
116150

@@ -209,6 +243,7 @@ def simulate(
209243
)
210244

211245
key, k_x0, k_y0, k_filt0 = jr.split(rng_key, 4)
246+
filter_obj = build_cuthbert_filter(dynamics, filter_config, key=rng_key)
212247

213248
x_0 = dynamics.initial_condition.sample(k_x0)
214249
y_0 = dynamics.observation_model(x_0, None, times[0]).sample(k_y0)
@@ -226,13 +261,14 @@ def simulate(
226261
dt0 = times[1] - times[0] if T > 1 else jnp.asarray(1.0, dtype=times.dtype)
227262
x_hat_0 = compute_cuthbert_filter_update(
228263
dynamics,
229-
filter_config,
264+
None,
230265
None,
231266
k_filt0,
232267
y=y_0,
233268
u=None,
234269
t=times[0],
235270
t_prev=times[0] - dt0,
271+
filter_obj=filter_obj,
236272
)
237273
initial_state_fn = getattr(self.control_policy, "initial_state", None)
238274
s_0 = initial_state_fn() if callable(initial_state_fn) else None
@@ -243,7 +279,9 @@ def _step(carry, t_idx):
243279
t_now = times[t_idx]
244280
t_next = times[t_idx + 1]
245281

246-
u_k, s_next = self.control_policy(x_hat_prev, t_now, t_next, s_prev)
282+
u_k, s_next = self.control_policy(
283+
filter_state_dist(x_hat_prev), t_now, t_next, s_prev
284+
)
247285
if isinstance(u_k, Distribution):
248286
raise ValueError(
249287
"Returning a distribution is not yet supported, instead "
@@ -258,13 +296,14 @@ def _step(carry, t_idx):
258296

259297
x_hat_next = compute_cuthbert_filter_update(
260298
dynamics,
261-
filter_config,
299+
None,
262300
x_hat_prev,
263301
k_filt,
264302
y=y_next,
265303
u=u_k,
266304
t=t_next,
267305
t_prev=t_now,
306+
filter_obj=filter_obj,
268307
)
269308

270309
new_carry = (x_next, x_hat_next, s_next, step_key)
@@ -320,5 +359,6 @@ def _step(carry, t_idx):
320359
"ControlledSimulatedResult",
321360
"DiscreteControlLoopSimulator",
322361
"PolicyCallable",
362+
"filter_state_dist",
323363
"filter_state_mean",
324364
]

dynestyx/control/mppi.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
import jax.numpy as jnp
1717
import jax.random as jr
1818
from jax import Array
19-
from jaxtyping import PRNGKeyArray, PyTree, Real
19+
from jaxtyping import PRNGKeyArray, Real
20+
from numpyro.distributions import Distribution
2021

21-
from dynestyx.control.discrete_controller_simulators import filter_state_mean
2222
from dynestyx.models import DynamicalModel
2323

2424

@@ -146,7 +146,7 @@ def step(carry, u_and_idx):
146146

147147
def __call__(
148148
self,
149-
x_hat: PyTree,
149+
x_hat: Distribution,
150150
t_now: Real[Array, ""],
151151
t_next: Real[Array, ""],
152152
s: tuple[Real[Array, "horizon control_dim"], PRNGKeyArray],
@@ -157,7 +157,7 @@ def __call__(
157157
# t_next (the real simulation's next observation time) is unused --
158158
# MPPI plans its own horizon-step lookahead from t_now using its own dt.
159159
del t_next
160-
x0 = filter_state_mean(x_hat)
160+
x0 = x_hat.mean
161161
nominal, key = s
162162
key, noise_key, rollout_key = jr.split(key, 3)
163163
control_dim = nominal.shape[-1]

dynestyx/inference/integrations/cuthbert/discrete_filter.py

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -204,52 +204,60 @@ def _build_cuthbert_filter_obj(
204204
return filter_obj, parallel
205205

206206

207-
def compute_cuthbert_filter_update(
207+
def build_cuthbert_filter(
208208
dynamics: DynamicalModel,
209209
filter_config: BaseFilterConfig,
210+
key: jax.Array | None = None,
211+
):
212+
"""Build the cuthbert Filter object for `(dynamics, filter_config)`.
213+
214+
Depends only on `dynamics`/`filter_config`, never on trajectory data, so
215+
it can be built once and reused across many `compute_cuthbert_filter_update`
216+
calls via that function's `filter_obj=` argument -- avoids rebuilding it
217+
on every step. `key` is only required (non-`None`) for `PFConfig`/`EnKFConfig`.
218+
"""
219+
filter_kwargs = _config_to_filter_kwargs(filter_config)
220+
filter_obj, _ = _build_cuthbert_filter_obj(
221+
dynamics, filter_config, filter_kwargs, key, want_parallel=False
222+
)
223+
return filter_obj
224+
225+
226+
def compute_cuthbert_filter_update(
227+
dynamics: DynamicalModel,
228+
filter_config: BaseFilterConfig | None,
210229
prev_state,
211230
key: jax.Array,
212231
*,
213232
y: jax.Array,
214233
u: jax.Array | None,
215234
t: jax.Array,
216235
t_prev: jax.Array | None = None,
236+
filter_obj=None,
217237
):
218-
r"""One-step FilterUpdate: state_k + u_k + y_{k+1} -> state_{k+1}.
219-
220-
Unlike `compute_cuthbert_filter` (whole-trajectory), this performs exactly
221-
one predict+update step using cuthbert's `Filter.filter_prepare`/
222-
`filter_combine` primitives directly, without requiring future
223-
observations. This is what makes online control possible: the state
224-
returned here can be consumed by a policy to choose the next control
225-
before the next observation exists.
226-
227-
Pass `prev_state=None` for the bootstrap call (computing the filtering
228-
state after only the first observation, with no control history yet);
229-
this internally calls the cuthbert filter's `init_prepare` first.
230-
231-
Control convention (important, and different from `compute_cuthbert_filter`
232-
/ `DiscreteTimeSimulator`): `u` is the control that drove the transition
233-
*into* the state being filtered, i.e. u_k when producing state_{k+1} from
234-
state_k and y_{k+1} -- matching `FilterUpdate(x_hat_k, u_k, y_{k+1}, ...)`
235-
in the control-loop equations. `compute_cuthbert_filter`/
236-
`DiscreteTimeSimulator` instead pair `ctrl_values[t]` with *both* the
237-
observation and the outgoing transition at the same index t, which is
238-
only valid when the whole control trajectory is already known in advance.
239-
For online control this is impossible: u_{k+1} cannot exist before
240-
y_{k+1} is observed, since it is computed by the policy from the filtered
241-
state that itself depends on y_{k+1}. So `u` here is used for both
242-
`CuthbertInputs.u` and `CuthbertInputs.u_prev` in the single-row input
243-
built for this step. Pass `u=None` for the bootstrap call, matching y_0's
244-
lack of a control argument in the control-loop equations (numerically
245-
equivalent to zeros for models with a control-input matrix, since D=None
246-
or u=None are both treated as "no control contribution").
238+
r"""One-step FilterUpdate: state_k + u_k + y_{k+1} -> state_{k+1}. Used for online filtering.
239+
240+
Unlike `compute_cuthbert_filter` (whole-trajectory), this performs one
241+
predict+update step directly via cuthbert's `Filter.filter_prepare`/
242+
`filter_combine`
243+
Pass `prev_state=None` for the bootstrap call
244+
(first observation, no control history yet); this runs `init_prepare`
245+
first.
246+
247+
`u` is the control that drove the transition *into* the state being
248+
filtered (u_k, producing state_{k+1} from y_{k+1}).
249+
250+
Provide exactly one of `filter_config` (builds the filter internally) or
251+
`filter_obj` (an already-built filter from `build_cuthbert_filter`, to
252+
reuse across repeated calls instead of rebuilding it here).
247253
"""
248-
filter_kwargs = _config_to_filter_kwargs(filter_config)
254+
if (filter_config is None) == (filter_obj is None):
255+
raise ValueError("Provide exactly one of filter_config or filter_obj.")
256+
249257
key_state, key_prep = jr.split(key)
250-
filter_obj, _ = _build_cuthbert_filter_obj(
251-
dynamics, filter_config, filter_kwargs, key_state, want_parallel=False
252-
)
258+
if filter_obj is None:
259+
assert filter_config is not None
260+
filter_obj = build_cuthbert_filter(dynamics, filter_config, key_state)
253261

254262
control_dim = dynamics.control_dim
255263
u_arr = jnp.zeros((control_dim,)) if u is None else jnp.asarray(u)

dynestyx/inference/integrations/utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ def sample(self, key, sample_shape=()):
114114
def log_prob(self, value):
115115
raise NotImplementedError("log_prob is not implemented for WeightedParticles.")
116116

117+
@property
118+
def mean(self) -> jax.Array:
119+
"""Weighted mean of the particles."""
120+
weights = jax.nn.softmax(self.log_weights, axis=-1)
121+
return jnp.sum(weights[..., None] * self.particles, axis=-2)
122+
117123

118124
def particles_to_delta_mixtures(
119125
particles: Real[Array, "*plate time n_particles state_dim"]

0 commit comments

Comments
 (0)