2121cuthbert's `Filter.filter_prepare`/`filter_combine` primitives one step at a
2222time instead of over a whole pre-supplied trajectory. This works for any
2323filter 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
2933Important: 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}`),
4448"""
4549
4650import dataclasses
47- from typing import Any , Protocol , runtime_checkable
51+ from typing import Protocol , runtime_checkable
4852
4953import jax
5054import jax .numpy as jnp
5155import jax .random as jr
56+ import numpyro .distributions as dist
5257from jax import Array
5358from jaxtyping import PRNGKeyArray , PyTree , Real
5459from numpyro .distributions import Distribution
5560
5661from dynestyx .inference .configs .filter import BaseFilterConfig
5762from dynestyx .inference .filters import _default_filter_config
5863from 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
6168from dynestyx .models import DynamicalModel
6269from dynestyx .simulation .base import BaseSimulator
6370from 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
87115class 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]
0 commit comments