blpd.plot

Create plots of LPD model results.

The functions here all take xr.Datasets as their first argument.

  1"""
  2Create plots of LPD model results.
  3
  4The functions here all take `xr.Dataset`s as their first argument.
  5"""
  6
  7from itertools import cycle
  8
  9import matplotlib as mpl
 10import matplotlib.pyplot as plt
 11import numpy as np
 12from matplotlib.collections import LineCollection as _LineCollection
 13from mpl_toolkits.mplot3d import Axes3D as _Axes3D  # noqa: F401 unused import
 14
 15from . import utils
 16from .utils import _add_snippets
 17
 18# ^ could add `as _{}` to all of these
 19# to indicate that these are not intended to be public parts of the module name space
 20# since __all__ is not respected by linters or autocompleters
 21
 22__all__ = (
 23    "conc_2d",
 24    "conc_scatter",
 25    "conc_xline",
 26    "final_pos_hist",
 27    "final_pos_hist2d",
 28    "final_pos_scatter",
 29    "trajectories",
 30    "ws_hist",
 31)
 32
 33# TODO: create some base classes for plots to reduce repeating of code
 34#       - allow passing fig and ax kwargs in __init__
 35#       - stars to mark sources; check_fig_num, labeling, etc.
 36#       - alpha calculation based on number of particles & spread?
 37
 38# TODO: really all plots could have the auto-bounds stuff. and (optionally?) print message about it?
 39
 40
 41_SOURCE_MARKER_PROPS = dict(marker="*", c="gold", ms=11, mec="0.35", mew=1.0)
 42
 43utils._SNIPPETS.update(
 44    spc_params="""
 45spc : str
 46    ASCII identifier of a chemical species. For example, `'apinene'`.
 47    """.strip(),
 48    ds_lpd_params="""
 49ds : xr.Dataset
 50    Dataset of Lagrangian particle positions, e.g., from `blpd.model.Model.to_xr`.
 51    """.strip(),
 52    ds_chem_params="""
 53ds : xr.Dataset
 54    Dataset of Lagrangian particle positions + chemistry, e.g., from
 55    `blpd.chem.calc_relative_levels_fix_oxidants`.
 56    """.strip(),
 57    log_cnorm_params="""
 58log_cnorm : bool
 59    Whether to use a logarithmic colormap normalization.
 60    """.strip(),
 61)
 62
 63
 64@_add_snippets
 65def final_pos_scatter(ds, sdim="xy"):
 66    """Scatter plot of particle end positions.
 67
 68    Parameters
 69    ----------
 70    %(ds_lpd_params)s
 71    %(sdim_params)s
 72    """
 73    p = utils.load_p(ds)
 74
 75    dims = utils.dims_from_sdim(sdim)
 76    if len(dims) == 3:
 77        sdim = "xyz"
 78        x = ds.x.values
 79        y = ds.y.values
 80        z = ds.z.values
 81        subplot_kw = {"projection": "3d"}
 82        coords = (x, y, z)
 83        plot_kw = dict(alpha=0.5, mew=0, ms=7)
 84    elif len(dims) == 2:
 85        x = ds[dims[0]].values
 86        y = ds[dims[1]].values
 87        subplot_kw = {}
 88        coords = (x, y)
 89        plot_kw = dict(alpha=0.5, mfc="none", mew=0.8, ms=5)
 90    else:
 91        raise ValueError("invalid `sdim`")
 92
 93    num = utils.check_fig_num(f"final-pos-scatter-{sdim}")
 94    fig, ax = plt.subplots(num=num, subplot_kw=subplot_kw)
 95
 96    ax.plot(*coords, "o", **plot_kw)
 97
 98    ax.set_xlabel(f"${dims[0]}$")
 99    ax.set_ylabel(f"${dims[1]}$")
