Footprint emulator¶
A trained network that maps a met context and a receptor position to the receptor's footprint row (FootprintEmulator), its training on pipekit-train (the emulate extra: FootprintSimulator and MetContextPrior feed a cached SimulationDataset built by footprint_dataset, and train_footprint_emulator runs a TrainingLoop with the MSE loss and EarlyStopping), and its calibration against the particle model: the relative Frobenius error (emulator_relative_error) and the probed source–receptor check through vardax (assert_emulator_calibrated, the assim extra). emulated_footprint returns the (obs, cell) matrix that linear_gaussian_inversion takes, so emulator-based inference is that inversion with the emulated footprint. Tier II, Steps 3–4.
emulator
¶
Footprint emulator and its calibration (implementation in plumax.lagrangian._src.emulator).
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
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 |