Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Puff — forward simulation

Gaussian puff — forward simulation

This notebook exercises the time-resolved Gaussian-puff forward model in plume_simulation.gauss_puff. Unlike the steady plume, the puff model releases a sequence of instantaneous puffs at fixed cadence; each puff travels with the (possibly time-varying) wind and spreads as a 3-D Gaussian. The total concentration field is the superposition of all active puffs.

We’ll cover: (1) a constant-wind scenario with ground-level snapshots, (2) a vertical cross-section, (3) a downwind transect evolving over time, (4) a stability-class sweep, and (5) a time-varying wind scenario that the steady plume cannot represent. Refer to Gaussian puff — derivation from the 3-D advection-diffusion equation for the mathematical background.

import matplotlib.pyplot as plt
import numpy as np

from plume_simulation.gauss_puff import simulate_puff

1. Constant wind — ground-level snapshots

Steady 5 m/s wind from the west, stability class C (slightly unstable), emission rate 0.1 kg/s, 2-Hz puff release. We pick a domain tight around the downwind plume so the plots aren’t dominated by empty space.

n_t = 61
time_array = np.linspace(0.0, 120.0, n_t, dtype=np.float32)
wind_speed = np.full(n_t, 5.0, dtype=np.float32)
wind_direction = np.full(n_t, 270.0, dtype=np.float32)

ds = simulate_puff(
    emission_rate=0.1,
    source_location=(0.0, 0.0, 2.0),
    wind_speed=wind_speed,
    wind_direction=wind_direction,
    stability_class="C",
    domain_x=(-30.0, 700.0, 151),
    domain_y=(-120.0, 120.0, 61),
    domain_z=(0.0, 60.0, 16),
    time_array=time_array,
    release_frequency=2.0,
)
print(ds)
<xarray.Dataset> Size: 38MB
Dimensions:               (time: 61, x: 151, y: 61, z: 16)
Coordinates:
  * time                  (time) float32 244B 0.0 2.0 4.0 ... 116.0 118.0 120.0
  * x                     (x) float64 1kB -30.0 -25.13 -20.27 ... 695.1 700.0
  * y                     (y) float64 488B -120.0 -116.0 -112.0 ... 116.0 120.0
  * z                     (z) float64 128B 0.0 4.0 8.0 12.0 ... 52.0 56.0 60.0
Data variables:
    concentration         (time, x, y, z) float32 36MB 0.0 0.0 ... 6.588e-09
    column_concentration  (time, x, y) float32 2MB 0.0 0.0 ... 1.084e-06
    wind_speed            (time) float32 244B 5.0 5.0 5.0 5.0 ... 5.0 5.0 5.0
    wind_direction        (time) float32 244B 270.0 270.0 270.0 ... 270.0 270.0
Attributes:
    title:                     Gaussian puff dispersion (JAX + diffrax)
    source_x:                  0.0
    source_y:                  0.0
    source_z:                  2.0
    stability_class:           C
    dispersion_scheme:         pg
    release_frequency:         2.0
    release_interval:          0.5
    n_puffs:                   240
    background_concentration:  0.0

The dataset holds the full 4-D concentration(time, x, y, z) field plus a 3-D column_concentration(time, x, y). The attrs block records the scenario metadata (source, stability class, dispersion scheme).

Ground-level evolution

snapshots = [15.0, 45.0, 75.0, 105.0]
fig, axes = plt.subplots(1, len(snapshots), figsize=(4.2 * len(snapshots), 4.0),
                          sharey=True)
vmax = float(ds["concentration"].sel(z=0, method="nearest").max())
for ax, t_k in zip(axes, snapshots):
    field = ds["concentration"].sel(time=t_k, method="nearest").sel(z=0, method="nearest")
    im = ax.pcolormesh(
        ds["x"], ds["y"], field.T,
        cmap="magma", vmin=0.0, vmax=vmax, shading="auto",
    )
    ax.scatter([0], [0], c="w", s=40, marker="*", edgecolors="k", label="source")
    ax.set_title(f"t = {t_k:.0f} s")
    ax.set_xlabel("x [m]")
    ax.set_aspect("equal")