100    if len(dims) == 3:
101        ax.set_zlabel(f"${dims[2]}$")
102    ax.set_title(utils.s_t_info(p), loc="left")
103    ax.set_title(f"$N_p = {p['Np_tot']}$", loc="right")
104
105    for xs, ys in p["source_positions"]:
106        sp = dict(x=xs, y=ys, z=p["release_height"])
107        if len(dims) == 3:
108            ax.plot([sp[dims[0]]], [sp[dims[1]]], [sp[dims[2]]], "*", c="gold", ms=10)
109        else:
110            ax.plot(sp[dims[0]], sp[dims[1]], "*", c="gold", ms=10)
111
112    fig.set_tight_layout(True)
113
114
115# TODO: add option to do trajectories for continuous release runs, colored by time out
116# TODO: trajectories for hist run in 3-D?
117
118
119@_add_snippets
120def trajectories(ds, *, smooth=False, smooth_window_size=None, color_sources=False):
121    """Plot particle trajectories.
122
123    Parameters
124    ----------
125    %(ds_lpd_params)s
126    smooth : bool
127        Whether to smooth the trajectories.
128    smooth_window_size : int, optional
129        How many points to include in the moving average window.
130    color_sources : bool
131        Whether to color particle trajectories by their sources
132        to help in differentiating them.
133    """
134    if "t" not in ds.dims:
135        raise ValueError("time 't' must be a dimension")
136
137    p = utils.load_p(ds)
138    pos = np.stack((ds.x.values, ds.y.values, ds.z.values), axis=-1)  # same shape as hist["pos"]
139
140    t_tot = p["t_tot"]
141    dt = p["dt_out"]  # note output timestep not that of the integration
142    # N_t = p["N_t"]
143    Np = p["Np_tot"]
144    # N = Np * N_t
145    assert pos.shape[0] == Np
146
147    ltitle = utils.s_t_info(p)
148    rtitle = utils.s_sample_size(p)
149
150    # allow specifying smooth_window_size only
151    if smooth_window_size is not None and not smooth:
152        smooth = True
153    if smooth:
154        if smooth_window_size is None:
155            n = 100
156        else:
157            n = int(smooth_window_size)
158
159        if n * dt > 0.5 * t_tot:
160            raise ValueError(
161                "can't do any smoothing with the requested window size (not enough points)"
162            )
163        pos0 = pos[:, 0, :][:, np.newaxis, :]
164        pos = np.swapaxes(utils.moving_average(np.swapaxes(pos, 0, 1), n=n, axis=0), 0, 1)
165        # ^ `np.swapaxes` should return views, not create new arrays (ver >= 1.10.0)
166
167        # preserve starting point
168        pos = np.concatenate((pos0, pos), axis=1)  # axis=1 => basically `hstack`
169
170        ltitle = f"$N_{{smooth}} = {n}$ ({n*dt:} s)\n{ltitle}"  # add smoothing info to left title
171
172    num = utils.check_fig_num("trajectories")
173    fig, ax = plt.subplots(num=num)
174
175    if color_sources:
176        if isinstance(color_sources, (list, np.ndarray)):
177            colors = color_sources  # assume is a list of colors (TODO: should check)
178        else:
179            colors = plt.get_cmap("Dark2").colors
180            # Dark2 is a ListedColormap with 8 colors; `plt.cm.Dark2` same but pylint complains plt.cm `no-member` Dark2
181
182        N_sources = p["N_sources"]
183        for i_source, color in zip(range(N_sources), cycle(colors)):
184            segs = [pos[i, :, :2] for i in range(i_source, Np, N_sources)]
185
186            lc = _LineCollection(
187                segs,
188                linewidths=0.5,
189                colors=color,
190                linestyles="solid",
191                alpha=0.3,
192            )
193            ax.add_collection(lc)
194
195    else:
196        segs = [pos[i, :, :2] for i in range(Np)]
197
198        lc = _LineCollection(
199            segs,
200            linewidths=0.5,
201            colors="0.6",
202            linestyles="solid",
203            alpha=0.5,
204        )
205        ax.add_collection(lc)
206
207    for x, y in p["source_positions"]:
208        ax.plot(x, y, **_SOURCE_MARKER_PROPS)
209
210    ax.autoscale()  # `ax.add_collection` won't do this automatically
211    ax.set_xlabel("$x$")
212    ax.set_ylabel("$y$")
213    ax.set_title(ltitle, loc="left")
214    ax.set_title(rtitle, loc="right")
215
216    fig.set_tight_layout(True)
217
218
219@_add_snippets
220def conc_scatter(
221    ds,
222    spc="apinene",
223    sdim="xy",
224    *,
225    cmap="gnuplot",
226    log_cnorm=False,
227    vmax=100,
228    vmin=None,
229    ax=None,
230):
231    """Plot species relative level in particle as a scatter (2- or 3-d).
232
233    Parameters
234    ----------
235    %(ds_chem_params)s
236    %(spc_params)s
237    %(sdim_params)s
238    %(log_cnorm_params)s
239    """
240    p = utils.load_p(ds)
241
242    dims = utils.dims_from_sdim(sdim)
243    if len(dims) == 3:
244        sdim = "xyz"
245        x = ds.x.values
246        y = ds.y.values
247        z = ds.z.values
248        subplot_kw = {"projection": "3d"}
249        coords = (x, y, z)
250        # plot_kw = dict(alpha=0.5, mew=0, ms=7)
251    elif len(dims) == 2:
252        x = ds[dims[0]].values
253        y = ds[dims[1]].values
254        subplot_kw = {}
255        coords = (x, y)
256        # plot_kw = dict(alpha=0.5, mfc="none", mew=0.8, ms=5)
257    else:
258        raise ValueError("invalid `sdim`")
259
260    conc = ds.f_r.sel(spc=spc).values
261    spc_display_name = str(ds.display_name.sel(spc=spc).values)
262
263    num = utils.check_fig_num(f"horizontal-end-positions-with-conc_{spc}_scatter_{sdim}")
264    fig, ax = utils.maybe_new_figure(num, ax=ax, subplot_kw=subplot_kw)
265
266    norm, _ = utils.maybe_log_cnorm(log_cnorm=log_cnorm, levels=None, vmin=vmin, vmax=vmax)
267
268    im = ax.scatter(
269        *coords,
270        c=conc,
271        s=7,
272        marker="o",
273        alpha=0.4,
274        linewidths=0,
275        cmap=cmap,
276        norm=norm,
277    )
278    cb = fig.colorbar(im, ax=ax, drawedges=False)
279    cb.set_label(f"{spc_display_name} relative conc. (%)")
280
281    for xs, ys in p["source_positions"]:
282        sp = dict(x=xs, y=ys, z=p["release_height"])
283        ax.plot(*tuple(sp[dim] for dim in dims), **_SOURCE_MARKER_PROPS)
284
285    ax.set_title(utils.s_t_info(p), loc="left")
286    ax.set(
287        xlabel=f"{ds[dims[0]].attrs['long_name']} [{ds[dims[0]].attrs['units']}]",
288        ylabel=f"{ds[dims[1]].attrs['long_name']} [{ds[dims[1]].attrs['units']}]",
289    )
290    if len(dims) == 3:
291        ax.set_zlabel(f"{ds[dims[2]].attrs['long_name']} [{ds[dims[2]].attrs['units']}]")
292    fig.set_tight_layout(True)
293
294
295@_add_snippets
296def conc_2d(
297    ds,
298    spc="apinene",
299    *,
300    bins=(20, 10),
301    plot_type="pcolor",
302    levels=30,  # for contourf
303    cmap="gnuplot",
304    log_cnorm=False,
305    vmax=100,
306    vmin=None,
307    ax=None,
308):
309    """Plot species 2-d (*xy*) binned average species relative level.
310
311    Parameters
312    ----------
313    %(ds_chem_params)s
314    %(spc_params)s
315    %(bins_params)s
316    plot_type : {'pcolor', 'contourf'}
317    levels : int
318        Only used for plot type `'contourf'`.
319    %(log_cnorm_params)s
320    """
321    p = utils.load_p(ds)
322    X = ds.x.values
323    Y = ds.y.values
324    conc = ds.f_r.sel(spc=spc).values
325    spc_display_name = str(ds.display_name.sel(spc=spc).values)
326
327    if "x" not in ds.dims:
328        # We were passed the particles dataset, need to do the binning
329        binned = utils.bin_values_xy(X, Y, conc, bins=bins)
330    else:
331        raise NotImplementedError("already binned?")
332
333    fig, ax = utils.maybe_new_figure(f"horizontal-end-positions-with-conc_{spc}_{plot_type}", ax=ax)
334
335    norm, locator = utils.maybe_log_cnorm(
336        log_cnorm=log_cnorm,
337        levels=levels if plot_type == "contourf" else None,
338        vmin=vmin,
339        vmax=vmax,
340    )
341
342    if plot_type == "pcolor":
343        im = ax.pcolormesh(binned.xe, binned.ye, binned.v, cmap=cmap, norm=norm)
344    elif plot_type == "contourf":
345        im = ax.contourf(
346            binned.x, binned.y, binned.v, levels, cmap=cmap, norm=norm, locator=locator
347        )
348    else:
349        raise ValueError("`plot_type` should be 'pcolor' or 'contourf'")
350
351    cb = fig.colorbar(im, ax=ax, drawedges=False)
352    cb.set_label(f"{spc_display_name} relative conc. (%)")
353
354    for x, y in p["source_positions"]:
355        ax.plot(x, y, **_SOURCE_MARKER_PROPS)
356
357    ax.autoscale(enable=True, axis="both", tight=True)
358    ax.set_title(utils.s_t_info(p), loc="left")
359    ax.set(
360        xlabel=f"{ds.x.attrs['long_name']} [{ds.x.attrs['units']}]",
361        ylabel=f"{ds.y.attrs['long_name']} [{ds.y.attrs['units']}]",
362    )
363    fig.set_tight_layout(True)
364
365
366@_add_snippets
367def conc_xline(
368    ds,
369    spc="apinene",
370    y=0.0,
371    *,
372    dy=1.0,
373    ax=None,  # TODO: select z as well?
374    legend=True,
375    label=None,
376    legend_title="Chemical species",
377):
378    r"""Plot species average relative level in the *x*-direction at a certain approximate y value.
379    `spc` can be ``'all'`` (all will fit in one plot since we are plotting lines).
380
381    Parameters
382    ----------
383    %(ds_chem_params)s
384    %(spc_params)s
385    y : float
386        *y* value of our "line".
387    dy : float
388        We will include Lagrangian particles within $[-\delta y / 2, \delta y / 2]$.
389    """
390    p = utils.load_p(ds)
391    X = ds.x.values
392    Y = ds.y.values
393
394    fig, ax = utils.maybe_new_figure(f"horizontal-end-positions-with-conc_{spc}_line", ax=ax)
395
396    spc_to_plot = ds.spc.values if spc == "all" else [spc]
397
398    # Define bins (edges)
399    xe = utils._auto_bins_1d(X, nx_max=100, std_mult=2.0, method="sqrt")
400    ye = np.r_[y - 0.5 * dy, y + 0.5 * dy]
401    # ze = np.r_[z - 0.5*dz, z + 0.5*dz]
402    bins = [xe, ye]
403
404    for spc in spc_to_plot:
405        spc_display_name = str(ds.display_name.sel(spc=spc).values)
406        conc = ds.f_r.sel(spc=spc).values
407        binned = utils.bin_values_xy(X, Y, conc, bins=bins)
408        ax.plot(binned.x, binned.v.squeeze(), label=spc_display_name if label is None else label)
409
410    for xs, ys in p["source_positions"]:
411        if abs(ys - y) <= 5:
412            ax.plot(xs, np.nanmin(binned.v), **_SOURCE_MARKER_PROPS)
413
414    if legend:
415        fig.legend(
416            ncol=2,
417            fontsize="small",
418            title=legend_title,
419        )
420    ax.set_title(utils.s_t_info(p), loc="left")
421    ax.set(
422        xlabel=f"{ds.x.attrs['long_name']} [{ds.x.attrs['units']}]",
423        ylabel="Relative concentration",
424    )
425    fig.set_tight_layout(True)
426
427
428# TODO: x-y (or u-v) hists for different height (z) bins
429
430
431@_add_snippets
432def ws_hist(
433    ds,
434    *,
435    bounds=None,
436):
437    """Plot histograms of particle wind speed components (one subplot for each).
438    For a single- or continuous-release run.
439
440    Parameters
441    ----------
442    %(ds_lpd_params)s
443    bounds : 2-tuple(float), optional
444        If provided, used as wind speed limits for all histograms.
445    """
446    p = utils.load_p(ds)
447    u_all = np.ravel(ds.u.values)  # or can do `.ravel()`
448    v_all = np.ravel(ds.v.values)
449    w_all = np.ravel(ds.w.values)
450
451    num = utils.check_fig_num("ws-hist-all")
452    fig, axs = plt.subplots(3, 1, num=num, sharex=True)
453
454    if bounds is None:
455        bins = 100
456    else:
457        bins = np.linspace(bounds[0], bounds[1], 100)
458
459    labels = ["$u$", "$v$", "$w$"]
460    for i, (ui, ax) in enumerate(zip([u_all, v_all, w_all], axs.flat)):
461        ax.hist(ui, bins)
462        ax.text(0.01, 0.98, labels[i], va="top", ha="left", fontsize=13, transform=ax.transAxes)
463
464    if bounds:
465        axs[0].set_xlim(bounds)
466
467    axs[0].set_title(utils.s_t_info(p), loc="left")
468    axs[0].set_title(utils.s_sample_size(p), loc="right")
469    axs[0].set_xlabel(f"[{ds.u.attrs['units']}]")
470    fig.set_tight_layout(True)
471
472
473@_add_snippets
474def final_pos_hist(
475    ds,
476    *,
477    bounds=None,
478):
479    """Plot histograms of final position components (*x*, *y*, and *z*).
480
481    Parameters
482    ----------
483    %(ds_lpd_params)s
484    bounds : 2-tuple(float), optional
485        If provided, used as position component limits for all histograms.
486    """
487    p = utils.load_p(ds)
488    if "t" in ds.dims:
489        xf = ds.x.isel(t=-1).values
490        yf = ds.y.isel(t=-1).values
491        zf = ds.z.isel(t=-1).values
492    else:
493        xf = ds.x.values
494        yf = ds.y.values
495        zf = ds.z.values
496
497    num = utils.check_fig_num("final-pos-hist")
498    fig, axs = plt.subplots(3, 1, num=num, sharex=True)
499
500    if bounds is None:
501        bins = 100
502    else:
503        bins = np.linspace(bounds[0], bounds[1], 100)
504
505    labels = ["$x$", "$y$", "$z$"]
506    for i, (xi, ax) in enumerate(zip([xf, yf, zf], axs.flat)):
507        ax.hist(xi, bins)
508        ax.text(0.01, 0.98, labels[i], va="top", ha="left", fontsize=13, transform=ax.transAxes)
509
510    if bounds:
511        axs[0].set_xlim(bounds)
512
513    axs[0].set_title(utils.s_t_info(p), loc="left")
514    axs[0].set_title(utils.s_sample_size(p, N_p_only=True), loc="right")
515    axs[-1].set_xlabel(f"[{ds.x.attrs['units']}]")
516    fig.set_tight_layout(True)
517
518
519@_add_snippets
520def final_pos_hist2d(
521    ds,
522    *,
523    sdim="xy",
524    bins=50,
525    plot_type="pcolor",
526    log_cnorm=False,
527    vmax=None,
528):
529    """Plot 2-d histogram of selected final position components.
530
531    Parameters
532    ----------
533    %(ds_lpd_params)s
534    %(sdim_params)s
535    %(bins_params)s
536    %(log_cnorm_params)s
537    """
538    p = utils.load_p(ds)
539
540    dims = utils.dims_from_sdim(sdim)
541    if "t" in ds.dims:
542        x = ds[dims[0]].isel(t=-1).values
543        y = ds[dims[1]].isel(t=-1).values
544    else:
545        x = ds[dims[0]].values
546        y = ds[dims[1]].values
547
548    num = utils.check_fig_num(f"final-pos-hist-{sdim}")
549    fig, ax = plt.subplots(num=num)
550
551    if bins == "auto":
552        bins = utils.auto_bins_xy([x, y])
553
554    if log_cnorm:
555        norm = mpl.colors.LogNorm(vmin=1.0, vmax=vmax)
556    else:
557        norm = mpl.colors.Normalize(vmin=1.0, vmax=vmax)
558
559    _, _, _, im = ax.hist2d(x, y, bins=bins, norm=norm)
560    # ^ returns h (nx, ny), xedges, yedges, image
561
562    cb = fig.colorbar(im, ax=ax)
563    cb.set_label("Particle count")
564
565    for x, y in p["source_positions"]:
566        ax.plot(x, y, "*", c="gold", ms=11, mec="0.35", mew=1.0)
567
568    ax.set_xlabel(f"${dims[0]}$ [{ds.x.attrs['units']}]")
569    ax.set_ylabel(f"${dims[1]}$ [{ds.x.attrs['units']}]")
570    ax.autoscale(enable=True, axis="both", tight=True)
571    ax.set_title(utils.s_t_info(p), loc="left")
572    ax.set_title(utils.s_sample_size(p, N_p_only=True), loc="right")
573    fig.set_tight_layout(True)
def conc_2d( ds, spc='apinene', *, bins=(20, 10), plot_type='pcolor', levels=30, cmap='gnuplot', log_cnorm=False, vmax=100, vmin=None, ax=None):
296@_add_snippets
297def conc_2d(
298    ds,
299    spc="apinene",
300    *,
301    bins=(20, 10),
302    plot_type="pcolor",
303    levels=30,  # for contourf
304    cmap="gnuplot",
305    log_cnorm=False,
306    vmax=100,
307    vmin=None,
308    ax=None,
309):
310    """Plot species 2-d (*xy*) binned average species relative level.
311
312    Parameters
313    ----------
314    %(ds_chem_params)s
315    %(spc_params)s
316    %(bins_params)s
317    plot_type : {'pcolor', 'contourf'}
318    levels : int
319        Only used for plot type `'contourf'`.
320    %(log_cnorm_params)s
321    """
322    p = utils.load_p(ds)
323    X = ds.x.values
324    Y = ds.y.values
325    conc = ds.f_r.sel(spc=spc).values
326    spc_display_name = str(ds.display_name.sel(spc=spc).values)
327
328    if "x" not in ds.dims:
329        # We were passed the particles dataset, need to do the binning
330        binned = utils.bin_values_xy(X, Y, conc, bins=bins)
331    else:
332        raise NotImplementedError("already binned?")
333
334    fig, ax = utils.maybe_new_figure(f"horizontal-end-positions-with-conc_{spc}_{plot_type}", ax=ax)
335
336    norm, locator = utils.maybe_log_cnorm(
337        log_cnorm=log_cnorm,
338        levels=levels if plot_type == "contourf" else None,
339        vmin=vmin,
340        vmax=vmax,
341    )
342
343    if plot_type == "pcolor":
344        im = ax.pcolormesh(binned.xe, binned.ye, binned.v, cmap=cmap, norm=norm)
345    elif plot_type == "contourf":
346        im = ax.contourf(
347            binned.x, binned.y, binned.v, levels, cmap=cmap, norm=norm, locator=locator
348        )
349    else:
350        raise ValueError("`plot_type` should be 'pcolor' or 'contourf'")
351
352    cb = fig.colorbar(im, ax=ax, drawedges=False)
353    cb.set_label(f"{spc_display_name} relative conc. (%)")
354
355    for x, y in p["source_positions"]:
356        ax.plot(x, y, **_SOURCE_MARKER_PROPS)
357
358    ax.autoscale(enable=True, axis="both", tight=True)
359    ax.set_title(utils.s_t_info(p), loc="left")
360    ax.set(
361        xlabel=f"{ds.x.attrs['long_name']} [{ds.x.attrs['units']}]",
362        ylabel=f"{ds.y.attrs['long_name']} [{ds.y.attrs['units']}]",
363    )
364    fig.set_tight_layout(True)

