Tutorial — Ensemble data assimilation¶
Runs an ensemble Kalman filter on a chaotic system for 200 cycles, with the work real assimilation systems spend most of their code on: an observation pre-processing chain (deduplication, bias correction, range checks), quality control inside the analysis (a first-guess check), and post-processing into verification scores and a product with its uncertainty.
pipekit owns none of the science here. EnsembleDACycle runs the cycle;
the forward model, the observation operator and the analysis step are
plain classes that satisfy its protocols, so the same structure takes a
weather model, an ocean model or an epidemic model, and an analysis from
any library.
Time: ~15 minutes (the script runs in a few seconds).
Prerequisites:
flowchart TD
subgraph obs ["obs_source: pre-processing"]
FE[Fetch<br/>raw reports for cycle i] --> DD[Dedupe] --> BC[BiasCorrect<br/>instrument B − 1.5] --> RC[RangeCheck] --> TV[ToVectors<br/>idx, y]
end
subgraph cyc ["EnsembleDACycle"]
M[(ensemble<br/>30 members)] --> FM[CallableForward<br/>Lorenz-96, per member]
FM --> AN[EnKF analysis<br/>inflation · first-guess check<br/>localized gain · perturbed obs]
TV --> AN
H[PointObs H] --> AN
AN -->|next cycle| M
AN --> LOG[(analysis log<br/>mean · spread · used · rejected)]
end
LOG --> SC[scores: RMSE vs truth · free run · spread-skill · obs counts]
SC --> RP[report: rounded, printed]
LOG --> PR([product: last analysis ± spread])
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, NoReturn
import numpy as np
from jaxtyping import Bool, Float, Int
from pipekit_ops import Operator, Sequential
from pipekit_cycle import CallableForward, DAState, EnsembleDACycle
# ── shapes ───────────────────────────────────────────────────────────────────
# N state variables (40)
# E ensemble members (30)
# T time levels of a trajectory (201 for the truth, 200 for analysis and free run)
# M observations that reach the analysis in one cycle (≤ 20)
# U observations the analysis uses after its first-guess check (≤ M)
Report = dict[str, Any]
State = Float[np.ndarray, " N"]
Trajectory = Float[np.ndarray, "T N"]
Ensemble = Float[np.ndarray, "E N"]
Covariance = Float[np.ndarray, "N N"]
Distances = Int[np.ndarray, "N N"]
Sites = Int[np.ndarray, " M"]
Values = Float[np.ndarray, " M"]
Passed = Bool[np.ndarray, " M"]
UsedSites = Int[np.ndarray, " U"]
UsedValues = Float[np.ndarray, " U"]
Gain = Float[np.ndarray, "N U"]
ObsErrorCov = Float[np.ndarray, "U U"]
PerturbedObs = Float[np.ndarray, "E U"]
# N, the number of Lorenz-96 variables
N: int = 40
# F, the forcing
F: float = 8.0
# Δt, one model step
DT: float = 0.01
# one cycle = 5 model steps = 0.05 time units (~6 h)
SUBSTEPS: int = 5
CYCLES: int = 200
MEMBERS: int = 30
# σ_o, the observation-error standard deviation
SIGMA_O: float = 1.0
Step 1 — The model, a truth run and its raw observations¶
Lorenz-96 with 40 variables stands in for the system; one cycle is five model steps (about six hours in atmospheric terms). A truth run makes the observations: every other variable, from two instruments. Instrument B reads 1.5 too high, a report is now and then off by 25, and the first two reports of every cycle arrive twice.
def tendency(x: State) -> State:
"""dx_i/dt = (x_{i+1} − x_{i−2}) x_{i−1} − x_i + F."""
# (x_{i+1} − x_{i−2}) x_{i−1} − x_i + F, indices periodic in i
return (np.roll(x, -1) - np.roll(x, 2)) * np.roll(x, 1) - x + F
def cycle_step(x: State, dt: float) -> State:
"""Advance one cycle with ``SUBSTEPS`` RK4 steps of ``DT``."""
for _ in range(SUBSTEPS):
k1 = tendency(x)
k2 = tendency(x + 0.5 * DT * k1)
k3 = tendency(x + 0.5 * DT * k2)
k4 = tendency(x + DT * k3)
# x ↦ x + Δt (k₁ + 2k₂ + 2k₃ + k₄) / 6
x = x + DT * (k1 + 2 * k2 + 2 * k3 + k4) / 6
return x
rng = np.random.default_rng(0)
# x₀ = F + ε, ε ~ N(0, I)
x: State = F + rng.normal(0.0, 1.0, N)
# spin up onto the attractor
for _ in range(200):
x = cycle_step(x, DT * SUBSTEPS)
truth_states: list[State] = [x]
for _ in range(CYCLES):
x = cycle_step(x, DT * SUBSTEPS)
truth_states.append(x)
# x^t_k: truth[0] is the start, truth[k] the state after cycle k
truth: Trajectory = np.stack(truth_states)
def raw_observations(k: int) -> list[Report]:
"""What two instruments report for cycle k, defects included."""
r = np.random.default_rng(1000 + k)
out: list[Report] = []
# every other variable
for i in range(0, N, 2):
instrument = "A" if i % 4 == 0 else "B"
# y_i = x^t_{k,i} + ε + b, ε ~ N(0, σ_o²), b = 1.5 for instrument B
bias = 1.5 if instrument == "B" else 0.0
y = truth[k, i] + r.normal(0.0, SIGMA_O) + bias
out.append({"cycle": k, "site": i, "instrument": instrument, "value": float(y)})
# a gross error now and then
if r.random() < 0.3:
out[r.integers(len(out))]["value"] += 25.0
# duplicated delivery
out += out[:2]
return out
Step 2 — Pre-process the observations¶
EnsembleDACycle asks its obs_source for the observations of each
cycle, and any operator will do, so the whole pre-processing chain is one
Sequential: fetch, deduplicate, correct the known instrument bias from
a calibration table, drop physically impossible values, and pack what is
left into vectors of sites and values. Each step is named first, then
bundled with |, then run once by hand on the first cycle so every
intermediate can be inspected.
@dataclass
class ObsVectors:
"""The observations of one cycle: sites ``idx`` and values ``y``."""
idx: Sites
y: Values
class Fetch(Operator):
"""Cycle index i ↦ the raw reports valid after cycle i + offset."""
def __init__(self, *, offset: int) -> None:
self.offset = offset
def _apply(self, i: int) -> list[Report]:
return raw_observations(self.offset + i)
class Dedupe(Operator):
"""One report per (instrument, site); the last delivery wins."""
def _apply(self, reports: list[Report]) -> list[Report]:
by_key: dict[tuple[str, int], Report] = {}
for report in reports:
by_key[(report["instrument"], report["site"])] = report
return list(by_key.values())
class BiasCorrect(Operator):
"""y ↦ y − b_instrument, from a calibration table."""
def __init__(self, *, bias: dict[str, float]) -> None:
self.bias = bias
def _apply(self, reports: list[Report]) -> list[Report]:
corrected: list[Report] = []
for report in reports:
b = self.bias.get(report["instrument"], 0.0)
corrected.append({**report, "value": report["value"] - b})
return corrected
class RangeCheck(Operator):
"""Drop reports outside the physically plausible range."""
def __init__(self, *, lo: float, hi: float) -> None:
self.lo = lo
self.hi = hi
def _apply(self, reports: list[Report]) -> list[Report]:
kept: list[Report] = []
for report in reports:
if self.lo <= report["value"] <= self.hi:
kept.append(report)
return kept
def site_of(report: Report) -> int:
return report["site"]
class ToVectors(Operator):
"""Reports ↦ sites and values, sorted by site."""
def _apply(self, reports: list[Report]) -> ObsVectors:
ordered = sorted(reports, key=site_of)
sites: list[int] = []
values: list[float] = []
for report in ordered:
sites.append(report["site"])
values.append(report["value"])
return ObsVectors(idx=np.array(sites), y=np.array(values))
# the steps
# i ↦ the raw reports after cycle i + 1: the first analysis follows one forecast
fetch = Fetch(offset=1)
# one report per (instrument, site)
dedupe = Dedupe()
# y ↦ y − b, b_A = 0, b_B = 1.5
bias_correct = BiasCorrect(bias={"A": 0.0, "B": 1.5})
# keep y ∈ [−20, 25]
range_check = RangeCheck(lo=-20.0, hi=25.0)
# reports ↦ (idx, y) ∈ ℕ^M × ℝ^M, sorted by site
to_vectors = ToVectors()
# the bundle
observations: Sequential = fetch | dedupe | bias_correct | range_check | to_vectors
# run it once, step by step, for the first cycle
# 22 reports: one per observed site (20), the first two delivered twice
raw: list[Report] = fetch(0)
unique: list[Report] = dedupe(raw)
corrected: list[Report] = bias_correct(unique)
plausible: list[Report] = range_check(corrected)
first_obs: ObsVectors = to_vectors(plausible)
Step 3 — The ensemble analysis, with its own quality control¶
PointObs is the observation operator; EnKF is the analysis step.
Neither imports pipekit: they satisfy ObservationOperator and
AnalysisStep by having the right methods. Before it updates, the
analysis inflates the ensemble a little (the filter otherwise grows
overconfident) and rejects observations too far from the forecast for
their combined error, the first-guess check that catches the gross
errors the range check let through. The Gaspari–Cohn taper ρ stops
distant, spuriously correlated variables from updating each other.
EnsembleDACycle returns only the final ensemble, so the analysis keeps a
log for the post-processing.
class PointObs:
"""H: the state sampled at the observed sites (an ObservationOperator)."""
def __call__(self, state: State, idx: Sites | None = None) -> State | Values:
# H x = (x_i)_{i ∈ idx}, or x itself when no sites are given
if idx is None:
return state
return state[idx]
def linearize(self, state: State) -> NoReturn:
raise NotImplementedError
def gaspari_cohn(d: Distances, c: float) -> Covariance:
"""The Gaspari–Cohn taper ρ(d) with half-width c: 1 at d = 0, 0 beyond 2c."""
# r = |d| / c
r = np.abs(d) / c
out = np.zeros_like(r)
near = r <= 1
far = (r > 1) & (r <= 2)
rn = r[near]
rf = r[far]
# r ≤ 1: ρ = −¼r⁵ + ½r⁴ + ⅝r³ − ⁵⁄₃r² + 1
out[near] = (((-0.25 * rn + 0.5) * rn + 0.625) * rn - 5 / 3) * rn**2 + 1
# 1 < r ≤ 2: ρ = r⁵/12 − ½r⁴ + ⅝r³ + ⁵⁄₃r² − 5r + 4 − 2/(3r); r > 2: ρ = 0
out[far] = (
((((rf / 12 - 0.5) * rf + 0.625) * rf + 5 / 3) * rf - 5) * rf + 4 - 2 / (3 * rf)
)
return out
@dataclass
class AnalysisRecord:
"""What one analysis leaves behind: its mean, spread and observation counts."""
mean: State
spread: State
used: int
rejected: int
class EnKF:
"""Stochastic EnKF with a first-guess check, localization and inflation.
x_aᵐ = x_fᵐ + K (yᵐ − H x_fᵐ), K = (ρ ∘ P_f) Hᵀ (H (ρ ∘ P_f) Hᵀ + R)⁻¹,
with yᵐ = y + εᵐ, εᵐ ~ N(0, R). An AnalysisStep.
"""
def __init__(
self, *, inflation: float, radius: float, check: float, seed: int
) -> None:
self.inflation = inflation
self.check = check
i = np.arange(N)
d = np.abs(i[:, None] - i[None, :])
# ρ_{ij} = GC(min(|i − j|, N − |i − j|)): distance on the periodic ring
self.rho: Covariance = gaspari_cohn(np.minimum(d, N - d), radius)
self.rng = np.random.default_rng(seed)
self.log: list[AnalysisRecord] = []
def __call__(
self,
forecast: list[State],
obs: ObsVectors,
*,
obs_op: PointObs,
obs_err_cov: float,
) -> list[State]:
X: Ensemble = np.stack(forecast)
# x̄ = (1/E) ∑ₘ xᵐ
mean: State = X.mean(0)
# multiplicative inflation: xᵐ ↦ x̄ + λ (xᵐ − x̄)
X = mean + self.inflation * (X - mean)
# first-guess check: keep |y − H x̄| ≤ c √(σ_o² + σ_b²),
# σ_b² the ensemble variance at each observed site
hx: Values = obs_op(mean, obs.idx)
sb2: Values = X[:, obs.idx].var(0, ddof=1)
keep: Passed = np.abs(obs.y - hx) <= self.check * np.sqrt(obs_err_cov + sb2)
idx: UsedSites = obs.idx[keep]
y: UsedValues = obs.y[keep]
# A = X − x̄, the anomalies
A: Ensemble = X - X.mean(0)
# localized forecast covariance: ρ ∘ P_f, P_f = Aᵀ A / (E − 1)
P: Covariance = self.rho * (A.T @ A) / (len(X) - 1)
# R = σ_o² I
R: ObsErrorCov = obs_err_cov * np.eye(len(idx))
# K = (ρ ∘ P_f) Hᵀ (H (ρ ∘ P_f) Hᵀ + R)⁻¹
K: Gain = P[:, idx] @ np.linalg.inv(P[np.ix_(idx, idx)] + R)
# perturbed observations: yᵐ = y + εᵐ, εᵐ ~ N(0, R)
noise: PerturbedObs = self.rng.normal(
0.0, np.sqrt(obs_err_cov), (len(X), len(idx))
)
Y: PerturbedObs = y + noise
# x_aᵐ = xᵐ + K (yᵐ − H xᵐ)
Xa: Ensemble = X + (Y - X[:, idx]) @ K.T
record = AnalysisRecord(
mean=Xa.mean(0),
spread=Xa.std(0, ddof=1),
used=int(keep.sum()),
rejected=int((~keep).sum()),
)
self.log.append(record)
return list(Xa)
Step 4 — Cycle¶
The ensemble starts far from the truth (a spread of 3 around it). A free run from the same start, with no observations, is the baseline.
# the steps
# M: x ↦ x after one cycle, applied to each member
forward = CallableForward(cycle_step, dt=DT * SUBSTEPS)
# H x = (x_i)_{i ∈ idx}
point_obs = PointObs()
# λ = 1.04, c_loc = 4 variables, first-guess check at 4σ
enkf = EnKF(inflation=1.04, radius=4.0, check=4.0, seed=2)
# the bundle: forecast → observe → analyse, 200 times
da = EnsembleDACycle(
forward_model=forward,
obs_op=point_obs,
analysis_step=enkf,
obs_source=observations,
n_steps=CYCLES,
n_members=MEMBERS,
)
# run it once
r0 = np.random.default_rng(3)
# a poor start: xᵐ₀ = x^t₀ + εᵐ, εᵐ ~ N(0, 3² I)
members: list[State] = []
for _ in range(MEMBERS):
members.append(truth[0] + r0.normal(0.0, 3.0, N))
result = da(members, DAState(obs_err_cov=SIGMA_O**2))
final_members: list[State] = result[0]
final_state: DAState = result[1]
# the baseline: the same start, no observations
free: State = np.stack(members).mean(0)
free_states: list[State] = []
for _ in range(CYCLES):
free = cycle_step(free, DT * SUBSTEPS)
free_states.append(free)
free_run: Trajectory = np.stack(free_states)
Step 5 — Post-process into products and a report¶
Nothing here needs a pipeline: the scores are plain arithmetic on arrays already in hand, computed once the filter has settled: the analysis error against the truth, the free run's, and the spread-skill ratio (close to 1 when the ensemble's spread is an honest estimate of its error). The last analysis, with its spread, is the product that goes on to forecasters or the next system.
log: list[AnalysisRecord] = enkf.log
# x̄ᵃ_k, valid at truth[k] for k = 1 … 200
analysis: Trajectory = np.stack([record.mean for record in log])
# σᵃ_k, the ensemble spread of each analysis
spread: Trajectory = np.stack([record.spread for record in log])
# the truth at the analysis times
verifying: Trajectory = truth[1:]
# skip the first 50 cycles while the filter converges
settled = slice(50, None)
def rmse(a: Trajectory, b: Trajectory) -> float:
"""RMSE(a, b) = √( (1/(T N)) ∑_{t,i} (a_{t,i} − b_{t,i})² )."""
return float(np.sqrt(np.mean((a - b) ** 2)))
# RMSE(x̄ᵃ, xᵗ) over the settled cycles
analysis_rmse: float = rmse(analysis[settled], verifying[settled])
# RMSE(x^free, xᵗ), the baseline
free_run_rmse: float = rmse(free_run[settled], verifying[settled])
# σ̄ = √( mean σᵃ² ), the typical ensemble spread
spread_rms: float = float(np.sqrt(np.mean(spread[settled] ** 2)))
# spread-skill = σ̄ / RMSE(x̄ᵃ, xᵗ), ≈ 1 when the spread is an honest error estimate
spread_skill: float = spread_rms / analysis_rmse
# observations the first-guess check let through, and those it rejected
obs_used: int = sum(record.used for record in log)
obs_rejected: int = sum(record.rejected for record in log)
report: dict[str, float | int] = {
"analysis_rmse": round(analysis_rmse, 3),
"free_run_rmse": round(free_run_rmse, 3),
"obs_error": round(SIGMA_O, 3),
"spread_skill": round(spread_skill, 3),
"obs_used": obs_used,
"obs_rejected": obs_rejected,
}
print(json.dumps(report))
# the last analysis, with its uncertainty, ready to hand on
product: dict[str, Any] = {
"cycle": int(final_state.cycle_count),
"mean": analysis[-1].round(2).tolist(),
"spread": spread[-1].round(2).tolist(),
}
{"analysis_rmse": 0.411, "free_run_rmse": 5.098, "obs_error": 1.0, "spread_skill": 1.133, "obs_used": 3941, "obs_rejected": 18}
The analysis error (0.41) is well below the observation error (1.0), and far below the free run's (5.1): combining the model with the observations beats either alone. The spread is about right (a ratio of 1.13), and the first-guess check rejected 18 observations the range check had let in.
Make it yours¶
- Your model. Anything with
step(state, dt)anddtis a forward model; a trained emulator from the registry works throughNeuralForward(see Train, register, forecast). - Your analysis. Swap
EnKFfor an analysis from an algorithm library: it only has to be callable asanalysis(forecast, obs, obs_op=..., obs_err_cov=...). - Your observations. Replace
Fetchwith a reader for your archive; the rest of the chain is unchanged.
Where next¶
- Run a DA cycle: the shorter introduction to the cycle types.
- ETL over many regions: the pre-processing ideas at batch scale.