axes[0].set_ylabel("y [m]")
cbar = fig.colorbar(im, ax=axes, shrink=0.9, pad=0.02)
cbar.set_label("C at z=0 [kg/m³]")
fig.suptitle("Ground-level concentration — 5 m/s wind from the west, class C")
plt.show()
<Figure size 1680x400 with 5 Axes>

Each snapshot shows a lumpy “trail” of puffs stretching downwind — near the source the individual puff peaks are still resolved (release interval 0.5 s, puff-width growing from ~0.5 m), but by a few hundred metres the puffs overlap into a smooth plume.

Column integral

The column-integrated concentration ∫ C dz [kg/m²] — what a remote-sensing instrument in the vertical would see — smooths over the z-structure.

fig, axes = plt.subplots(1, len(snapshots), figsize=(4.2 * len(snapshots), 4.0),
                          sharey=True)
vmax_col = float(ds["column_concentration"].max())
for ax, t_k in zip(axes, snapshots):
    field = ds["column_concentration"].sel(time=t_k, method="nearest")
    im = ax.pcolormesh(
        ds["x"], ds["y"], field.T,
        cmap="viridis", vmin=0.0, vmax=vmax_col, shading="auto",
    )
    ax.scatter([0], [0], c="w", s=40, marker="*", edgecolors="k")
    ax.set_title(f"t = {t_k:.0f} s")
    ax.set_xlabel("x [m]")
    ax.set_aspect("equal")
axes[0].set_ylabel("y [m]")
cbar = fig.colorbar(im, ax=axes, shrink=0.9, pad=0.02)
cbar.set_label("column C [kg/m²]")
fig.suptitle("Column-integrated concentration")
plt.show()
<Figure size 1680x400 with 5 Axes>

2. Vertical cross-section along the plume centreline

Slice at y = 0 showing how mass spreads vertically as it travels downwind. Ground reflection keeps the concentration highest near z = 0 for a low-elevated source.

t_eval = 100.0
slice_yz = ds["concentration"].sel(time=t_eval, method="nearest").sel(y=0, method="nearest")

fig, ax = plt.subplots(figsize=(9, 3.5))
pcm = ax.pcolormesh(
    ds["x"], ds["z"], slice_yz.T,
    cmap="magma", shading="auto",
)
ax.scatter([0], [2], c="w", s=40, marker="*", edgecolors="k", label="source (z=2 m)")
ax.set_xlabel("x [m] (downwind)")
ax.set_ylabel("z [m]")
ax.set_title(f"Vertical cross-section at y=0, t={t_eval:.0f} s (class C, 5 m/s)")
cbar = fig.colorbar(pcm, ax=ax, pad=0.02)
cbar.set_label("C [kg/m³]")
ax.legend(loc="upper right")
plt.show()
<Figure size 900x350 with 2 Axes>

3. Downwind transect vs. time

Following the concentration at a fixed downwind receptor over the simulation window gives the arrival-and-decay curve every in-situ sensor sees. The puff field builds up as successive puffs pass the receptor, and approaches a steady value once the source has been running long enough for puffs born “outside” the domain of interest.

receptors_x = [100.0, 250.0, 400.0, 600.0]
fig, ax = plt.subplots(figsize=(9, 4))
for x_r in receptors_x:
    curve = ds["concentration"].sel(x=x_r, method="nearest").sel(
        y=0, method="nearest").sel(z=1, method="nearest")
    ax.plot(ds["time"], curve, label=f"x = {x_r:.0f} m")
ax.set_xlabel("t [s]")
ax.set_ylabel("C at (x, 0, 1 m) [kg/m³]")
ax.set_title("Downwind transect — arrival and saturation")
ax.grid(alpha=0.3)
ax.legend()
plt.show()
<Figure size 900x400 with 1 Axes>