Plot species 2-d (xy) binned average species relative level.

Parameters
  • ds (xr.Dataset): Dataset of Lagrangian particle positions + chemistry, e.g., from blpd.chem.calc_relative_levels_fix_oxidants.
  • spc (str): ASCII identifier of a chemical species. For example, 'apinene'.
  • bins: If bins='auto', blpd.utils.auto_bins is used to construct the bins. Alternatively, you can pass bin specifications in any of the forms described here.
  • plot_type ({'pcolor', 'contourf'}):

  • levels (int): Only used for plot type 'contourf'.

  • log_cnorm (bool): Whether to use a logarithmic colormap normalization.
def conc_scatter( ds, spc='apinene', sdim='xy', *, cmap='gnuplot', log_cnorm=False, vmax=100, vmin=None, ax=None):
220@_add_snippets
221def conc_scatter(
222    ds,
223    spc="apinene",
224    sdim="xy",
225    *,
226    cmap="gnuplot",
227    log_cnorm=False,
228    vmax=100,
229    vmin=None,
230    ax=None,
231):
232    """Plot species relative level in particle as a scatter (2- or 3-d).
233
234    Parameters
235    ----------
236    %(ds_chem_params)s
237    %(spc_params)s
238    %(sdim_params)s
239    %(log_cnorm_params)s
240    """
241    p = utils.load_p(ds)
242
243    dims = utils.dims_from_sdim(sdim)
244    if len(dims) == 3:
245        sdim = "xyz"
246        x = ds.x.values
247        y = ds.y.values
248        z = ds.z.values
249        subplot_kw = {"projection": "3d"}
250        coords = (x, y, z)
251        # plot_kw = dict(alpha=0.5, mew=0, ms=7)
252    elif len(dims) == 2:
253        x = ds[dims[0]].values
254        y = ds[dims[1]].values
255        subplot_kw = {}
256        coords = (x, y)
257        # plot_kw = dict(alpha=0.5, mfc="none", mew=0.8, ms=5)
258    else:
259        raise ValueError("invalid `sdim`")
260
261    conc = ds.f_r.sel(spc=spc).values
262    spc_display_name = str(ds.display_name.sel(spc=spc).values)
263
264    num = utils.check_fig_num(f"horizontal-end-positions-with-conc_{spc}_scatter_{sdim}")
265    fig, ax = utils.maybe_new_figure(num, ax=ax, subplot_kw=subplot_kw)
266
267    norm, _ = utils.maybe_log_cnorm(log_cnorm=log_cnorm, levels=None, vmin=vmin, vmax=vmax)
268
269    im = ax.scatter(
270        *coords,
271        c=conc,
272        s=7,
273        marker="o",
274        alpha=0.4,
275        linewidths=0,
276        cmap=cmap,
277        norm=norm,
278    )
279    cb = fig.colorbar(im, ax=ax, drawedges=False)
280    cb.set_label(f"{spc_display_name} relative conc. (%)")
281
282    for xs, ys in p["source_positions"]:
283        sp = dict(x=xs, y=ys, z=p["release_height"])
284        ax.plot(*tuple(sp[dim] for dim in dims), **_SOURCE_MARKER_PROPS)
285
286    ax.set_title(utils.s_t_info(p), loc="left")
287    ax.set(
288        xlabel=f"{ds[dims[0]].attrs['long_name']} [{ds[dims[0]].attrs['units']}]",
289        ylabel=f"{ds[dims[1]].attrs['long_name']} [{ds[dims[1]].attrs['units']}]",
290    )
291    if len(dims) == 3:
292        ax.set_zlabel(f"{ds[dims[2]].attrs['long_name']} [{ds[dims[2]].attrs['units']}]")
293    fig.set_tight_layout(True)

