ekalmX — Architecture¶
Overview¶
ekalmX follows a three-layer progressive disclosure architecture. Each layer is self-contained and useful alone. Lower layers are pure and composable; higher layers provide convenience.
Three-Layer Stack¶
┌─────────────────────────────────────────────────────────────────────────┐
│ Layer 2 — Models │
│ Ready-to-use ensemble methods. Minimal config, maximal convenience. │
│ Sequential: LETKF, ETKF, EnSRF, ESTKF, StochasticEnKF │
│ Processes: EKI, EKS, UKI, ETKI, GNKI │
│ optax: EKI/EKS/UKI as optax.GradientTransformation │
├─────────────────────────────────────────────────────────────────────────┤
│ Layer 1 — Components │
│ Protocols, building blocks, configurable filter/process steps. │
│ AbstractSequentialFilter, AbstractProcess │
│ AbstractDynamics, AbstractObsOperator, AbstractLocalizer │
│ AbstractInflator, AbstractNoise, AbstractScheduler │
│ FilterState, ProcessState, AnalysisResult │
├─────────────────────────────────────────────────────────────────────────┤
│ Layer 0 — Primitives │
│ Pure functions. Stateless, differentiable, composable. │
│ ensemble_mean, ensemble_anomalies, ensemble_covariance │
│ kalman_gain, cross_covariance, log_likelihood │
│ gaspari_cohn, localize, inflate, perturbed_observations │
│ create_patches, assign_obs, blend_patches │
└─────────────────────────────────────────────────────────────────────────┘
Not part of the library API:
┌─────────────────────────────────────────────────────────────────────────┐
│ Zoo — Reference implementations (educational, not maintained to core │
│ standard). Continuous-time filters, dynamical systems for testing. │
└─────────────────────────────────────────────────────────────────────────┘
Protocols¶
ekalmX defines two protocol families for its two modes of operation, plus shared extension points for user-supplied components.
Filter protocols (sequential state estimation)¶
class AbstractSequentialFilter(eqx.Module):
"""One analysis update per assimilation window."""
@abc.abstractmethod
def forecast(
self,
particles: Float[Array, "N_e N_x"],
dynamics: AbstractDynamics,
t0: Scalar,
t1: Scalar,
) -> Float[Array, "N_e N_x"]:
"""Propagate ensemble forward in time."""
...
@abc.abstractmethod
def analysis(
self,
particles: Float[Array, "N_e N_x"],
obs: Float[Array, "N_y"],
obs_op: AbstractObsOperator,
obs_noise: AbstractLinearOperator,
) -> AnalysisResult:
"""Assimilate observations into ensemble."""
...
Process protocols (iterative parameter estimation)¶
class AbstractProcess(eqx.Module):
"""Iterate until convergence (EKI, EKS, UKI, etc.)."""
@abc.abstractmethod
def init(
self,
particles: Float[Array, "J N_p"],
obs: Float[Array, "N_d"],
noise_cov: AbstractLinearOperator,
) -> ProcessState:
...
@abc.abstractmethod
def update(
self,
state: ProcessState,
forward_evals: Float[Array, "J N_d"],
) -> ProcessState:
"""Single EKP update step. Caller provides forward evaluations."""
...
optax interface (processes as gradient transformations)¶
def eki_as_optax(
forward_fn: Callable,
obs: Float[Array, "N_d"],
noise_cov: AbstractLinearOperator,
scheduler: AbstractScheduler,
**kwargs,
) -> optax.GradientTransformation:
"""Wrap EKI as an optax GradientTransformation.
The caller provides forward evaluations (not gradients).
Compatible with optax.chain, schedules, clipping, logging.
Parallel to optax_bayes: both are structured update rules
that fit the optax init → update → state pattern.
"""
...
Shared extension points¶
class AbstractDynamics(eqx.Module):
"""Forward model: propagate state from t0 to t1."""
@abc.abstractmethod
def __call__(
self, state: Float[Array, "N_x"], t0: Scalar, t1: Scalar
) -> Float[Array, "N_x"]:
...
class AbstractObsOperator(eqx.Module):
"""Map state space → observation space."""
@abc.abstractmethod
def __call__(self, state: Float[Array, "N_x"]) -> Float[Array, "N_y"]:
...
class AbstractLocalizer(eqx.Module):
"""Covariance localization via tapering."""
@abc.abstractmethod
def __call__(
self, cov: Float[Array, "N_x N_y"], coords: Any
) -> Float[Array, "N_x N_y"]:
...
class AbstractInflator(eqx.Module):
"""Ensemble inflation to maintain spread."""
@abc.abstractmethod
def __call__(
self, particles: Float[Array, "N_e N_x"], **kwargs
) -> Float[Array, "N_e N_x"]:
...
class AbstractNoise(eqx.Module):
"""Noise model backed by gaussx operators."""
@abc.abstractmethod
def covariance(self) -> AbstractLinearOperator:
"""Return covariance as a gaussx/lineax operator."""
...
@abc.abstractmethod
def sample(self, key: PRNGKey, shape: tuple) -> Array:
...
class AbstractScheduler(eqx.Module):
"""Learning rate / step size for EKP processes."""
@abc.abstractmethod
def get_dt(self, state: ProcessState) -> Scalar:
...
All protocols are eqx.Module subclasses — pytree-compatible, JIT-friendly, serializable.
Compatibility with pipekit-cycle¶
filterax's protocols are designed to map cleanly onto the three Protocols in pipekit-cycle (ForwardModel, ObservationOperator, AnalysisStep) — but the names and signatures differ where DA-domain conventions diverge from pipekit's generic surface. Bridging is one line of user code per concept; filterax does not ship aliases or import pipekit.
| filterax abstract | pipekit-cycle Protocol | Bridge |
|---|---|---|
AbstractDynamics.__call__(state, t0, t1) |
ForwardModel.step(state, dt) |
User wraps with a step(state, dt) method calling self._dyn(state, 0.0, dt) |
AbstractObsOperator.__call__(state) |
ObservationOperator.__call__(state) |
Identical surface — no wrapper needed |
AbstractSequentialFilter.analysis(forecast, obs, obs_op, obs_noise) |
AnalysisStep.__call__(forecast, obs, *, obs_op, obs_err_cov) |
User wraps with a __call__(forecast, obs, *, obs_op, obs_err_cov) method calling self._filter.analysis(...) |
The user wrappers are short (under 10 lines each); @runtime_checkable makes isinstance(wrapper, pipekit_cycle.AnalysisStep) pass at runtime without inheritance. See D11 and integrations/pipekit.md §4 for the concrete snippets and rationale.
Key Data Types¶
Core state containers¶
class FilterState(eqx.Module):
"""Running state for sequential filters."""
particles: Float[Array, "N_e N_x"]
step: int = eqx.field(static=True)
class ProcessState(eqx.Module):
"""Running state for EKP processes."""
particles: Float[Array, "J N_p"]
forward_evals: Float[Array, "J N_d"]
obs: Float[Array, "N_d"]
noise_cov: AbstractLinearOperator
step: int = eqx.field(static=True)
algo_time: float
class UKIState(eqx.Module):
"""Parametric state for Unscented Kalman Inversion."""
mean: Float[Array, "N_p"]
covariance: AbstractLinearOperator
step: int = eqx.field(static=True)
Analysis output¶
class AnalysisResult(eqx.Module):
"""Output of any analysis/update step."""
particles: Float[Array, "N_e N_x"]
log_likelihood: Optional[Scalar] = None
diagnostics: Optional[dict] = None
Configuration¶
class FilterConfig(eqx.Module):
"""Static configuration for sequential filters."""
localizer: Optional[AbstractLocalizer] = None
inflator: Optional[AbstractInflator] = None
n_ensemble: int = eqx.field(static=True)
class ProcessConfig(eqx.Module):
"""Static configuration for EKP processes."""
scheduler: AbstractScheduler
n_iterations: int = eqx.field(static=True)
Design notes:
- Store particles only — compute mean, anomalies, covariance on demand via L0 primitives.
- Ensemble dimension is the leading axis (N_e, N_x) for natural eqx.filter_vmap over members.
- log_likelihood is optional — only computed when needed for differentiable training.
- Configuration is eqx.Module — serializable, pytree-compatible, JIT-friendly.
- particles is a bare JAX array. Coordinate-aware carriers (coordax.Array, GeoTensor) are
handled via a CarrierAdapter outside the core type — see D13 and
integrations/geostack.md.
- State types are fully eqx.tree_serialise_leaves-compatible — see D15 and
features/state_persistence.md for the persistence contract.
Carrier Adapter Layer¶
For consumers that want coordinate-aware ensemble particles (CRS, named dimensions, time coordinates) the core types stay JAX-array only; a CarrierAdapter flattens to and unflattens from a plain (N_e, N_x) array around analysis calls. The interface lives in filterax.integrations:
class CarrierAdapter(eqx.Module):
"""Round-trip a coordinate-aware carrier to/from FilterState.particles."""
@abc.abstractmethod
def flatten(self, carrier) -> tuple[Float[Array, "N_e N_x"], "CarrierMeta"]:
"""Project carrier to a (N_e, N_x) array and a static metadata record."""
...
@abc.abstractmethod
def unflatten(
self, particles: Float[Array, "N_e N_x"], meta: "CarrierMeta"
) -> Any:
"""Reconstruct the original carrier from particles + metadata."""
...
Filters see only Float[Array, "N_e N_x"]. The adapter is user-facing convenience; the math layer stays minimal. filterax ships only the interface plus a DictAdapter reference implementation for dict[str, Array] schemas (no optional deps). Coordinate-aware adapters (CoordaxAdapter, GeoTensorAdapter, …) are downstream-owned — they live in the libraries that own those carriers, not in filterax. See integrations/geostack.md for the expected adapter shape.
Serialization Contract¶
Every state and config type is fully eqx.tree_serialise_leaves-compatible (D15). filterax exposes two helpers in Wave 4:
filterax.save_state(path: str | Path, state: FilterState | ProcessState | UKIState) -> None
filterax.load_state(path: str | Path, like: FilterState | ProcessState | UKIState) -> FilterState | ProcessState | UKIState
These wrap eqx.tree_serialise_leaves / eqx.tree_deserialise_leaves with a magic-byte header (b"FAX1") so future schema changes are detectable. All static fields on state types must be JSON-serializable scalars; custom classes in static fields are rejected at construction.
See features/state_persistence.md for the full contract, warm-start patterns, and the operational-alert recipe.
Package Layout¶
ekalmx/
├── __init__.py # Public API re-exports
├── _src/
│ ├── _protocols.py # AbstractSequentialFilter, AbstractProcess,
│ │ # AbstractDynamics, AbstractObsOperator,
│ │ # AbstractLocalizer, AbstractInflator,
│ │ # AbstractNoise, AbstractScheduler
│ ├── _types.py # FilterState, ProcessState, UKIState,
│ │ # AnalysisResult, FilterConfig, ProcessConfig
│ │
│ ├── # --- Layer 0: Primitives ---
│ ├── statistics.py # ensemble_mean, ensemble_anomalies,
│ │ # ensemble_covariance, cross_covariance
│ ├── gain.py # kalman_gain (gaussx-backed)
│ ├── likelihood.py # log_likelihood, innovation_statistics
│ ├── localization.py # gaspari_cohn, gaussian_taper, hard_cutoff
│ ├── inflation.py # multiplicative, rtps, rtpp, adaptive
│ ├── perturbations.py # perturbed_observations
│ ├── patches.py # create_patches, assign_obs, blend_patches
│ │
│ ├── # --- Layer 1: Components ---
│ ├── filters/ # Sequential filter analysis steps
│ │ ├── stochastic_enkf.py # StochasticEnKF
│ │ ├── etkf.py # ETKF, ETKF_Livings
│ │ ├── ensrf.py # EnSRF (Whitaker, serial variants via config)
│ │ ├── estkf.py # ESTKF
│ │ └── letkf.py # LETKF
│ ├── processes/ # Iterative EKP update steps
│ │ ├── eki.py # EKI (Ensemble Kalman Inversion)
│ │ ├── eks.py # EKS / ALDI (Ensemble Kalman Sampler)
│ │ ├── uki.py # UKI (Unscented Kalman Inversion)
│ │ ├── etki.py # ETKI (Ensemble Transform KI)
│ │ └── gnki.py # GNKI (Gauss-Newton KI)
│ │
│ ├── # --- Layer 2: Models ---
│ ├── models/
│ │ ├── sequential.py # Full filter loop: forecast → analysis → inflate
│ │ └── inversion.py # Full process loop: init → update → convergence
│ ├── optax/
│ │ └── transforms.py # EKI/EKS/UKI as optax.GradientTransformation
│ │
│ └── utils/
│ └── diagnostics.py # spread, rank_histogram, innovation_check,
│ # desroziers_consistency
│
├── zoo/ # Reference implementations (not core API)
│ ├── continuous/ # Kalman-Bucy, EKF-Bucy, UKF-Bucy, etc.
│ └── dynamical_systems/ # L63, L96, KS (for testing/demos)
│
├── docs/
├── notebooks/
└── tests/
Dependency Stack¶
Required¶
| Package | Version | Role |
|---|---|---|
jax |
>= 0.5 | Core array framework |
jaxlib |
>= 0.5 | Backend |
equinox |
>= 0.11 | Module system, pytrees |
gaussx |
>= 0.1 | Structured covariance operators, ensemble_covariance, kalman_gain |
optax |
>= 0.2 | EKP as GradientTransformation, learning rate schedules |
jaxtyping |
>= 0.2.28 | Shape annotations |
beartype |
>= 0.18 | Runtime type checking |
einops |
>= 0.8 | Readable tensor operations |
Optional¶
| Package | Version | Role | Used by |
|---|---|---|---|
diffrax |
>= 0.5 | ODE/SDE integration | Zoo continuous-time filters, ODE-based dynamics |
optimistix |
>= 0.1 | Solver backends | EKI fixed-point, advanced inner minimization |
lineax |
>= 0.1 | Linear solvers | Transitive via gaussx, advanced covariance ops |
Zoo / examples only (not library dependencies)¶
| Package | Role |
|---|---|
matplotlib |
Visualization |
xarray |
Structured output |
geo_toolz |
Pre/post-processing |
somax |
Ocean model examples |
Dependency diagram¶
┌───────────────┐
│ jax, equinox │
│ einops │
└───────┬───────┘
│ required
┌───────▼───────┐
│ gaussx │ structured covariance ops
└───────┬───────┘
│ required
┌───────▼───────┐
│ ekalmX │
└──┬────┬────┬──┘
│ │ │
┌─────────┘ │ └─────────┐
│ required │ optional │ optional
┌─────▼─────┐ ┌────▼─────┐ ┌─────▼──────┐
│ optax │ │ diffrax │ │ optimistix │
│ (EKP as │ │ (ODE │ │ (solver │
│ optax │ │ dynamics)│ │ backends) │
│ transform│ │ │ │ │
└───────────┘ └──────────┘ └────────────┘
zoo/examples only (not library deps):
matplotlib, xarray, geo_toolz, somax
gaussx Integration Points¶
ekalmX delegates structured covariance operations to gaussx rather than reimplementing them.
| ekalmX operation | gaussx backend | Benefit |
|---|---|---|
| Ensemble covariance | gaussx.recipes.ensemble_covariance → LowRankUpdate |
Rank-aware, never N×N dense |
| Cross-covariance | gaussx.recipes.ensemble_cross_covariance |
Structured matrix |
| Kalman gain K | gaussx.recipes.kalman_gain |
Woodbury solve when J << N |
| Obs noise R | lineax.DiagonalLinearOperator |
Trivial inversion |
| Localization taper | Hadamard product on operator | Structure-preserving |
| Log-likelihood | gaussx.ops.logdet on innovation covariance |
Structure-exploiting logdet |
| Process noise Q | gaussx.operators.LowRankUpdate or Diagonal |
Flexible structure |
optax Integration¶
EKP processes wrap as optax.GradientTransformation for composability with the optax ecosystem:
import ekalmx
import optax
# EKI as optax transform — parallel to optax_bayes
eki_transform = ekalmx.optax.eki(
forward_fn=simulator,
obs=observations,
noise_cov=R,
scheduler=ekalmx.DataMisfitController(),
n_ensemble=100,
)
# Compose with optax utilities
optimizer = optax.chain(
eki_transform,
optax.clip_by_global_norm(1.0),
)
# Standard optax loop
state = optimizer.init(params)
for step in range(n_steps):
updates, state = optimizer.update(grads=None, state=state, params=params)
params = optax.apply_updates(params, updates)
The caller provides forward evaluations (not gradients). The grads argument is unused — the ensemble provides the search directions. This mirrors optax_bayes where both are structured update rules fitting the init → update → state pattern.
CI / Quality Gates¶
| Check | Command | Scope |
|---|---|---|
| Tests | uv run pytest tests -x |
Full suite |
| Lint | uv run ruff check . |
Entire repo |
| Format | uv run ruff format --check . |
Entire repo |
| Typecheck | uv run ty check ekalmx |
Package only |
All four must pass before merge. GitHub Actions on push/PR.
Conventional commits required (feat:, fix:, docs:, test:, etc.).
Build system: hatchling (PEP 621) Python: >= 3.12, < 3.14 License: MIT