4. Stability-class sweep

For the same wind and emission rate, stability class controls how aggressively puffs spread. Class A (very unstable) has the widest puffs and lowest centreline concentration; class F (very stable) keeps the plume narrow and hot.

fig, axes = plt.subplots(2, 3, figsize=(13, 7), sharex=True, sharey=True)
snap_t = 90.0
for ax, cls in zip(axes.ravel(), "ABCDEF"):
    ds_cls = simulate_puff(
        emission_rate=0.1,
        source_location=(0.0, 0.0, 2.0),
        wind_speed=wind_speed,
        wind_direction=wind_direction,
        stability_class=cls,
        domain_x=(-30.0, 700.0, 101),
        domain_y=(-200.0, 200.0, 61),
        domain_z=(0.0, 40.0, 9),
        time_array=time_array,
        release_frequency=2.0,
    )
    field = ds_cls["column_concentration"].sel(time=snap_t, method="nearest")
    im = ax.pcolormesh(
        ds_cls["x"], ds_cls["y"], field.T,
        cmap="viridis", shading="auto",
    )
    ax.set_title(f"class {cls}")
    ax.set_aspect("equal")
    ax.scatter([0], [0], c="w", s=25, marker="*", edgecolors="k")
for ax in axes[-1, :]:
    ax.set_xlabel("x [m]")
for ax in axes[:, 0]:
    ax.set_ylabel("y [m]")
fig.suptitle(f"Column concentration at t={snap_t:.0f} s — stability sweep")
plt.tight_layout()
plt.show()
<Figure size 1300x700 with 6 Axes>

5. Time-varying wind

Diffrax-driven cumulative integration lets us advect puffs under arbitrary wind histories. Here the wind direction sweeps 30° during the simulation — a “wind shift” event — and the plume bends accordingly. The steady plume model cannot represent this scenario at all.

wind_speed_sweep = np.full(n_t, 4.0, dtype=np.float32)
# Linear sweep 280° → 250° (wind rotates counterclockwise from the "from-west"
# direction, pulling the plume's downwind axis upward in y).
wind_direction_sweep = np.linspace(280.0, 250.0, n_t).astype(np.float32)

ds_sweep = simulate_puff(
    emission_rate=0.1,
    source_location=(0.0, 0.0, 2.0),
    wind_speed=wind_speed_sweep,
    wind_direction=wind_direction_sweep,
    stability_class="C",
    domain_x=(-30.0, 700.0, 141),
    domain_y=(-150.0, 300.0, 91),
    domain_z=(0.0, 40.0, 9),
    time_array=time_array,
    release_frequency=2.0,
)

fig, axes = plt.subplots(1, 4, figsize=(16.5, 4.0), sharey=True)
sweep_snaps = [30.0, 60.0, 90.0, 120.0]
vmax = float(ds_sweep["column_concentration"].max())
for ax, t_k in zip(axes, sweep_snaps):
    field = ds_sweep["column_concentration"].sel(time=t_k, method="nearest")
    im = ax.pcolormesh(
        ds_sweep["x"], ds_sweep["y"], field.T,
        cmap="viridis", vmin=0.0, vmax=vmax, shading="auto",
    )
    ax.scatter([0], [0], c="w", s=40, marker="*", edgecolors="k")
    ax.set_title(f"t = {t_k:.0f} s")
    ax.set_xlabel("x [m]")
    ax.set_aspect("equal")
axes[0].set_ylabel("y [m]")
cbar = fig.colorbar(im, ax=axes, shrink=0.9, pad=0.02)
cbar.set_label("column C [kg/m²]")
fig.suptitle("Wind shift 280° → 250° — plume bends over time")
plt.show()
<Figure size 1650x400 with 5 Axes>

Older puffs were advected under the earlier (more westerly) wind and sit at lower y; newer puffs track the rotated direction. The bent trail is a signature diagnostic of non-stationarity and motivates the state-estimation workflow in Puff — state estimation.

What next?