Plot species relative level in particle as a scatter (2- or 3-d).

Parameters
  • ds (xr.Dataset): Dataset of Lagrangian particle positions + chemistry, e.g., from blpd.chem.calc_relative_levels_fix_oxidants.
  • spc (str): ASCII identifier of a chemical species. For example, 'apinene'.
  • sdim (str): String representation of the set of dimensions. Valid options: ["xy", "xz", "yz", "xyz", "3d", "3-d"].
  • log_cnorm (bool): Whether to use a logarithmic colormap normalization.
def conc_xline( ds, spc='apinene', y=0.0, *, dy=1.0, ax=None, legend=True, label=None, legend_title='Chemical species'):
367@_add_snippets
368def conc_xline(
369    ds,
370    spc="apinene",
371    y=0.0,
372    *,
373    dy=1.0,
374    ax=None,  # TODO: select z as well?
375    legend=True,
376    label=None,
377    legend_title="Chemical species",
378):
379    r"""Plot species average relative level in the *x*-direction at a certain approximate y value.
380    `spc` can be ``'all'`` (all will fit in one plot since we are plotting lines).
381
382    Parameters
383    ----------
384    %(ds_chem_params)s
385    %(spc_params)s
386    y : float
387        *y* value of our "line".
388    dy : float
389        We will include Lagrangian particles within $[-\delta y / 2, \delta y / 2]$.
390    """
391    p = utils.load_p(ds)
392    X = ds.x.values
393    Y = ds.y.values
394
395    fig, ax = utils.maybe_new_figure(f"horizontal-end-positions-with-conc_{spc}_line", ax=ax)
396
397    spc_to_plot = ds.spc.values if spc == "all" else [spc]
398
399    # Define bins (edges)
400    xe = utils._auto_bins_1d(X, nx_max=100, std_mult=2.0, method="sqrt")
401    ye = np.r_[y - 0.5 * dy, y + 0.5 * dy]
402    # ze = np.r_[z - 0.5*dz, z + 0.5*dz]
403    bins = [xe, ye]
404
405    for spc in spc_to_plot:
406        spc_display_name = str(ds.display_name.sel(spc=spc).values)
407        conc = ds.f_r.sel(spc=spc).values
408        binned = utils.bin_values_xy(X, Y, conc, bins=bins)
409        ax.plot(binned.x, binned.v.squeeze(), label=spc_display_name if label is None else label)
410
411    for xs, ys in p["source_positions"]:
412        if abs(ys - y) <= 5:
413            ax.plot(xs, np.nanmin(binned.v), **_SOURCE_MARKER_PROPS)
414
415    if legend:
416        fig.legend(
417            ncol=2,
418            fontsize="small",
419            title=legend_title,
420        )
421    ax.set_title(utils.s_t_info(p), loc="left")
422    ax.set(
423        xlabel=f"{ds.x.attrs['long_name']} [{ds.x.attrs['units']}]",
424        ylabel="Relative concentration",
425    )
426    fig.set_tight_layout(True)

