ekalmX — Layer 2: Models¶
High-level, ready-to-use ensemble methods. Minimal boilerplate — configure, call, get results.
Layer 2 models compose L0 primitives and L1 components into complete workflows: forecast-analysis-inflate loops for filters, init-update-converge loops for processes.
Sequential Filter Models¶
LETKF¶
class LETKF(eqx.Module):
"""Local Ensemble Transform Kalman Filter — full assimilation loop.
Composes: AbstractDynamics (forecast) → L1 LETKF (analysis)
→ AbstractInflator (post-processing), repeated over observation windows.
Parameters
----------
dynamics : AbstractDynamics
Forward model. Applied via eqx.filter_vmap over ensemble.
obs_op : AbstractObsOperator
Maps state → observation space.
localizer : AbstractLocalizer
Covariance localization (required for LETKF).
inflator : optional AbstractInflator
Ensemble inflation. Default: no inflation.
config : FilterConfig
Static settings (n_ensemble, etc.).
"""
dynamics: AbstractDynamics
obs_op: AbstractObsOperator
localizer: AbstractLocalizer
inflator: Optional[AbstractInflator] = None
config: FilterConfig = FilterConfig(n_ensemble=50)
def assimilate(
self,
init_ensemble: Float[Array, "N_e N_x"],
observations: list[tuple[Float[Array, "N_y"], Scalar]],
obs_noise: AbstractLinearOperator,
t0: Scalar = 0.0,
dt: Scalar = 1.0,
state_coords: Optional[Float[Array, "N_x D"]] = None,
obs_coords: Optional[Float[Array, "N_y D"]] = None,
) -> AssimilationResult:
"""Run full assimilation over observation sequence.
Parameters
----------
init_ensemble : initial ensemble (N_e, N_x)
observations : list of (obs_values, obs_time) pairs
obs_noise : observation error covariance R
t0 : initial time
dt : time step between observation windows
state_coords : grid point coordinates (for localization)
obs_coords : observation coordinates (for localization)
Returns
-------
AssimilationResult with final ensemble and per-window history
"""
...
def assimilate_and_smooth(
self,
init_ensemble: Float[Array, "N_e N_x"],
observations: list[tuple[Float[Array, "N_y"], Scalar]],
obs_noise: AbstractLinearOperator,
smoother: Optional[EnKS] = None,
**kwargs,
) -> AssimilationResult:
"""Filter forward, then smooth backward.
Convenience method combining filter + smoother in one call.
"""
...
ETKF (L2)¶
class ETKF(eqx.Module):
"""Ensemble Transform Kalman Filter — full assimilation loop.
Same interface as LETKF but without mandatory localization.
Suitable for low-dimensional problems or when localization
is not needed.
"""
dynamics: AbstractDynamics
obs_op: AbstractObsOperator
localizer: Optional[AbstractLocalizer] = None
inflator: Optional[AbstractInflator] = None
config: FilterConfig = FilterConfig(n_ensemble=50)
def assimilate(self, init_ensemble, observations, obs_noise, **kwargs):
...
EnSRF (L2)¶
class EnSRF(eqx.Module):
"""Ensemble Square Root Filter — full assimilation loop.
Wraps L1 EnSRF with forecast-analysis-inflate loop.
"""
dynamics: AbstractDynamics
obs_op: AbstractObsOperator
localizer: Optional[AbstractLocalizer] = None
inflator: Optional[AbstractInflator] = None
whitaker: bool = eqx.field(static=True, default=True)
serial: bool = eqx.field(static=True, default=False)
config: FilterConfig = FilterConfig(n_ensemble=50)
def assimilate(self, init_ensemble, observations, obs_noise, **kwargs):
...
AssimilationResult¶
class AssimilationResult(eqx.Module):
"""Output of a full assimilation run."""
particles: Float[Array, "N_e N_x"] # Final analysis ensemble
history: list[AnalysisResult] # Per-window results
forecast_history: list[Float[Array, "N_e N_x"]] # Per-window forecasts
log_likelihood: Optional[Scalar] = None # Total log-likelihood
Process Models (EKP)¶
EKI (L2)¶
class EKI(eqx.Module):
"""Ensemble Kalman Inversion — full process loop.
Composes: L1 EKI process + scheduler + convergence check.
Parameters
----------
forward_fn : callable
Forward model: params → predictions.
Applied via eqx.filter_vmap over ensemble.
obs : observation vector
noise_cov : observation noise covariance
scheduler : step size strategy (default: DataMisfitController)
config : ProcessConfig (n_iterations, etc.)
"""
forward_fn: Callable
obs: Float[Array, "N_d"]
noise_cov: AbstractLinearOperator
scheduler: AbstractScheduler = DataMisfitController()
config: ProcessConfig = ProcessConfig(scheduler=DataMisfitController(), n_iterations=50)
def run(
self,
init_particles: Float[Array, "J N_p"],
) -> ProcessResult:
"""Run EKI to convergence.
Iterates: evaluate forward model → update ensemble → check convergence.
Terminates when n_iterations reached or scheduler signals completion.
Returns
-------
ProcessResult with calibrated ensemble, mean, and covariance
"""
...
def run_with_callback(
self,
init_particles: Float[Array, "J N_p"],
callback: Optional[Callable[[ProcessState, int], None]] = None,
) -> ProcessResult:
"""Run with per-iteration callback for logging/diagnostics."""
...
EKS (L2)¶
class EKS(eqx.Module):
"""Ensemble Kalman Sampler — full process loop.
Produces approximate posterior samples (ensemble does NOT collapse).
Runs longer than EKI — typically hundreds of iterations.
"""
forward_fn: Callable
obs: Float[Array, "N_d"]
noise_cov: AbstractLinearOperator
scheduler: AbstractScheduler = EKSStableScheduler()
config: ProcessConfig = ProcessConfig(scheduler=EKSStableScheduler(), n_iterations=500)
def run(self, init_particles) -> ProcessResult:
...
UKI (L2)¶
class UKI(eqx.Module):
"""Unscented Kalman Inversion — full process loop.
Parametric: maintains mean + covariance, not particles.
Uses 2p+1 sigma points for quadrature.
"""
forward_fn: Callable
obs: Float[Array, "N_d"]
noise_cov: AbstractLinearOperator
scheduler: AbstractScheduler = DataMisfitController()
alpha: float = eqx.field(static=True, default=1.0)
beta: float = eqx.field(static=True, default=2.0)
kappa: float = eqx.field(static=True, default=0.0)
config: ProcessConfig = ProcessConfig(scheduler=DataMisfitController(), n_iterations=50)
def run(
self,
init_mean: Float[Array, "N_p"],
init_cov: AbstractLinearOperator,
) -> ProcessResult:
...
ProcessResult¶
class ProcessResult(eqx.Module):
"""Output of a full process run."""
particles: Optional[Float[Array, "J N_p"]] # Final ensemble (None for UKI)
mean: Float[Array, "N_p"] # Posterior mean
covariance: AbstractLinearOperator # Posterior covariance
history: list[ProcessState] # Per-iteration states
converged: bool # Whether scheduler signaled completion
n_iterations: int # Actual iterations run
optax Transforms¶
EKP processes wrapped as optax.GradientTransformation for composability.
ekalmx.optax.eki¶
def eki(
forward_fn: Callable,
obs: Float[Array, "N_d"],
noise_cov: AbstractLinearOperator,
scheduler: AbstractScheduler = DataMisfitController(),
n_ensemble: int = 100,
seed: int = 0,
) -> optax.GradientTransformation:
"""Wrap EKI as an optax GradientTransformation.
The caller provides forward evaluations (not gradients).
The `grads` argument to update() is unused — the ensemble
provides search directions via cross-covariance.
Parallel to optax_bayes: both are structured update rules
fitting the optax init → update → state pattern.
Compatible with optax.chain, inject_hyperparams, etc.
Returns
-------
optax.GradientTransformation with init() and update() methods
"""
...
ekalmx.optax.eks¶
def eks(
forward_fn: Callable,
obs: Float[Array, "N_d"],
noise_cov: AbstractLinearOperator,
scheduler: AbstractScheduler = EKSStableScheduler(),
n_ensemble: int = 100,
seed: int = 0,
) -> optax.GradientTransformation:
"""Wrap EKS as an optax GradientTransformation.
Produces posterior samples — ensemble does not collapse.
"""
...
ekalmx.optax.uki¶
def uki(
forward_fn: Callable,
obs: Float[Array, "N_d"],
noise_cov: AbstractLinearOperator,
scheduler: AbstractScheduler = DataMisfitController(),
alpha: float = 1.0,
beta: float = 2.0,
kappa: float = 0.0,
) -> optax.GradientTransformation:
"""Wrap UKI as an optax GradientTransformation.
Parametric: state carries mean + covariance (not particles).
"""
...
Utilities¶
diagnostics¶
def spread(
particles: Float[Array, "N_e N_x"],
) -> Float[Array, "N_x"]:
"""Per-variable ensemble standard deviation."""
def rank_histogram(
particles: Float[Array, "T N_e N_x"],
truth: Float[Array, "T N_x"],
) -> Float[Array, "N_e+1"]:
"""Rank histogram (Talagrand diagram) for ensemble reliability.
Flat histogram = well-calibrated ensemble.
U-shaped = underdispersive. Dome = overdispersive.
"""
def innovation_check(
innovations: Float[Array, "T N_y"],
innovation_covs: list[AbstractLinearOperator],
) -> dict:
"""Desroziers et al. (2005) innovation consistency diagnostics.
Returns
-------
dict with:
mean_innovation : should be ≈ 0
normalized_variance : should be ≈ 1
chi_squared : chi² test statistic
"""
def effective_ensemble_size(
particles: Float[Array, "N_e N_x"],
) -> Scalar:
"""Effective ensemble size based on weight degeneracy.
For equally-weighted ensemble (standard EnKF): ESS = N_e.
Useful for particle filter diagnostics.
"""