Lagrangian particles¶
Lagrangian particle dispersion. Tier II.
lagrangian
¶
Tier II — Lagrangian particle dispersion (JAX).
A Markov-1 Langevin particle model: stochastic trajectories driven by mean wind plus an Ornstein–Uhlenbeck turbulent-velocity process. The bridge between the analytical Tier I (no real wind variability) and the Eulerian Tier III. See the Tier II design doc.
This implements Step 1 (the forward model), Step 2 (model-based inference) and Steps 3–4 (the footprint emulator and inference on it):
turbulence—HomogeneousTurbulence(well-mixed-exact) and Hanna (1982) Monin–Obukhov-similarity profiles.particles— the Markov-1 integrator with the Thomson (1987) well-mixed vertical drift correction and ground / PBL reflection.concentration— forward steady concentration field via residence-time binning (simulate_lagrangian).footprint— backward source–receptor sensitivity (compute_footprint).wind_interp—GriddedWind, theles_fvmC-grid wind (from aMetFieldor aPrescribedWindField) sampled at each particle; accepted wherever a wind callable is.met_ensemble— one footprint per met-ensemble member (ensemble_footprints) and the Gaussian inversion that adds their spread as a transport-error covariance (ensemble_inversion).-
inversion— Gaussian and sign-constrained lognormal Bayesian inversion of the source vector from observations, with a Matérn-3/2 spatial prior (linear_gaussian_inversion/lognormal_inversion): vardaxOptimalInterpolationoverfootprint_operator(theassimextra). -
emulator—FootprintEmulator, an MLP from met context and receptor to a footprint row, trained on particle-model footprints with pipekit-train (footprint_dataset,train_footprint_emulator; theemulateextra) and calibrated against them (emulator_relative_error,assert_emulator_calibrated).emulated_footprintfeeds the inversions unchanged (Step 4).
The amortized predictor (Step 5) is not yet implemented.
EmulatedFootprint
¶
Bases: Module
The emulator at fixed receptors, as the per-case map u ↦ F_emu(u).
F_emu(u)[i, s] = emulator(u, rᵢ)[s]. This is the model pipekit-train's
Equinox backend fits: it maps one met context to the (obs, cell)
target of a SimulationDataset pair. Gradients reach the network
weights only; the standardisation, the scale s and the receptors are
held fixed, as in :func:train_footprint_emulator.
Attributes:
| Name | Type | Description |
|---|---|---|
emulator |
FootprintEmulator
|
The per-receptor network. |
receptors |
Float[Array, 'obs 3']
|
Receptor positions (x, y, z) [m]; z is not an input. |
FootprintEmulator
¶
Bases: Module
Per-receptor network from met context and receptor to a footprint row.
F_emu(u, r) = s · softplus(g(z; θ)), z = ([u, r_xy] − m) / σ,
with g an MLP and m, σ the mean and standard deviation of the training inputs. The softplus keeps every cell non-negative, as a residence time is, and s is the mean training footprint, so g works on order-one numbers.
Attributes:
| Name | Type | Description |
|---|---|---|
network |
MLP
|
The MLP g, from |
input_mean |
Float[Array, ' input']
|
Mean m of the training inputs [u, r_xy]. |
input_scale |
Float[Array, ' input']
|
Standard deviation σ of the training inputs. |
footprint_scale |
Float[Array, '']
|
Scale s [s m² kg⁻¹]. |
Examples:
An untrained emulator for a two-feature context on a 3-cell grid:
>>> import jax
>>> import jax.numpy as jnp
>>> import equinox as eqx
>>> network = eqx.nn.MLP(4, 3, 8, 1, key=jax.random.PRNGKey(0))
>>> emulator = FootprintEmulator(
... network, jnp.zeros(4), jnp.ones(4), jnp.asarray(0.5)
... )
>>> row = emulator(jnp.array([3.0, 0.0]), jnp.array([0.0, 100.0]))
>>> row.shape, bool((row >= 0.0).all())
((3,), True)
FootprintSimulator
¶
FootprintSimulator(*, wind_from_context: Callable[[Float[Array, ' feature']], GriddedWind | Callable[[Array], Array]], receptors: Float[Array, 'obs 3'], turbulence: TurbulenceModel, domain_x: tuple[float, float, int], domain_y: tuple[float, float, int], n_particles: int = 300, t_back: float = 600.0, dt: float = 1.0, pbl_height: float = 1000.0, pbl_fraction: float = 0.5, air_density: float = 1.2)
The particle model as a forward model from met context to footprint.
F(u) = compute_footprint(receptors, wind_from_context(u)), one row per
receptor, cells flattened row-major over (x, y).
It satisfies pipekit_cycle.ForwardModel structurally (step and
dt), so it is the forward_model of pipekit-train's
SimulationDataset. The particle seed of a case is a hash of its
context, so a cached case is reproducible and two cases never share
their particle noise. Receptor i of a case uses seed + i, the common
random numbers of :func:~plumax.lagrangian.ensemble_footprints, whose
rows it reproduces for the same seed.
The particle scan is compiled once per simulator, with the context and
the receptors as traced arguments and jax.vmap over the receptors, so
only the first case of a simulator pays for compilation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
wind_from_context
|
Callable[[Float[Array, ' feature']], GriddedWind | Callable[[Array], Array]]
|
Mean wind for a context, a |
required |
receptors
|
Float[Array, 'obs 3']
|
Receptor positions (x, y, z) [m]. |
required |
turbulence
|
TurbulenceModel
|
Turbulence model. |
required |
domain_x
|
tuple[float, float, int]
|
|
required |
domain_y
|
tuple[float, float, int]
|
|
required |
n_particles
|
int
|
Particles per receptor and case. |
300
|
t_back
|
float
|
Backward integration time [s]. |
600.0
|
dt
|
float
|
Particle time step [s]; the forward model's |
1.0
|
pbl_height
|
float
|
Boundary-layer height [m]. |
1000.0
|
pbl_fraction
|
float
|
Fraction of the PBL that sees the surface flux. |
0.5
|
air_density
|
float
|
Air density [kg m⁻³]. |
1.2
|
Raises:
| Type | Description |
|---|---|
ParameterError
|
If |
Source code in src/plumax/lagrangian/_src/emulator.py
state_signature
property
¶
Everything that changes the footprints, for the dataset cache key.
step
¶
Footprint F(u) of the met context u = state.
Source code in src/plumax/lagrangian/_src/emulator.py
MetContextPrior
¶
Bases: Operator
The operational met distribution p(u) as pipekit-train's prior operator.
u ~ p(u | site, overpass time), one draw per integer seed. Draw u from the operational distribution at the facility, not from a uniform climatology grid: the design page notes a coarse (hour, season, stability) grid covers under 10 % of the operational probability mass.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
met_sampler
|
Callable[[PRNGKeyArray], Float[Array, ' feature']]
|
One draw u ~ p(u) from a key. |
required |
Source code in src/plumax/lagrangian/_src/emulator.py
GaussianPosterior
dataclass
¶
GaussianPosterior(mean: Array, covariance: Array, prior_mean: Array | None = None, prior_covariance: Array | None = None)
Closed-form Gaussian posterior over the source vector.
It also carries the Tier V per-event payload
(population.PerEventPosterior) for the event total Q = 1ᵀq, whose
posterior N(1ᵀq, 1ᵀP1) and prior N(1ᵀq_a, 1ᵀB1) are exact.
Attributes:
| Name | Type | Description |
|---|---|---|
mean |
Array
|
Posterior mean emission field |
covariance |
Array
|
Posterior covariance |
prior_mean |
Array | None
|
Prior mean |
prior_covariance |
Array | None
|
Prior covariance |
samples
¶
prior_logpdf
¶
log π(Q) of the prior on the total, N(1ᵀq_a, 1ᵀB1).
Raises:
| Type | Description |
|---|---|
PayloadError
|
If the posterior was built without its prior. |
Source code in src/plumax/lagrangian/inversion.py
LognormalPosterior
dataclass
¶
LognormalPosterior(mean: Array, log_increment: Array, log_covariance: Array, prior_mean: Array | None = None, prior_log_covariance: Array | None = None)
Linearised lognormal posterior over a non-negative source vector.
It also carries the Tier V per-event payload
(population.PerEventPosterior) for the event total
Q = Σᵢ q_a,i exp(δᵢ): draws are exact (δ ~ N(δ, P_log)); the prior
density of Q under δ ~ N(0, B_log) is the moment-matched lognormal, exact
for one cell.
Attributes:
| Name | Type | Description |
|---|---|---|
mean |
Array
|
Posterior mean emission field |
log_increment |
Array
|
Log-space MAP increment |
log_covariance |
Array
|
Posterior covariance in log-space |
prior_mean |
Array | None
|
Prior median field |
prior_log_covariance |
Array | None
|
Log-space prior covariance |
emission_rate
property
¶
The event total Σᵢ q*ᵢ, as population.event_from_posterior reads it.
samples
¶
n exact draws of Q = Σᵢ qᵢ exp(εᵢ), ε ~ N(0, P_log).
prior_logpdf
¶
log π(Q) for the prior total Σᵢ q_a,i exp(δᵢ), δ ~ N(0, B_log).
Raises:
| Type | Description |
|---|---|
PayloadError
|
If the posterior was built without its prior. |
Source code in src/plumax/lagrangian/inversion.py
ParticleState
dataclass
¶
State of an ensemble of Lagrangian particles.
Registered as a JAX pytree so it can be carried through lax.scan /
vmap; position and velocity are the (array) children.
Attributes:
| Name | Type | Description |
|---|---|---|
position |
Array
|
Particle positions |
velocity |
Array
|
Turbulent velocity perturbations [m/s], shape |
HannaTurbulence
dataclass
¶
HannaTurbulence(u_star: float, pbl_height: float, obukhov_length: float, w_star: float = 0.0, z_floor: float = 0.1, coriolis_parameter: float = DEFAULT_CORIOLIS_PARAMETER)
Height-dependent Hanna (1982) turbulence as a TurbulenceModel.
Thin adapter that binds the surface-layer scales required by
:func:hanna_profiles and exposes the at(z) -> (sigma, tau) interface
the integrator (:func:plumax.lagrangian.langevin_step,
:func:~plumax.lagrangian.integrate_particles,
:func:~plumax.lagrangian.compute_footprint) expects. Because σ_w varies
with height, the well-mixed vertical drift correction ½(1+v_w²/σ_w²)
∂σ_w²/∂z is non-zero — the inhomogeneous path that
:class:HomogeneousTurbulence never exercises.
To drive it from met rather than hand-typed scales, build it with
:func:plumax.met.hanna_turbulence_from from a
:class:plumax.met.MoninObukhov diagnosis.
Attributes:
| Name | Type | Description |
|---|---|---|
u_star |
float
|
Friction velocity |
pbl_height |
float
|
Boundary-layer height |
obukhov_length |
float
|
Monin–Obukhov length |
w_star |
float
|
Convective velocity scale |
z_floor |
float
|
Minimum height [m] at which the profiles are evaluated. The
convective |
coriolis_parameter |
float
|
Coriolis parameter |
at
¶
Return (σ, τ_L) at height(s) z from the Hanna (1982) profiles.
z is floored at :attr:z_floor before evaluation so the vertical
drift stays finite at the ground (see the class docstring). σ and
τ_L have a trailing axis of size 3, matching
:meth:HomogeneousTurbulence.at.
Source code in src/plumax/lagrangian/turbulence.py
HomogeneousTurbulence
dataclass
¶
HomogeneousTurbulence(sigma_u: float, sigma_v: float, sigma_w: float, tau_u: float, tau_v: float, tau_w: float)
Spatially uniform, well-mixed-exact turbulence statistics.
Attributes:
| Name | Type | Description |
|---|---|---|
sigma_u |
float
|
Along-wind velocity standard deviation [m/s]. |
sigma_v |
float
|
Cross-wind velocity standard deviation [m/s]. |
sigma_w |
float
|
Vertical velocity standard deviation [m/s]. |
tau_u |
float
|
Along-wind Lagrangian timescale [s]. |
tau_v |
float
|
Cross-wind Lagrangian timescale [s]. |
tau_w |
float
|
Vertical Lagrangian timescale [s]. |
isotropic
classmethod
¶
Build isotropic turbulence: equal σ and τ_L on all axes.
at
¶
Return (σ, τ_L) at height(s) z — constant, so z is ignored.
The signature matches :func:hanna_profiles so the integrator can treat
any turbulence model uniformly. σ and τ_L broadcast to the shape
of z along a trailing axis of size 3.
Source code in src/plumax/lagrangian/turbulence.py
GriddedWind
¶
Bases: Module
Mean wind on the les_fvm C-grid, trilinear in space, linear in time.
u(p, t) = Σ_corners w_corner u_corner over the 16 samples of u that
bracket (t, z, y, x), and likewise for v and w on their own points.
Attributes:
| Name | Type | Description |
|---|---|---|
x |
Float[Array, ' x']
|
T-point x-coordinates x_i [m], uniformly spaced. |
y |
Float[Array, ' y']
|
T-point y-coordinates y_j [m], uniformly spaced. |
z |
Float[Array, ' z']
|
T-point z-coordinates z_k [m], uniformly spaced. |
times |
Float[Array, ' time']
|
Sample times [s], strictly increasing. |
u |
Float[Array, 'time z y x']
|
Eastward wind [m/s] at U-points (x_i + Δx/2). |
v |
Float[Array, 'time z y x']
|
Northward wind [m/s] at V-points (y_j + Δy/2). |
w |
Float[Array, 'time z y x']
|
Vertical wind [m/s] at T-points. |
Examples:
A uniform wind is reproduced exactly, anywhere and at any time:
>>> import jax.numpy as jnp
>>> axis = jnp.arange(4.0) * 10.0 + 5.0
>>> ones = jnp.ones((2, 4, 4, 4))
>>> wind = GriddedWind(
... axis, axis, axis, jnp.array([0.0, 60.0]), 3.0 * ones, -ones, 0 * ones
... )
>>> wind(jnp.array([[12.0, 7.0, 31.0], [-50.0, 80.0, 0.0]]), 30.0).tolist()
[[3.0, -1.0, 0.0], [3.0, -1.0, 0.0]]
u is read on U-points, half a cell east of the T-points, so a
field linear in x comes back as x itself at the T-points:
>>> u = jnp.broadcast_to(axis + 5.0, (2, 4, 4, 4))
>>> sheared = GriddedWind(axis, axis, axis, jnp.array([0.0, 60.0]), u, ones, ones)
>>> sheared(jnp.array([[15.0, 15.0, 15.0]]), 0.0)[:, 0].tolist()
[15.0]
Piecewise-linear in time between the stored times:
>>> ramp = jnp.stack([ones[0], 3.0 * ones[0]])
>>> gusty = GriddedWind(axis, axis, axis, jnp.array([0.0, 60.0]), ramp, ones, ones)
>>> gusty(jnp.array([[15.0, 15.0, 15.0]]), 15.0)[:, 0].tolist()
[1.5]
from_grid
classmethod
¶
from_grid(plume_grid: PlumeGrid3D, times: Float[Array, ' time'], u: Float[Array, 'time z y x'], v: Float[Array, 'time z y x'], w: Float[Array, 'time z y x']) -> GriddedWind
Wind on a les_fvm analysis grid, interior arrays on the C-grid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
plume_grid
|
PlumeGrid3D
|
The analysis grid; its T-point axes place the samples. |
required |
times
|
Float[Array, ' time']
|
Sample times [s], strictly increasing. |
required |
u
|
Float[Array, 'time z y x']
|
Eastward wind [m/s] at U-points. |
required |
v
|
Float[Array, 'time z y x']
|
Northward wind [m/s] at V-points. |
required |
w
|
Float[Array, 'time z y x']
|
Vertical wind [m/s] at T-points. |
required |
Returns:
| Type | Description |
|---|---|
GriddedWind
|
The gridded wind. |
Source code in src/plumax/lagrangian/_src/wind_interp.py
bin_positions
¶
Histogram particle positions (n, 3) onto a 3-D grid (counts).
Source code in src/plumax/lagrangian/concentration.py
simulate_lagrangian
¶
simulate_lagrangian(emission_rate: float, source_location: tuple[float, float, float], turbulence: TurbulenceModel, domain_x: tuple[float, float, int], domain_y: tuple[float, float, int], domain_z: tuple[float, float, int], *, n_particles: int = 5000, t_end: float = 600.0, dt: float = 1.0, wind: Callable[[Array], Array] | GriddedWind | None = None, wind_speed: float | None = None, wind_direction: float | None = None, pbl_height: float | None = None, background_conc: float = 0.0, seed: int = 0) -> xr.Dataset
Simulate a steady Lagrangian concentration field on a 3-D grid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
emission_rate
|
float
|
Continuous source strength |
required |
source_location
|
tuple[float, float, float]
|
|
required |
turbulence
|
TurbulenceModel
|
Turbulence model (e.g.
:class: |
required |
domain_x
|
tuple[float, float, int]
|
|
required |
domain_y
|
tuple[float, float, int]
|
|
required |
domain_z
|
tuple[float, float, int]
|
|
required |
n_particles
|
int
|
Number of particles in the ensemble. |
5000
|
t_end
|
float
|
Integration horizon [s]. |
600.0
|
dt
|
float
|
Time step [s]. |
1.0
|
wind
|
Callable[[Array], Array] | GriddedWind | None
|
Mean-wind field |
None
|
wind_speed
|
float | None
|
Wind speed [m/s] (with |
None
|
wind_direction
|
float | None
|
Meteorological direction [deg from North]. |
None
|
pbl_height
|
float | None
|
Optional reflecting PBL lid [m]. |
None
|
background_conc
|
float
|
Additive background concentration [kg/m³]. |
0.0
|
seed
|
int
|
PRNG seed. |
0
|
Returns:
| Name | Type | Description |
|---|---|---|
An |
Dataset
|
class: |
Dataset
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
on a non-positive emission rate, a negative source height, or an ambiguous / missing wind specification. |
Source code in src/plumax/lagrangian/concentration.py
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | |
assert_emulator_calibrated
¶
assert_emulator_calibrated(emulator: FootprintEmulator, reference_footprints: Float[Array, 'case obs cell'], *, met_context: Float[Array, 'case feature'], receptors: Float[Array, 'obs 3'], key: PRNGKeyArray, threshold: float = 0.05, n_probes: int = 10) -> None
Check the emulated source–receptor maps against the particle model's.
‖F_emu v − F v‖ / ‖F v‖ < threshold for n_probes random source
fields v, through vardax.assert_adjoint_calibrated. The maps are
q ↦ (F_emu,ᵢ q)ᵢ and q ↦ (Fᵢ q)ᵢ stacked over the held-out cases i, so
this is the probed form of the Frobenius test over the same set, and it
checks the sensitivity the inversion sees.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
emulator
|
FootprintEmulator
|
The trained emulator. |
required |
reference_footprints
|
Float[Array, 'case obs cell']
|
Particle-model footprints Fᵢ at |
required |
met_context
|
Float[Array, 'case feature']
|
Held-out met contexts uᵢ. |
required |
receptors
|
Float[Array, 'obs 3']
|
Receptor positions (x, y, z) [m]. |
required |
key
|
PRNGKeyArray
|
PRNG key for the probe vectors. |
required |
threshold
|
float
|
Largest relative error allowed. |
0.05
|
n_probes
|
int
|
Number of probe vectors. |
10
|
Raises:
| Type | Description |
|---|---|
AssertionError
|
If a probe exceeds |
ImportError
|
Without the |
Source code in src/plumax/lagrangian/_src/emulator.py
emulated_footprint
¶
emulated_footprint(emulator: FootprintEmulator, met_context: Float[Array, ' feature'], receptors: Float[Array, 'obs 3']) -> Float[Array, 'obs cell']
The emulated footprint matrix F_emu of one met context.
F_emu[i, s] = emulator(u, rᵢ)[s], in the layout of
:func:~plumax.lagrangian.ensemble_footprints and of the inversions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
emulator
|
FootprintEmulator
|
The trained emulator. |
required |
met_context
|
Float[Array, ' feature']
|
Met context u. |
required |
receptors
|
Float[Array, 'obs 3']
|
Receptor positions (x, y, z) [m]; z is not an input. |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, 'obs cell']
|
The footprint rows [s m² kg⁻¹]. |
Examples:
Two receptors on a 3-cell grid:
>>> import jax
>>> import jax.numpy as jnp
>>> import equinox as eqx
>>> network = eqx.nn.MLP(4, 3, 8, 1, key=jax.random.PRNGKey(0))
>>> emulator = FootprintEmulator(
... network, jnp.zeros(4), jnp.ones(4), jnp.asarray(0.5)
... )
>>> receptors = jnp.array([[0.0, 0.0, 10.0], [0.0, 50.0, 10.0]])
>>> emulated_footprint(emulator, jnp.array([3.0, 0.0]), receptors).shape
(2, 3)
Source code in src/plumax/lagrangian/_src/emulator.py
emulator_relative_error
¶
emulator_relative_error(emulated: Float[Array, '*case obs cell'], reference: Float[Array, '*case obs cell']) -> Float[Array, '']
Relative Frobenius error ‖F_emu − F‖_F / ‖F‖_F over every case.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
emulated
|
Float[Array, '*case obs cell']
|
Emulated footprints F_emu. |
required |
reference
|
Float[Array, '*case obs cell']
|
Particle-model footprints F on the same met and receptors. |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
The relative error; the design page's hard test asks for < 0.05. |
Examples:
A uniform 2 % overestimate is a 2 % error:
>>> import jax.numpy as jnp
>>> reference = jnp.ones((2, 3, 4))
>>> round(float(emulator_relative_error(1.02 * reference, reference)), 6)
0.02
Source code in src/plumax/lagrangian/_src/emulator.py
footprint_dataset
¶
footprint_dataset(simulator: FootprintSimulator, met_sampler: Callable[[PRNGKeyArray], Float[Array, ' feature']], *, n_cases: int, cache_dir: str, seed: int = 0, split: str = 'train') -> Any
Simulated (met context, footprint) pairs, cached once on disk.
uᵢ ~ p(u), Fᵢ = simulator.step(uᵢ), as pipekit-train's
SimulationDataset wrapped in its CachedDataset: the particle
model runs once per case on first use, and every later epoch reads the
zarr cache under cache_dir, keyed on the simulator, the sampler, the
seed and the split.
The cache is held in memory after its first read, because training reads it case by case and a zarr read costs about 1 ms per array.
SimulationDataset partitions the n_cases seeds 80 / 10 / 10 into
the "train", "val" and "test" splits, so a split holds a
fraction of n_cases and the splits never share a case.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
simulator
|
FootprintSimulator
|
The particle model. |
required |
met_sampler
|
Callable[[PRNGKeyArray], Float[Array, ' feature']]
|
One draw u ~ p(u) from a key. |
required |
n_cases
|
int
|
Number of met contexts N over all three splits. |
required |
cache_dir
|
str
|
Directory (or fsspec URI) of the cache. |
required |
seed
|
int
|
Seed of the first case. |
0
|
split
|
str
|
|
'train'
|
Returns:
| Type | Description |
|---|---|
Any
|
The cached pipekit-train dataset. |
Raises:
| Type | Description |
|---|---|
ParameterError
|
If |
ImportError
|
Without the |
Source code in src/plumax/lagrangian/_src/emulator.py
init_footprint_emulator
¶
init_footprint_emulator(dataset: Any, receptors: Float[Array, 'obs 3'], *, width: int = 32, depth: int = 2, key: PRNGKeyArray) -> FootprintEmulator
An untrained emulator, standardised to the training set.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dataset
|
Any
|
The training |
required |
receptors
|
Float[Array, 'obs 3']
|
Receptor positions (x, y, z) [m] of the footprints. |
required |
width
|
int
|
Hidden width of the MLP. |
32
|
depth
|
int
|
Number of hidden layers. |
2
|
key
|
PRNGKeyArray
|
PRNG key for the MLP weights. |
required |
Returns:
| Type | Description |
|---|---|
FootprintEmulator
|
The emulator, ready for :func: |
Raises:
| Type | Description |
|---|---|
ParameterError
|
If |
Source code in src/plumax/lagrangian/_src/emulator.py
train_footprint_emulator
¶
train_footprint_emulator(emulator: FootprintEmulator, dataset: Any, receptors: Float[Array, 'obs 3'], *, val_dataset: Any | None = None, max_steps: int = 2000, batch_size: int = 16, learning_rate: float = 0.01, patience: int = 10, eval_every_n_steps: int = 100, seed: int = 0) -> FootprintEmulator
Fit the network weights θ with pipekit-train's Equinox backend.
θ* = argmin_θ 𝓛(θ), 𝓛(θ) = mean over cases, receptors and cells of (F_emu(uᵢ, r; θ) − Fᵢ)²,
by Adam on mini-batches of cases. The standardisation and the scale s stay fixed.
Algorithm
- Wrap the emulator at
receptorsas anEmulatedFootprint, the model u ↦ F_emu(u) that matches the dataset's pairs. - Run a pipekit-train
TrainingLoopon the"equinox"backend with itsMSEloss for at mostmax_stepssteps. - With
val_dataset, evaluate everyeval_every_n_stepssteps and stop through pipekit-train'sEarlyStoppingonce the validation MSE has not improved forpatienceevaluations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
emulator
|
FootprintEmulator
|
Initial emulator, e.g. from :func: |
required |
dataset
|
Any
|
The training pairs, e.g. from :func: |
required |
receptors
|
Float[Array, 'obs 3']
|
Receptor positions (x, y, z) [m] of the footprints. |
required |
val_dataset
|
Any | None
|
Optional held-out pairs for early stopping. |
None
|
max_steps
|
int
|
Maximum number of optimiser steps. |
2000
|
batch_size
|
int
|
Cases per step; at most the number of training cases. |
16
|
learning_rate
|
float
|
Adam learning rate. |
0.01
|
patience
|
int
|
Evaluations without improvement before stopping. |
10
|
eval_every_n_steps
|
int
|
Validation cadence in steps. |
100
|
seed
|
int
|
Seed of the batch shuffle. |
0
|
Returns:
| Type | Description |
|---|---|
FootprintEmulator
|
The emulator with the fitted weights. |
Raises:
| Type | Description |
|---|---|
ImportError
|
Without the |
Source code in src/plumax/lagrangian/_src/emulator.py
compute_footprint
¶
compute_footprint(receptor_location: tuple[float, float, float], turbulence: TurbulenceModel, domain_x: tuple[float, float, int], domain_y: tuple[float, float, int], *, wind: Callable[[Array], Array] | GriddedWind, n_particles: int = 5000, t_back: float = 600.0, dt: float = 1.0, pbl_height: float = 1000.0, pbl_fraction: float = 0.5, air_density: float = 1.2, receptor_time: float = 0.0, seed: int = 0) -> tuple[np.ndarray, np.ndarray, np.ndarray]
Compute a single receptor's surface footprint by backward integration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
receptor_location
|
tuple[float, float, float]
|
|
required |
turbulence
|
TurbulenceModel
|
Turbulence model. |
required |
domain_x
|
tuple[float, float, int]
|
|
required |
domain_y
|
tuple[float, float, int]
|
|
required |
wind
|
Callable[[Array], Array] | GriddedWind
|
Forward mean-wind field |
required |
n_particles
|
int
|
Ensemble size. |
5000
|
t_back
|
float
|
Backward integration time [s]. |
600.0
|
dt
|
float
|
Time step [s]. |
1.0
|
pbl_height
|
float
|
Boundary-layer height |
1000.0
|
pbl_fraction
|
float
|
Fraction |
0.5
|
air_density
|
float
|
Air density |
1.2
|
receptor_time
|
float
|
Physical time |
0.0
|
seed
|
int
|
PRNG seed. |
0
|
Returns:
| Type | Description |
|---|---|
ndarray
|
|
ndarray
|
|
ndarray
|
docstring). |
Source code in src/plumax/lagrangian/footprint.py
footprint_operator
¶
The Tier II source–receptor map 𝒢(q) = F q as a vardax operator.
vardax analyses carry the control as a (T, N) state; for a source
inversion T = 1. The operator maps that (1, n_grid) row to the
(1, n_obs) predicted observations, and vardax linearises it by
autodiff.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
footprint
|
Array
|
Source–receptor matrix |
required |
Returns:
| Type | Description |
|---|---|
CallableObs
|
A |
Raises:
| Type | Description |
|---|---|
ImportError
|
Without the |
Source code in src/plumax/lagrangian/inversion.py
linear_gaussian_inversion
¶
linear_gaussian_inversion(footprint: Array, observation: Array, *, prior_mean: Array, prior_cov: Array, obs_variance: Array) -> GaussianPosterior
Closed-form Gaussian–Gaussian (BLUE) inversion of the source vector.
For a Gaussian prior q ~ N(q_a, B) and Gaussian likelihood
y = F q + ε, ε ~ N(0, R) (R diagonal), returns the exact
posterior N(q*, P*) with::
q* = q_a + B Fᵀ (F B Fᵀ + R)⁻¹ (y − F q_a)
P* = B − B Fᵀ (F B Fᵀ + R)⁻¹ F B
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
footprint
|
Array
|
Source–receptor matrix |
required |
observation
|
Array
|
Observed values |
required |
prior_mean
|
Array
|
Prior mean |
required |
prior_cov
|
Array
|
Prior covariance |
required |
obs_variance
|
Array
|
Diagonal of |
required |
Returns:
| Name | Type | Description |
|---|---|---|
The |
GaussianPosterior
|
class: |
Raises:
| Type | Description |
|---|---|
ImportError
|
Without the |
Source code in src/plumax/lagrangian/inversion.py
lognormal_inversion
¶
lognormal_inversion(footprint: Array, observation: Array, *, prior_mean: Array, prior_log_cov: Array, obs_variance: Array) -> LognormalPosterior
Sign-constrained inversion via a lognormal prior linearised in log-space.
With log q ~ N(log q_a, B_log) and the forward linearised about the
prior mean, the tangent-linear Jacobian is F̃ = F · diag(q_a) and the
log-space increment δ = log(q / q_a) follows the same BLUE update::
δ* = B_log F̃ᵀ (F̃ B_log F̃ᵀ + R)⁻¹ (y − F q_a)
q* = q_a · exp(δ*) (≥ 0 by construction)
This is the design note's recommended v1 prior: it enforces non-negative
emissions while keeping the update closed-form (no projected-gradient or
NNLS inner loop). The innovation y − F q_a is the observation minus the
forward evaluated at the prior mean (δ = 0 ⇒ q = q_a).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
footprint
|
Array
|
Source–receptor matrix |
required |
observation
|
Array
|
Observed values |
required |
prior_mean
|
Array
|
Prior median emission field |
required |
prior_log_cov
|
Array
|
Log-space prior covariance |
required |
obs_variance
|
Array
|
Diagonal of |
required |
Returns:
| Name | Type | Description |
|---|---|---|
The |
LognormalPosterior
|
class: |
Raises:
| Type | Description |
|---|---|
ImportError
|
Without the |
Source code in src/plumax/lagrangian/inversion.py
matern32_covariance
¶
Build a Matérn-3/2 prior covariance over grid-cell coordinates.
The Matérn-3/2 kernel
k(r) = σ² (1 + √3 r / ℓ) exp(−√3 r / ℓ), r = ‖xᵢ − xⱼ‖
gives a once-differentiable random field — smoother than the exponential
(AR(1) / Matérn-1/2) kernel but far less rigid than a squared-exponential.
It is the design note's recommended spatial prior for regional emission
inversions: a real correlation length ℓ regularises the posterior so
unresolved structure spreads over neighbouring cells instead of collapsing
into single-cell spikes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
coordinates
|
Array
|
Cell-centre coordinates [m], shape |
required |
variance
|
float
|
Marginal prior variance |
required |
length_scale
|
float
|
Correlation length |
required |
Returns:
| Type | Description |
|---|---|
Array
|
The |
Source code in src/plumax/lagrangian/inversion.py
observation_covariance
¶
observation_covariance(retrieval_variance: Array, *, representation_variance: float | Array = 0.0) -> jax.Array
Assemble the diagonal observation-error covariance R.
R = R_retr + R_repr: the per-pixel L2 retrieval error plus a
representation error that accounts for model-vs-observation footprint
mismatch. The design note is explicit that omitting R_repr (using the
retrieval error alone) overweights the observations and yields overconfident
posteriors, so the representation term is a first-class argument here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
retrieval_variance
|
Array
|
Per-observation retrieval variance |
required |
representation_variance
|
float | Array
|
Representation variance |
0.0
|
Returns:
| Type | Description |
|---|---|
Array
|
The diagonal of |
Source code in src/plumax/lagrangian/inversion.py
ensemble_footprints
¶
ensemble_footprints(winds: Sequence[GriddedWind | Callable[[Array], Array]], receptors: Float[Array, 'obs 3'], turbulence: TurbulenceModel, domain_x: tuple[float, float, int], domain_y: tuple[float, float, int], *, n_particles: int = 5000, t_back: float = 600.0, dt: float = 1.0, pbl_height: float = 1000.0, pbl_fraction: float = 0.5, air_density: float = 1.2, receptor_time: float = 0.0, seed: int = 0) -> Float[Array, 'member obs cell']
Footprint matrix F_e of every ensemble member.
F_e[i, s] = compute_footprint(receptor i, wind e)[s], cells flattened
row-major over (x, y).
Every member reuses the same particle draws for a receptor (common random
numbers: receptor i uses seed seed + i in every member). The spread
between members is then the effect of the winds alone; independent draws
would add particle-sampling noise that :func:ensemble_inversion would count
as transport error.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
winds
|
Sequence[GriddedWind | Callable[[Array], Array]]
|
One mean wind per member: a
:class: |
required |
receptors
|
Float[Array, 'obs 3']
|
Receptor positions (x, y, z) [m]. |
required |
turbulence
|
TurbulenceModel
|
Turbulence model, shared by the members. |
required |
domain_x
|
tuple[float, float, int]
|
|
required |
domain_y
|
tuple[float, float, int]
|
|
required |
n_particles
|
int
|
Particles per receptor and member. |
5000
|
t_back
|
float
|
Backward integration time [s]. |
600.0
|
dt
|
float
|
Time step [s]. |
1.0
|
pbl_height
|
float
|
Boundary-layer height [m]. |
1000.0
|
pbl_fraction
|
float
|
Fraction of the PBL that sees the surface flux. |
0.5
|
air_density
|
float
|
Air density [kg m⁻³]. |
1.2
|
receptor_time
|
float
|
Observation time [s] on the winds' clock. |
0.0
|
seed
|
int
|
Base PRNG seed. |
0
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'member obs cell']
|
The member footprints, units s m² kg⁻¹ (see |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/plumax/lagrangian/_src/met_ensemble.py
ensemble_inversion
¶
ensemble_inversion(footprints: Float[Array, 'member obs cell'], observation: Float[Array, ' obs'], *, prior_mean: Float[Array, ' cell'], prior_cov: Float[Array, 'cell cell'], obs_variance: Float[Array, ' obs']) -> GaussianPosterior
Gaussian inversion that marginalises the footprint over a met ensemble.
q = q_a + B F̄ᵀ (F̄ B F̄ᵀ + R + R_transport)⁻¹ (y − F̄ q_a), P = B − B F̄ᵀ (F̄ B F̄ᵀ + R + R_transport)⁻¹ F̄ B,
with F̄ and R_transport from :func:transport_covariance. One member gives
:func:~plumax.lagrangian.linear_gaussian_inversion on its footprint.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
footprints
|
Float[Array, 'member obs cell']
|
Member footprints F_e (e.g. :func: |
required |
observation
|
Float[Array, ' obs']
|
Observed values y. |
required |
prior_mean
|
Float[Array, ' cell']
|
Prior mean q_a. |
required |
prior_cov
|
Float[Array, 'cell cell']
|
Prior covariance B. |
required |
obs_variance
|
Float[Array, ' obs']
|
Diagonal of the retrieval + representation covariance R. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
The |
GaussianPosterior
|
class: |
Raises:
| Type | Description |
|---|---|
ImportError
|
Without the |
Source code in src/plumax/lagrangian/_src/met_ensemble.py
transport_covariance
¶
transport_covariance(footprints: Float[Array, 'member obs cell'], prior_mean: Float[Array, ' cell']) -> tuple[Float[Array, 'obs cell'], Float[Array, 'obs obs']]
Mean footprint F̄ and linearised transport-error covariance at q_a.
R_transport = Σ_e d_e d_eᵀ / (E − 1), d_e = (F_e − F̄) q_a,
computed by gaussx.ensemble_covariance(..., bessel=True) on F_e q_a,
and zero for a single member.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
footprints
|
Float[Array, 'member obs cell']
|
Member footprints F_e. |
required |
prior_mean
|
Float[Array, ' cell']
|
Prior mean q_a, the linearisation point. |
required |
Returns:
| Type | Description |
|---|---|
tuple[Float[Array, 'obs cell'], Float[Array, 'obs obs']]
|
|
Examples:
Two members that disagree on one receptor:
>>> import jax.numpy as jnp
>>> footprints = jnp.array([[[1.0, 0.0], [0.0, 1.0]], [[3.0, 0.0], [0.0, 1.0]]])
>>> mean_footprint, covariance = transport_covariance(
... footprints, jnp.array([1.0, 1.0])
... )
>>> mean_footprint.tolist(), covariance.tolist()
([[2.0, 0.0], [0.0, 1.0]], [[2.0, 0.0], [0.0, 0.0]])
Identical members carry no transport error:
>>> same = jnp.stack([footprints[0], footprints[0]])
>>> bool((transport_covariance(same, jnp.ones(2))[1] == 0.0).all())
True
Source code in src/plumax/lagrangian/_src/met_ensemble.py
integrate_particles
¶
integrate_particles(state: ParticleState, wind: Callable[[Array], Array] | GriddedWind, turbulence: TurbulenceModel, *, t0: float, t1: float, dt: float, key: Array, pbl_height: float | None = None, save_trajectory: bool = False) -> tuple[ParticleState, jax.Array | None]
Integrate the ensemble from t0 to t1 with fixed step dt.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
ParticleState
|
Initial particle state. |
required |
wind
|
Callable[[Array], Array] | GriddedWind
|
Mean-wind field: |
required |
turbulence
|
TurbulenceModel
|
Turbulence model. |
required |
t0
|
float
|
Start time [s]. |
required |
t1
|
float
|
End time [s]. |
required |
dt
|
float
|
Time step [s]. |
required |
key
|
Array
|
PRNG key. |
required |
pbl_height
|
float | None
|
Optional PBL-top reflecting lid [m]. |
None
|
save_trajectory
|
bool
|
If |
False
|
Returns:
| Type | Description |
|---|---|
ParticleState
|
|
Array | None
|
|
Source code in src/plumax/lagrangian/particles.py
langevin_step
¶
langevin_step(state: ParticleState, wind: Array, turbulence: TurbulenceModel, dt: float, key: Array, *, pbl_height: float | None = None) -> ParticleState
Advance the ensemble one step dt with the Markov-1 update.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
ParticleState
|
Current particle state. |
required |
wind
|
Array
|
Mean wind |
required |
turbulence
|
TurbulenceModel
|
Turbulence model supplying |
required |
dt
|
float
|
Time step [s]. |
required |
key
|
Array
|
PRNG key for the Wiener increment. |
required |
pbl_height
|
float | None
|
If given, reflect particles off the PBL top [m]. |
None
|
Returns:
| Type | Description |
|---|---|
ParticleState
|
The advanced :class: |
Source code in src/plumax/lagrangian/particles.py
n_steps_for_horizon
¶
Number of fixed-dt steps covering horizon (ceil, float-robust).
Plain ceil(horizon / dt) adds a spurious extra step when the quotient is
mathematically an integer but floating-point rounds it just above (e.g.
2.1 / 0.15 → 14.000…02). Snapping the quotient to the nearest integer
when it is within rtol avoids that degenerate final zero-length step.
Returns 0 for a non-positive horizon.
Source code in src/plumax/lagrangian/particles.py
step_durations
¶
Per-step durations summing to horizon; the final step takes the rest.
All steps are dt except the last, which is the remainder
horizon - (n_steps - 1) * dt (equal to dt when horizon is an
exact multiple). Returns an empty array when n_steps == 0. Pair with
:func:n_steps_for_horizon so the final duration is always > 0 (no
degenerate zero-length step from float round-up). Used by the integrator /
residence / footprint paths so a non-divisible horizon advances over exactly
horizon rather than n_steps * dt.
Source code in src/plumax/lagrangian/particles.py
uniform_wind
¶
Return a constant mean-wind field t -> (u, v, w).
Source code in src/plumax/lagrangian/particles.py
wind_from_speed_direction
¶
wind_from_speed_direction(speed: float, direction_deg: float, w: float = 0.0) -> Callable[[jax.Array], jax.Array]
Constant wind from speed + meteorological direction (degrees from North).
direction_deg = 270 is a wind from the west (flowing east), matching the
convention used by :func:plumax.gauss_plume.simulate_plume.
Source code in src/plumax/lagrangian/particles.py
hanna_profiles
¶
hanna_profiles(z: Array, *, u_star: float, pbl_height: float, obukhov_length: float, w_star: float = 0.0, coriolis_parameter: float = DEFAULT_CORIOLIS_PARAMETER) -> tuple[jax.Array, jax.Array]
Hanna (1982) Monin–Obukhov-similarity turbulence profiles.
Returns height-dependent velocity standard deviations and Lagrangian
timescales for three stability regimes, chosen as in FLEXPART's
hanna.f90:
- Neutral (
h/|L| < 1, orL = 0): Stohl et al. (2005) eqs. 26–28, σ_u = 2.0 u_ exp(−3fz/u_), σ_v = σ_w = 1.3 u_ exp(−2fz/u_), τ_L = 0.5 z / σ_w / (1 + 15fz/u_*) on all three axes. - Unstable / convective (
h/|L| ≥ 1andL < 0): convective scaling withw_*. - Stable (
h/|L| ≥ 1andL > 0): eqs. 29–33, σ_u = 2.0 u_ (1 − z/h), σ_v = σ_w = 1.3 u_ (1 − z/h), τ_L,u = 0.15 (h/σ_u)(z/h)^0.5, τ_L,v = 0.07 (h/σ_v)(z/h)^0.5, τ_L,w = 0.1 (h/σ_w)(z/h)^0.8.
The stable τ_L,w exponent is 0.8, as in Hanna (1982) and hanna.f90;
Stohl et al. (2005) print 0.5 in eq. 33. L = 0 is the neutral sentinel
(1/L = 0) that :func:plumax.met.hanna_turbulence_from passes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
z
|
Array
|
Height(s) above ground [m]; any shape. |
required |
u_star
|
float
|
Friction velocity [m/s]. |
required |
pbl_height
|
float
|
Boundary-layer height |
required |
obukhov_length
|
float
|
Monin–Obukhov length |
required |
w_star
|
float
|
Convective velocity scale [m/s] (used when unstable). |
0.0
|
coriolis_parameter
|
float
|
Coriolis parameter f = 2Ω sin φ [1/s], used by the
neutral profiles; only |
DEFAULT_CORIOLIS_PARAMETER
|
Returns:
| Type | Description |
|---|---|
Array
|
|
Array
|
standard deviations [m/s] and Lagrangian timescales [s] for |
tuple[Array, Array]
|
|
References
Hanna, S. R. (1982). Applications in air pollution modeling. Stohl, A. et al. (2005). Technical note: The Lagrangian particle dispersion model FLEXPART version 6.2. Atmos. Chem. Phys. 5, 2461–2474, eqs. 20–33.
Source code in src/plumax/lagrangian/turbulence.py
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | |
gridded_wind_from_met
¶
The wind of a :class:~plumax.met.MetField, for particles.
MetField already stores u, v, w on the C-grid with a time
axis, so this is a relabelling; no value changes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
field
|
MetField
|
Meteorology on an analysis grid. |
required |
Returns:
| Type | Description |
|---|---|
GriddedWind
|
The gridded wind on |
Source code in src/plumax/lagrangian/_src/wind_interp.py
gridded_wind_from_prescribed
¶
gridded_wind_from_prescribed(wind_field: PrescribedWindField, times: Float[Array, ' time']) -> GriddedWind
Sample a les_fvm :class:~plumax.les_fvm.wind.PrescribedWindField.
The field is evaluated at each of times; between them the particles see
the piecewise-linear interpolant, so times should resolve the field's
own time variation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
wind_field
|
PrescribedWindField
|
The Eulerian solver's wind. |
required |
times
|
Float[Array, ' time']
|
Times [s] to sample it at, strictly increasing. |
required |
Returns:
| Type | Description |
|---|---|
GriddedWind
|
The gridded wind on |