Plot species average relative level in the x-direction at a certain approximate y value. spc can be 'all' (all will fit in one plot since we are plotting lines).

Parameters
  • ds (xr.Dataset): Dataset of Lagrangian particle positions + chemistry, e.g., from blpd.chem.calc_relative_levels_fix_oxidants.
  • spc (str): ASCII identifier of a chemical species. For example, 'apinene'.
  • y (float): y value of our "line".
  • dy (float): We will include Lagrangian particles within $[-\delta y / 2, \delta y / 2]$.
def final_pos_hist(ds, *, bounds=None):
474@_add_snippets
475def final_pos_hist(
476    ds,
477    *,
478    bounds=None,
479):
480    """Plot histograms of final position components (*x*, *y*, and *z*).
481
482    Parameters
483    ----------
484    %(ds_lpd_params)s
485    bounds : 2-tuple(float), optional
486        If provided, used as position component limits for all histograms.
487    """
488    p = utils.load_p(ds)
489    if "t" in ds.dims:
490        xf = ds.x.isel(t=-1).values
491        yf = ds.y.isel(t=-1).values
492        zf = ds.z.isel(t=-1).values
493    else:
494        xf = ds.x.values
495        yf = ds.y.values
496        zf = ds.z.values
497
498    num = utils.check_fig_num("final-pos-hist")
499    fig, axs = plt.subplots(3, 1, num=num, sharex=True)
500
501    if bounds is None:
502        bins = 100
503    else:
504        bins = np.linspace(bounds[0], bounds[1], 100)
505
506    labels = ["$x$", "$y$", "$z$"]
507    for i, (xi, ax) in enumerate(zip([xf, yf, zf], axs.flat)):
508        ax.hist(xi, bins)
509        ax.text(0.01, 0.98, labels[i], va="top", ha="left", fontsize=13, transform=ax.transAxes)
510
511    if bounds:
512        axs[0].set_xlim(bounds)
513
514    axs[0].set_title(utils.s_t_info(p), loc="left")
515    axs[0].set_title(utils.s_sample_size(p, N_p_only=True), loc="right")
516    axs[-1].set_xlabel(f"[{ds.x.attrs['units']}]")
517    fig.set_tight_layout(True)

