Meteorology¶
Gridded meteorology (MetField) on the les_fvm analysis grid, the wrfout and ERA5 loaders, and the adapter that feeds it to the Eulerian solver as wind; the shared lat/lon frame (LocalFrame) and clock (TimeAxis) every loader places data with; Monin–Obukhov surface-layer scales (MoninObukhov), a bulk-Richardson PBL height and the Hanna turbulence factory. Tier 0. The loaders and LocalFrame's transforms need pip install 'plumax[io]'.
met
¶
Meteorological forcing for the transport tiers (Tier 0 prerequisites).
The transport models are driven by an external meteorology; this package owns the container those models consume and the loaders that populate it from numerical-weather-prediction output.
- :class:
MetField— gridded wind, temperature, pressure and PBL height on ales_fvmanalysis grid, on theles_fvmC-grid stagger, with a time axis. - :func:
load_wrf— read awrfoutfile onto an analysis grid. - :func:
load_era5— read ERA5 pressure- and single-level files onto an analysis grid placed by a :class:LocalFrame. - :class:
LocalFrame— the analysis grid's metric frame on lat/lon (a UTM zone shifted to an origin); built and applied outsidejit. - :class:
TimeAxis— seconds sincet0↔ UTC timestamps. - :class:
MoninObukhov— Monin–Obukhovu*,1/L,w*(pure JAX), with the stability functions in :mod:plumax.met.surface_layer. - :func:
pbl_height_bulk_richardson/ :func:hanna_turbulence_from— PBL height from the analysis profiles, and Tier II's Hanna turbulence built from the met. - :func:
to_prescribed_wind— expose a :class:MetFieldas the :class:~plumax.les_fvm.wind.PrescribedWindFieldthe Eulerian solver takes, with piecewise-linear interpolation between met times.
MetField
¶
Bases: Module
Meteorological forcing on an analysis grid with a time axis.
Attributes:
| Name | Type | Description |
|---|---|---|
plume_grid |
PlumeGrid3D
|
Analysis grid every field lives on. |
times |
Float[Array, ' n_time']
|
Seconds since |
u |
Float[Array, 'n_time nz ny nx']
|
Eastward wind [m/s] at U-points (T-point |
v |
Float[Array, 'n_time nz ny nx']
|
Northward wind [m/s] at V-points (T-point |
w |
Float[Array, 'n_time nz ny nx']
|
Vertical wind [m/s] at T-points. |
temperature |
Float[Array, 'n_time nz ny nx']
|
Air temperature [K] at T-points. |
pressure |
Float[Array, 'n_time nz ny nx']
|
Air pressure [Pa] at T-points. |
pbl_height |
Float[Array, 'n_time ny nx']
|
Boundary-layer height above ground [m] at horizontal
T-points, shape |
t0 |
str
|
ISO-8601 timestamp that |
wind_at
¶
wind_at(t: Float[Array, '']) -> tuple[Float[Array, 'nz ny nx'], Float[Array, 'nz ny nx'], Float[Array, 'nz ny nx']]
Interior (u, v, w) at time t, piecewise-linear in time.
Met is typically hourly while the transport step is seconds, so the
wind is interpolated linearly between the two bracketing met times
(policy (b) of the Tier 0 design page). Outside [times[0],
times[-1]] the nearest end value is held constant.
Source code in src/plumax/met/field.py
LocalFrame
dataclass
¶
UTM metres relative to an origin, as the analysis grid's frame.
(x, y) = (E(φ, λ) − E₀, N(φ, λ) − N₀),
with E, N the easting and northing in the UTM zone epsg and
(E₀, N₀) the origin's. The axes are UTM grid axes, which differ from
true east and north by the meridian convergence (under a degree near the
zone's central meridian, about 3° at its edges at mid-latitudes).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
origin_lat
|
float
|
Latitude φ₀ of |
required |
origin_lon
|
float
|
Longitude λ₀ of |
required |
epsg
|
int
|
EPSG code of the projected CRS; :meth: |
required |
Raises:
| Type | Description |
|---|---|
MissingExtraError
|
When a transform runs without |
Examples:
The origin maps to zero, and a round trip is exact to well under 1 mm:
>>> import numpy as np
>>> frame = LocalFrame.at(31.95, -102.15)
>>> x, y = frame.to_xy(31.95, -102.15)
>>> bool(np.allclose([x, y], 0.0, atol=1e-6))
True
>>> lat, lon = frame.to_latlon(250.0, -400.0)
>>> bool(np.allclose(frame.to_xy(lat, lon), [250.0, -400.0], atol=1e-6))
True
Arrays keep their shape:
The configuration round-trips:
at
classmethod
¶
The frame in the UTM zone containing (lat, lon), origin there.
to_xy
¶
to_xy(lat: Float[ndarray, '*#points'] | float, lon: Float[ndarray, '*#points'] | float) -> tuple[Float[np.ndarray, '*shape'], Float[np.ndarray, '*shape']]
Frame metres (x, y) of points given by latitude φ and longitude λ.
Source code in src/plumax/met/_src/frames.py
to_latlon
¶
to_latlon(x: Float[ndarray, '*#points'] | float, y: Float[ndarray, '*#points'] | float) -> tuple[Float[np.ndarray, '*shape'], Float[np.ndarray, '*shape']]
Latitude φ and longitude λ of points given in frame metres.
Source code in src/plumax/met/_src/frames.py
east_north_axes
¶
east_north_axes(lat: Float[ndarray, '*#points'] | float, lon: Float[ndarray, '*#points'] | float) -> tuple[Float[np.ndarray, '*shape 2'], Float[np.ndarray, '*shape 2']]
Unit vectors of true east ê and true north n̂, in frame components.
ê = ∂(x, y)/∂λ / ‖∂(x, y)/∂λ‖, n̂ = ∂(x, y)/∂φ / ‖∂(x, y)/∂φ‖.
A wind (u_e, v_n) has frame components u_e ê + v_n n̂. The
derivatives are forward differences of :meth:to_xy over
:data:AXIS_STEP_DEGREES, so the vectors carry the meridian convergence
and nothing is assumed about the projection.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lat
|
Float[ndarray, '*#points'] | float
|
Latitudes φ [degrees north]. |
required |
lon
|
Float[ndarray, '*#points'] | float
|
Longitudes λ [degrees east], broadcastable with |
required |
Returns:
| Type | Description |
|---|---|
Float[ndarray, '*shape 2']
|
|
Float[ndarray, '*shape 2']
|
vector in its last axis. |
Examples:
Near a zone's central meridian the frame barely rotates; 2.85° east of it, at 32° N, grid north leans by about sin φ · Δλ ≈ 1.5°:
>>> import numpy as np
>>> frame = LocalFrame.at(31.95, -102.15)
>>> east, north = frame.east_north_axes(31.95, -102.15)
>>> east.shape
(2,)
>>> lean = np.degrees(np.arctan2(north[0], north[1]))
>>> bool(1.0 < abs(lean) < 2.0)
True
Source code in src/plumax/met/_src/frames.py
grid_indices
¶
grid_indices(lat: Float[ndarray, '*#points'] | float, lon: Float[ndarray, '*#points'] | float, grid: PlumeGrid3D) -> tuple[Int[np.ndarray, '*shape'], Int[np.ndarray, '*shape']]
Indices (i, j) of the analysis T-cells containing lat/lon points.
i = ⌊(x − x₀ + Δx/2) / Δx⌋, j = ⌊(y − y₀ + Δy/2) / Δy⌋,
with x₀ = grid.x[0]; points outside the grid get -1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lat
|
Float[ndarray, '*#points'] | float
|
Latitudes φ [degrees north]. |
required |
lon
|
Float[ndarray, '*#points'] | float
|
Longitudes λ [degrees east], broadcastable with |
required |
grid
|
PlumeGrid3D
|
Analysis grid, in this frame's metres. |
required |
Returns:
| Type | Description |
|---|---|
tuple[Int[ndarray, '*shape'], Int[ndarray, '*shape']]
|
|
Examples:
>>> import jax.numpy as jnp
>>> import numpy as np
>>> from plumax.les_fvm.grid import make_grid
>>> frame = LocalFrame.at(31.95, -102.15)
>>> grid = make_grid(
... (0.0, 400.0, 4), (0.0, 300.0, 4), (0.0, 50.0, 4), dtype=jnp.float64
... )
>>> x, y = np.array([60.0, -10.0]), np.array([140.0, 50.0])
>>> lat, lon = frame.to_latlon(x, y)
>>> i, j = frame.grid_indices(lat, lon, grid)
>>> i.tolist(), j.tolist()
([0, -1], [1, -1])
Source code in src/plumax/met/_src/frames.py
get_config
¶
from_config
classmethod
¶
MoninObukhov
¶
Bases: Module
Diagnose u, 1/L and w from a MetField's lowest level (dry air).
u = κ U / (ln(z/z₀) − ψ_m(z/L) + ψ_m(z₀/L)), 1/L = −κ g H / (ρ c_p T u³), w* = (g H h / (ρ c_p T))^{1/3} for H > 0, else 0.
The usual forms carry g w'θ'/θ. H/(ρ c_p) is the actual-temperature flux w'T' and w'θ' = (θ/T) w'T', so the potential-temperature factor cancels and T remains.
Algorithm
- Take the lowest T-level (height z = Δz/2 above the grid's surface): wind speed from the neighbouring U- and V-points, T, p and ρ.
- Start neutral (1/L = 0) and iterate u ← log law, 1/L ← −b/u³
n_iterationstimes, with u* floored at :data:U_STAR_FLOORwhere it divides. - Evaluate w* where H > 0.
Calm and neutral gives u = 0, 1/L = 0, w = 0. Very stable cases (z/L ≳ 1) converge slowly; check the residual there.
Attributes:
| Name | Type | Description |
|---|---|---|
n_iterations |
int
|
Fixed-point iterations of the u*–L system (≥ 1). |
Call arguments: the MetField; z0, the roughness length z₀ [m]; and
sensible_heat_flux, H [W m⁻²] positive upward. z0 and H are scalars
or broadcast against (time, y, x).
Raises:
| Type | Description |
|---|---|
PlumaxError
|
If |
Examples:
Neutral air over 5 m s⁻¹ wind: the log law, and 1/L = 0:
>>> import jax.numpy as jnp
>>> from plumax.les_fvm.grid import make_grid
>>> from plumax.met.field import MetField
>>> grid = make_grid(
... (0.0, 400.0, 4), (0.0, 400.0, 4), (0.0, 200.0, 4), dtype=jnp.float64
... )
>>> shape = (1, 4, 4, 4)
>>> field = MetField(
... plume_grid=grid, times=jnp.zeros(1), u=jnp.full(shape, 5.0),
... v=jnp.zeros(shape), w=jnp.zeros(shape),
... temperature=jnp.full(shape, 300.0), pressure=jnp.full(shape, 1.0e5),
... pbl_height=jnp.full((1, 4, 4), 800.0),
... )
>>> layer = MoninObukhov()(field, z0=0.1, sensible_heat_flux=0.0)
>>> layer.u_star.shape
(1, 4, 4)
>>> bool(jnp.allclose(layer.u_star, 0.4 * 5.0 / jnp.log(25.0 / 0.1)))
True
Daytime heating makes the column unstable and convective:
>>> sunny = MoninObukhov()(field, z0=0.1, sensible_heat_flux=200.0)
>>> bool((sunny.inverse_obukhov_length < 0).all() & (sunny.w_star > 0).all())
True
The configuration round-trips:
>>> MoninObukhov.from_config(MoninObukhov(n_iterations=8).get_config())
MoninObukhov(n_iterations=8)
References
Monin & Obukhov (1954); Businger et al. (1971), JAS 28, 181–189.
SurfaceLayer
¶
Bases: Module
Surface-layer scales on the analysis grid's horizontal T-points.
Attributes:
| Name | Type | Description |
|---|---|---|
u_star |
Float[Array, 'time y x']
|
Friction velocity u* [m s⁻¹]. |
inverse_obukhov_length |
Float[Array, 'time y x']
|
1/L [m⁻¹]; zero is neutral, negative unstable. Stored as 1/L because it is finite at neutral. |
w_star |
Float[Array, 'time y x']
|
Convective velocity w* [m s⁻¹]; zero unless H > 0. |
TimeAxis
dataclass
¶
Seconds since t0 for a set of UTC sample times.
s_k = (τ_k − t₀) / 1 s,
with τ_k the sample timestamps and t₀ the reference the model counts
from (by default the first sample).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
t0
|
datetime64
|
Reference time t₀, |
required |
seconds
|
Float[ndarray, ' time']
|
Sample times s_k as seconds since |
required |
Examples:
Hourly samples, and back from seconds to timestamps:
>>> axis = TimeAxis.from_datetimes(
... ["2024-06-01T00:00", "2024-06-01T01:00", "2024-06-01T03:30"]
... )
>>> axis.seconds.tolist()
[0.0, 3600.0, 12600.0]
>>> str(axis.to_datetime(5400.0))
'2024-06-01T01:30:00.000'
An explicit origin, and the stamp MetField.t0 stores:
>>> late = TimeAxis.from_datetimes(["2024-06-01T01:00"], t0="2024-06-01")
>>> late.seconds.tolist(), late.t0_iso
([3600.0], '2024-06-01_00:00:00')
Unsorted timestamps are rejected:
>>> TimeAxis.from_datetimes(["2024-06-02", "2024-06-01"])
...
Traceback (most recent call last):
...
TimeAxisError: TimeAxis timestamps must be strictly increasing
t0_iso
property
¶
t0 as YYYY-MM-DD_HH:MM:SS[.fff], the stamp MetField.t0 stores.
Milliseconds are kept when t0 has them, so t0_iso plus
seconds reproduces every sample time exactly.
from_datetimes
classmethod
¶
Build the axis from timestamps τ_k (anything datetime64 accepts).
Raises:
| Type | Description |
|---|---|
TimeAxisError
|
If |
Source code in src/plumax/met/_src/time.py
to_seconds
¶
Seconds since t0 of timestamps: s = (τ − t₀) / 1 s.
to_datetime
¶
Timestamps τ = t₀ + s, rounded to the millisecond.
Source code in src/plumax/met/_src/time.py
load_era5
¶
load_era5(pressure_levels: str | Path, single_levels: str | Path, *, plume_grid: PlumeGrid3D, frame: LocalFrame, times: slice | None = None) -> MetField
Load ERA5 pressure- and single-level files onto an analysis grid.
w = −ω / (ρ g), ρ = p / (R_d T), z_k = hypsometric height of level k.
Algorithm
- Read both files, check they share times and a lat/lon grid, and order the levels surface first.
- Integrate each level's height above ground from
sp. - Convert ω to w. Unwrap a regional subset's longitudes so they increase across 0°/360° or ±180°; close the seam of a global grid.
- Interpolate every column to the analysis heights, then sample the analysis T-, U- and V-points bilinearly and project the wind onto the frame's axes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pressure_levels
|
str | Path
|
NetCDF with |
required |
single_levels
|
str | Path
|
NetCDF with |
required |
plume_grid
|
PlumeGrid3D
|
Analysis grid, in |
required |
frame
|
LocalFrame
|
Frame the analysis grid is laid out in. |
required |
times
|
slice | None
|
Optional |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
MetField
|
class: |
Raises:
| Type | Description |
|---|---|
MissingExtraError
|
If the |
MissingVariableError
|
If a required variable is missing. |
GridMismatchError
|
If the two files' times or lat/lon grids differ, or the longitudes are unevenly spaced. |
TimeAxisError
|
If |
DomainError
|
If an analysis point falls outside the ERA5 domain. |
Source code in src/plumax/met/_src/era5.py
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 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 | |
to_prescribed_wind
¶
Expose a :class:MetField as the wind object the Eulerian solver takes.
The returned :class:~plumax.les_fvm.wind.PrescribedWindField samples
:meth:MetField.wind_at — so simulate_eulerian_dispersion(
wind_field=to_prescribed_wind(field), ...) runs the FV transport on
loaded meteorology with no other change. The caller's domain_*
triples must describe the same grid field.plume_grid was built on.
Source code in src/plumax/met/field.py
hanna_turbulence_from
¶
hanna_turbulence_from(field: MetField, layer: SurfaceLayer, *, it: int, iy: int, ix: int, coriolis_parameter: float = DEFAULT_CORIOLIS_PARAMETER) -> HannaTurbulence
Hanna (1982) turbulence at one analysis column and met time.
L = 1 / (1/L), passed as 0 at neutral.
HannaTurbulence treats L = 0 as neutral (as well as any h/|L| < 1),
so a neutral column (1/L = 0) is passed as L = 0 rather than ±inf.
MetField carries no latitude, so the Coriolis parameter f is an
argument with FLEXPART's default.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
field
|
MetField
|
Met on the analysis grid (for the PBL height h). |
required |
layer
|
SurfaceLayer
|
Surface-layer scales from :class: |
required |
it
|
int
|
Met time index. |
required |
iy
|
int
|
Analysis y index. |
required |
ix
|
int
|
Analysis x index. |
required |
coriolis_parameter
|
float
|
Coriolis parameter f = 2Ω sin φ [1/s] for the
neutral profiles; the default 1e-4 s⁻¹ (latitude ≈ 43°) is the
value FLEXPART's |
DEFAULT_CORIOLIS_PARAMETER
|
Returns:
| Type | Description |
|---|---|
HannaTurbulence
|
The turbulence model, with plain-float parameters. |
Examples:
A neutral column maps to Hanna's L = 0, an unstable one keeps its sign:
>>> import jax.numpy as jnp
>>> from plumax.les_fvm.grid import make_grid
>>> from plumax.met._src.surface_layer import MoninObukhov
>>> from plumax.met.field import MetField
>>> grid = make_grid(
... (0.0, 400.0, 4), (0.0, 400.0, 4), (0.0, 200.0, 4), dtype=jnp.float64
... )
>>> shape = (1, 4, 4, 4)
>>> field = MetField(
... plume_grid=grid, times=jnp.zeros(1), u=jnp.full(shape, 5.0),
... v=jnp.zeros(shape), w=jnp.zeros(shape),
... temperature=jnp.full(shape, 300.0), pressure=jnp.full(shape, 1.0e5),
... pbl_height=jnp.full((1, 4, 4), 800.0),
... )
>>> neutral = MoninObukhov()(field, z0=0.1, sensible_heat_flux=0.0)
>>> turbulence = hanna_turbulence_from(field, neutral, it=0, iy=1, ix=0)
>>> turbulence.obukhov_length, turbulence.pbl_height
(0.0, 800.0)
>>> sunny = MoninObukhov()(field, z0=0.1, sensible_heat_flux=200.0)
>>> hanna_turbulence_from(field, sunny, it=0, iy=1, ix=0).obukhov_length < 0
True
Source code in src/plumax/met/_src/pbl.py
pbl_height_bulk_richardson
¶
pbl_height_bulk_richardson(field: MetField, *, ri_crit: float = 0.25) -> Float[np.ndarray, 'time y x']
PBL height h from the bulk Richardson number of the analysis profiles.
Ri_b(z) = g (θ(z) − θ_s)(z − z_s) / (θ_s |𝐮(z) − 𝐮_s|²), h: first Ri_b = Ri_c,
with θ = T (p_ref/p)^{R_d/c_p} (a MetField has no humidity, so θ_v = θ)
and the surface values taken from the lowest level.
Algorithm
- Build θ, the T-point wind and the heights above the grid's surface.
- Hand them to :func:
xrtoolz.atm.pbl_height_bulk_richardson, which finds the first crossing and interpolates between levels.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
field
|
MetField
|
Met on the analysis grid. |
required |
ri_crit
|
float
|
Critical bulk Richardson number Ri_c. |
0.25
|
Returns:
| Type | Description |
|---|---|
Float[ndarray, 'time y x']
|
h [m]; NaN where no level reaches Ri_c inside the grid. |
Raises:
| Type | Description |
|---|---|
MissingExtraError
|
If the |
Examples:
A neutral layer under an inversion at 400 m, with shear:
>>> import jax.numpy as jnp
>>> from plumax.les_fvm.grid import make_grid
>>> from plumax.met.field import MetField
>>> grid = make_grid(
... (0.0, 400.0, 4), (0.0, 400.0, 4), (0.0, 1000.0, 20), dtype=jnp.float64
... )
>>> z = grid.z - (grid.z[0] - 0.5 * grid.dz)
>>> theta = 300.0 + 0.01 * jnp.maximum(z - 400.0, 0.0)
>>> column = lambda f: jnp.broadcast_to(f[None, :, None, None], (1, 20, 4, 4))
>>> field = MetField(
... plume_grid=grid, times=jnp.zeros(1), u=column(2.0 + 0.005 * z),
... v=jnp.zeros((1, 20, 4, 4)), w=jnp.zeros((1, 20, 4, 4)),
... temperature=column(theta), pressure=jnp.full((1, 20, 4, 4), 1.0e5),
... pbl_height=jnp.zeros((1, 4, 4)),
... )
>>> height = pbl_height_bulk_richardson(field)
>>> height.shape
(1, 4, 4)
>>> bool(((height > 350.0) & (height < 1000.0)).all())
True
Source code in src/plumax/met/_src/pbl.py
load_wrf
¶
load_wrf(path: str | Path, *, plume_grid: PlumeGrid3D, origin: tuple[float, float] | LocalFrame = (0.0, 0.0), times: slice | None = None) -> MetField
Load a wrfout file onto an analysis grid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
NetCDF |
required |
plume_grid
|
PlumeGrid3D
|
Analysis grid (from :func: |
required |
origin
|
tuple[float, float] | LocalFrame
|
WRF-frame metres of the analysis grid's |
(0.0, 0.0)
|
times
|
slice | None
|
Optional |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
MetField
|
class: |
Raises:
| Type | Description |
|---|---|
MissingExtraError
|
If the |
TimeAxisError
|
If |
MissingVariableError
|
With a |
DomainError
|
With a |
ValueError
|
With metre offsets, if an analysis point falls outside
the WRF mass-point domain (the loader never extrapolates
horizontally); or if the file has no usable time axis ( |
Source code in src/plumax/met/wrf.py
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 195 196 197 198 199 200 201 202 | |