Plot histograms of final position components (x, y, and z).

Parameters
  • ds (xr.Dataset): Dataset of Lagrangian particle positions, e.g., from blpd.model.Model.to_xr.
  • bounds (2-tuple(float), optional): If provided, used as position component limits for all histograms.
def final_pos_hist2d( ds, *, sdim='xy', bins=50, plot_type='pcolor', log_cnorm=False, vmax=None):
520@_add_snippets
521def final_pos_hist2d(
522    ds,
523    *,
524    sdim="xy",
525    bins=50,
526    plot_type="pcolor",
527    log_cnorm=False,
528    vmax=None,
529):
530    """Plot 2-d histogram of selected final position components.
531
532    Parameters
533    ----------
534    %(ds_lpd_params)s
535    %(sdim_params)s
536    %(bins_params)s
537    %(log_cnorm_params)s
538    """
539    p = utils.load_p(ds)
540
541    dims = utils.dims_from_sdim(sdim)
542    if "t" in ds.dims:
543        x = ds[dims[0]].isel(t=-1).values
544        y = ds[dims[1]].isel(t=-1).values
545    else:
546        x = ds[dims[0]].values
547        y = ds[dims[1]].values
548
549    num = utils.check_fig_num(f"final-pos-hist-{sdim}")
550    fig, ax = plt.subplots(num=num)
551
552    if bins == "auto":
553        bins = utils.auto_bins_xy([x, y])
554
555    if log_cnorm:
556        norm = mpl.colors.LogNorm(vmin=1.0, vmax=vmax)
557    else:
558        norm = mpl.colors.Normalize(vmin=1.0, vmax=vmax)
559
560    _, _, _, im = ax.hist2d(x, y, bins=bins, norm=norm)
561    # ^ returns h (nx, ny), xedges, yedges, image
562
563    cb = fig.colorbar(im, ax=ax)
564    cb.set_label("Particle count")
565
566    for x, y in p["source_positions"]:
567        ax.plot(x, y, "*", c="gold", ms=11, mec="0.35", mew=1.0)
568
569    ax.set_xlabel(f"${dims[0]}$ [{ds.x.attrs['units']}]")
570    ax.set_ylabel(f"${dims[1]}$ [{ds.x.attrs['units']}]")
571    ax.autoscale(enable=True, axis="both", tight=True)
572    ax.set_title(utils.s_t_info(p), loc="left")
573    ax.set_title(utils.s_sample_size(p, N_p_only=True), loc="right")
574    fig.set_tight_layout(True)

Plot 2-d histogram of selected final position components.

Parameters
  • ds (xr.Dataset): Dataset of Lagrangian particle positions, e.g., from blpd.model.Model.to_xr.
  • sdim (str): String representation of the set of dimensions. Valid options: ["xy", "xz", "yz", "xyz", "3d", "3-d"].
  • bins: If bins='auto', blpd.utils.auto_bins is used to construct the bins. Alternatively, you can pass bin specifications in any of the forms described here.
  • log_cnorm (bool): Whether to use a logarithmic colormap normalization.
def final_pos_scatter(ds, sdim='xy'):
 65@_add_snippets
 66def final_pos_scatter(ds, sdim="xy"):
 67    """Scatter plot of particle end positions.
 68
 69    Parameters
 70    ----------
 71    %(ds_lpd_params)s
 72    %(sdim_params)s
 73    """
 74    p = utils.load_p(ds)
 75
 76    dims = utils.dims_from_sdim(sdim)
 77    if len(dims) == 3:
 78        sdim = "xyz"
 79        x = ds.x.values
 80        y = ds.y.values
 81        z = ds.z.values
 82        subplot_kw = {"projection": "3d"}
 83        coords = (x, y, z)
 84        plot_kw = dict(alpha=0.5, mew=0, ms=7)
 85    elif len(dims) == 2:
 86        x = ds[dims[0]].values
 87        y = ds[dims[1]].values
 88        subplot_kw = {}
 89        coords = (x, y)
 90        plot_kw = dict(alpha=0.5, mfc="none", mew=0.8, ms=5)
 91    else:
 92        raise ValueError("invalid `sdim`")
 93
 94    num = utils.check_fig_num(f"final-pos-scatter-{sdim}")
 95    fig, ax = plt.subplots(num=num, subplot_kw=subplot_kw)
 96
 97    ax.plot(*coords, "o", **plot_kw)
 98
 99    ax.set_xlabel(f"${dims[0]}$")
100    ax.set_ylabel(f"${dims[1]}$")
101    if len(dims) == 3:
102        ax.set_zlabel(f"${dims[2]}$")
103    ax.set_title(utils.s_t_info(p), loc="left")
104    ax.set_title(f"$N_p = {p['Np_tot']}$", loc="right")
105
106    for xs, ys in p["source_positions"]:
107        sp = dict(x=xs, y=ys, z=p["release_height"])
108        if len(dims) == 3:
109            ax.plot([sp[dims[0]]], [sp[dims[1]]], [sp[dims[2]]], "*", c="gold", ms=10)
110        else:
111            ax.plot(sp[dims[0]], sp[dims[1]], "*", c="gold", ms=10)
112
113    fig.set_tight_layout(True)

Scatter plot of particle end positions.

Parameters
  • ds (xr.Dataset): Dataset of Lagrangian particle positions, e.g., from blpd.model.Model.to_xr.
  • sdim (str): String representation of the set of dimensions. Valid options: ["xy", "xz", "yz", "xyz", "3d", "3-d"].
def trajectories(ds, *, smooth=False, smooth_window_size=None, color_sources=False):
120@_add_snippets
121def trajectories(ds, *, smooth=False, smooth_window_size=None, color_sources=False):
122    """Plot particle trajectories.
123
124    Parameters
125    ----------
126    %(ds_lpd_params)s
127    smooth : bool
128        Whether to smooth the trajectories.
129    smooth_window_size : int, optional
130        How many points to include in the moving average window.
131    color_sources : bool
132        Whether to color particle trajectories by their sources
133        to help in differentiating them.
134    """
135    if "t" not in ds.dims:
136        raise ValueError("time 't' must be a dimension")
137
138    p = utils.load_p(ds)
139    pos = np.stack((ds.x.values, ds.y.values, ds.z.values), axis=-1)  # same shape as hist["pos"]
140
141    t_tot = p["t_tot"]
142    dt = p["dt_out"]  # note output timestep not that of the integration
143    # N_t = p["N_t"]
144    Np = p["Np_tot"]
145    # N = Np * N_t
146    assert pos.shape[0] == Np
147
148    ltitle = utils.s_t_info(p)
149    rtitle = utils.s_sample_size(p)
150
151    # allow specifying smooth_window_size only
152    if smooth_window_size is not None and not smooth:
153        smooth = True
154    if smooth:
155        if smooth_window_size is None:
156            n = 100
157        else:
158            n = int(smooth_window_size)
159
160        if n * dt > 0.5 * t_tot:
161            raise ValueError(
162                "can't do any smoothing with the requested window size (not enough points)"
163            )
164        pos0 = pos[:, 0, :][:, np.newaxis, :]
165        pos = np.swapaxes(utils.moving_average(np.swapaxes(pos, 0, 1), n=n, axis=0), 0, 1)
166        # ^ `np.swapaxes` should return views, not create new arrays (ver >= 1.10.0)
167
168        # preserve starting point
169        pos = np.concatenate((pos0, pos), axis=1)  # axis=1 => basically `hstack`
170
171        ltitle = f"$N_{{smooth}} = {n}$ ({n*dt:} s)\n{ltitle}"  # add smoothing info to left title
172
173    num = utils.check_fig_num("trajectories")
174    fig, ax = plt.subplots(num=num)
175
176    if color_sources:
177        if isinstance(color_sources, (list, np.ndarray)):
178            colors = color_sources  # assume is a list of colors (TODO: should check)
179        else:
180            colors = plt.get_cmap("Dark2").colors
181            # Dark2 is a ListedColormap with 8 colors; `plt.cm.Dark2` same but pylint complains plt.cm `no-member` Dark2
182
183        N_sources = p["N_sources"]
184        for i_source, color in zip(range(N_sources), cycle(colors)):
185            segs = [pos[i, :, :2] for i in range(i_source, Np, N_sources)]
186
187            lc = _LineCollection(
188                segs,
189                linewidths=0.5,
190                colors=color,
191                linestyles="solid",
192                alpha=0.3,
193            )
194            ax.add_collection(lc)
195
196    else:
197        segs = [pos[i, :, :2] for i in range(Np)]
198
199        lc = _LineCollection(
200            segs,
201            linewidths=0.5,
202            colors="0.6",
203            linestyles="solid",
204            alpha=0.5,
205        )
206        ax.add_collection(lc)
207
208    for x, y in p["source_positions"]:
209        ax.plot(x, y, **_SOURCE_MARKER_PROPS)
210
211    ax.autoscale()  # `ax.add_collection` won't do this automatically
212    ax.set_xlabel("$x$")
213    ax.set_ylabel("$y$")
214    ax.set_title(ltitle, loc="left")
215    ax.set_title(rtitle, loc="right")
216
217    fig.set_tight_layout(True)

Plot particle trajectories.

Parameters
  • ds (xr.Dataset): Dataset of Lagrangian particle positions, e.g., from blpd.model.Model.to_xr.
  • smooth (bool): Whether to smooth the trajectories.
  • smooth_window_size (int, optional): How many points to include in the moving average window.
  • color_sources (bool): Whether to color particle trajectories by their sources to help in differentiating them.
def ws_hist(ds, *, bounds=None):
432@_add_snippets
433def ws_hist(
434    ds,
435    *,
436    bounds=None,
437):
438    """Plot histograms of particle wind speed components (one subplot for each).
439    For a single- or continuous-release run.
440
441    Parameters
442    ----------
443    %(ds_lpd_params)s
444    bounds : 2-tuple(float), optional
445        If provided, used as wind speed limits for all histograms.
446    """
447    p = utils.load_p(ds)
448    u_all = np.ravel(ds.u.values)  # or can do `.ravel()`
449    v_all = np.ravel(ds.v.values)
450    w_all = np.ravel(ds.w.values)
451
452    num = utils.check_fig_num("ws-hist-all")
453    fig, axs = plt.subplots(3, 1, num=num, sharex=True)
454
455    if bounds is None:
456        bins = 100
457    else:
458        bins = np.linspace(bounds[0], bounds[1], 100)
459
460    labels = ["$u$", "$v$", "$w$"]
461    for i, (ui, ax) in enumerate(zip([u_all, v_all, w_all], axs.flat)):
462        ax.hist(ui, bins)
463        ax.text(0.01, 0.98, labels[i], va="top", ha="left", fontsize=13, transform=ax.transAxes)
464
465    if bounds:
466        axs[0].set_xlim(bounds)
467
468    axs[0].set_title(utils.s_t_info(p), loc="left")
469    axs[0].set_title(utils.s_sample_size(p), loc="right")
470    axs[0].set_xlabel(f"[{ds.u.attrs['units']}]")
471    fig.set_tight_layout(True)

Plot histograms of particle wind speed components (one subplot for each). For a single- or continuous-release run.

Parameters
  • ds (xr.Dataset): Dataset of Lagrangian particle positions, e.g., from blpd.model.Model.to_xr.
  • bounds (2-tuple(float), optional): If provided, used as wind speed limits for all histograms.