blpd.chem

Chemistry and emissions calculations on LPD (Lagrangian particle dispersion) model output.

>>> ds0 = blpd.Model().run().to_xr()  # LPD output
>>> ds = calc_relative_levels_fixed_oxidants(ds0)
  1"""
  2Chemistry and emissions calculations
  3on LPD (Lagrangian particle dispersion) model output.
  4
  5>>> ds0 = blpd.Model().run().to_xr()  # LPD output
  6>>> ds = blpd.chem.calc_relative_levels_fixed_oxidants(ds0)
  7"""
  8
  9import numpy as np
 10import xarray as xr
 11from scipy import stats
 12
 13from .utils import auto_bins_xy
 14from .utils import cache
 15from .utils import calc_t_out
 16from .utils import load_p
 17
 18__all__ = (
 19    "calc_areal_emission_rates_canola",
 20    "calc_gridded_conc_canola",
 21    "calc_relative_levels_fixed_oxidants",
 22    "load_canola_species_data",
 23    "load_canola_flower_basal_emissions",
 24    "load_default_chemical_species_data",
 25)
 26
 27
 28@cache
 29def load_default_chemical_species_data():
 30    """Chemical species data used by default in `calc_relative_levels_fixed_oxidants`.
 31    Returns
 32    -------
 33    dict
 34        *keys*: ASCII chemical species names;
 35        *values*: another dict with *values*:
 36        `'display_name'`, `'kO3'`, `'kOH'`, `'kNO3'`
 37    """
 38    # key, display name string, kO3, kOH, kNO3
 39    # for now must have space after `,` !
 40    s = """
 41apinene, "α-pinene", 8.1e-17, 5.3e-11, 6.2e-12
 42bocimene, "β-ocimene", 5.4e-16, 2.52e-10, 2.2e-11
 43bpinene, "β-pinene", 2.4e-17, 7.8e-11, 2.5e-12
 44carene, "3-carene", 3.8e-17, 8.7e-11, 9.1e-12
 45cineole, "1,8-cineole", 6.0e-20, 1.0e-11, 1.7e-16
 46farnesene, "α-farnesene", 1.0e-15, 3.2e-10, 5.5e-11
 47limonene, "d-limonene", 2.5e-16, 1.6e-10, 1.3e-11
 48linalool, "linalool", 4.3e-16, 1.6e-10, 1.1e-11
 49myrcene, "β-myrcene", 4.7e-16, 2.1e-10, 1.3e-11
 50sabinene, "sabinene", 9.0e-17, 1.2e-10, 1.0e-11
 51thujene, "α-thujene", 4.4e-16, 8.7e-11, 1.1e-11
 52    """.strip()
 53    # TODO: store in separate file that makes it easier to add more (and other data). YAML?
 54
 55    d = {}
 56    for line in s.split("\n"):
 57        parts = [p.strip() for p in line.split(", ")]
 58        dl = {
 59            "display_name": eval(parts[1]),
 60            "kO3": float(parts[2]),
 61            "kOH": float(parts[3]),
 62            "kNO3": float(parts[4]),
 63        }
 64        key = parts[0]
 65        d[key] = dl
 66
 67    return d
 68
 69
 70# TODO: for relative levels, should always be 0--1 (or 0--100% optionally), so should remove `fv_0_default` and coordinate with gridded calc
 71def calc_relative_levels_fixed_oxidants(
 72    ds,
 73    species=None,
 74    *,
 75    t_out=None,
 76    p_overrides=None,
 77    fv_0_default: float = 100.0  # percentage mode by default
 78):
 79    """Calculate relative levels (rt. values at particle release)
 80    using fixed (over space and time) oxidant levels.
 81
 82    The fixed oxidant levels greatly simplify the problem—fractional chemical destruction only depends on time-since-release.
 83    Since we release particles in a known non-random way (fixed release rate),
 84    we can derive time-since-release after the LPD run has finished,
 85    without even needing the trajectory data.
 86    (This assumes the source strengths remain constant as well.)
 87
 88    Parameters
 89    ----------
 90    ds : xr.Dataset
 91        LPD model output generated by `blpd.model.Model.to_xarray`.
 92    species : dict
 93        *keys*: the species to calculate levels for;
 94        *values*: a `dict` for each species that must at least have the rate constants
 95        ``'kO3'``, ``'kOH'``, ``'kNO3'``
 96        but can also have ``'fv_0'``, the in-particle concentration at release.
 97        By default, the standard dataset `CHEMICAL_SPECIES_DATA` is used.
 98    fv_0_default
 99        Set to `1.0` instead for fractional mode (default is percentage mode)
100        or pick a different value.
101        Used for species in `species` that don't provide `'fv_0'` in their dict.
102
103    Returns
104    -------
105    xr.Dataset
106    """
107    if species is None:
108        species = load_default_chemical_species_data()
109
110    p = load_p(ds)
111
112    if p_overrides is not None:
113        p.update(p_overrides)
114
115    # unpack needed model options/params
116    Np_tot = p["Np_tot"]
117
118    if t_out is None:
119        t_out = calc_t_out(p)
120
121    # (molec cm^-3)
122    conc_O3 = p["conc_oxidants"]["O3"]
123    conc_OH = p["conc_oxidants"]["OH"]
124    conc_NO3 = p["conc_oxidants"]["NO3"]
125
126    # Save position dataset to merge with
127    ds0 = ds.copy()
128
129    ip = np.arange(Np_tot)  # for now
130    spcs = sorted(species.keys())  # species keys
131    n_spc = len(spcs)
132
133    def d0():
134        return np.empty((ip.size, n_spc))  # to be filled; or create d0 and use .copy()
135
136    ds = xr.Dataset(
137        coords={
138            "ip": ("ip", ip, {"long_name": "Lagrangian particle index"}),
139            "spc": ("spc", spcs, {"long_name": "chemical species"}),
140        },
141        data_vars={
142            "f_r": (("ip", "spc"), d0(), {"long_name": "fraction remaining"}),
143            "f_d_o3": (("ip", "spc"), d0(), {"long_name": "fraction destroyed by O3"}),
144            "f_d_oh": (("ip", "spc"), d0(), {"long_name": "fraction destroyed by OH"}),
145            "f_d_no3": (("ip", "spc"), d0(), {"long_name": "fraction destroyed by NO3"}),
146        },
147        attrs={
148            "conc_O3": conc_O3,  # molec cm-3
149            "conc_OH": conc_OH,
150            "conc_NO3": conc_NO3,
151            "p_json": ds0.attrs["p_json"],
152        },
153    )
154    for spc, d_spc in species.items():
155        conc0_val = p["fv_0"].get(spc, fv_0_default)
156
157        kO3 = d_spc["kO3"]
158        kOH = d_spc["kOH"]
159        kNO3 = d_spc["kNO3"]
160
161        # k_sum = kO3 + kOH + kNO3
162        # k_inv_sum = 1/kO3 + 1/kOH + 1/kNO3
163        kc_sum = conc_O3 * kO3 + conc_OH * kOH + conc_NO3 * kNO3
164        kc_O3 = kO3 * conc_O3
165        kc_OH = kOH * conc_OH
166        kc_NO3 = kNO3 * conc_NO3
167
168        # fraction remaining
169        f_remaining = (
170            1.0 * np.exp(-kc_O3 * t_out) * np.exp(-kc_OH * t_out) * np.exp(-kc_NO3 * t_out)
171        )
172
173        # fraction destroyed
174        # breakdown by oxidant depends on the rate const and oxidant levels
175        f_destroyed = 1 - f_remaining  # total
176        f_d_O3 = kc_O3 / kc_sum * f_destroyed
177        f_d_OH = kc_OH / kc_sum * f_destroyed
178        f_d_NO3 = kc_NO3 / kc_sum * f_destroyed
179        # note: transforming from f_r to these is linear
180        # so could be done after binning, but we might want to plot these particle-wise
181
182        f_r_spc = np.full((Np_tot,), conc0_val) * f_remaining
183
184        # update dataset values
185        ds["f_r"].loc[:, spc] = f_r_spc
186        ds["f_d_o3"].loc[:, spc] = f_d_O3
187        ds["f_d_oh"].loc[:, spc] = f_d_OH
188        ds["f_d_no3"].loc[:, spc] = f_d_NO3
189
190    ds_ret = xr.merge([ds, ds0[["x", "y", "z"]]])
191    ds_ret.attrs.update(ds.attrs)
192
193    # Add display names
194    display_names = [d["display_name"] for d in species.values()]
195    ds_ret["display_name"] = (
196        "spc",
197        display_names,
198        {"long_name": "better chemical species names (UTF-8)"},
199    )
200
201    return ds_ret
202
203
204@cache
205def load_canola_species_data():
206    """Canola species data as `dict` of `dict`s.
207
208    Returns
209    -------
210    dict
211        *keys*: ASCII chemical species names;
212        *values*: another dict with *values*:
213        `'MW'`, `'kO3'`, `'kOH'`, `'kNO3'`, `'OH_yield'`, `'HCHO_yield'`, `'display_name'`
214    """
215
216    def try_float(v):
217        try:
218            return float(v)
219        except ValueError:
220            return v
221
222    fields = ["key", "MW", "kO3", "kOH", "kNO3", "OH_yield", "HCHO_yield", "display_name"]
223    # ,mw,ko3,koh,kno3,oh_yield,hcho_yield,display_name
224    s = """
225apinene,136,8.09e-17,5.33e-11,6.16e-12,0.83,0.19,α-pinene
226bpinene,136,2.35e-17,7.81e-11,2.51e-12,0.35,0.45,β-pinene
227carene,136,3.8e-17,8.7e-11,9.1e-12,0.86,0.21,3-carene
228cineole,154,6e-20,1e-11,1.7e-16,0.01,0,"1,8-cineole"
229farnesene,204,1e-15,3.2e-10,5.5e-11,0.6,0.15,α-farnesene
230limonene,136,2.5e-16,1.61e-10,1.3e-11,0.67,0.19,d-limonene
231linalool,154,4.3e-16,1.6e-10,1.1e-11,0.72,0.36,linalool
232myrcene,154,4.7e-16,2.1e-10,1.27e-11,0.63,0.74,β-myrcene
233sabinene,136,9e-17,1.17e-10,1e-11,0.33,0.5,sabinene
234thujene,136,4.4e-16,8.7e-11,1.1e-11,0.69,0.36,α-thujene
235    """.strip()
236
237    import csv
238
239    lines = s.splitlines()
240    d = {}
241    for row in csv.DictReader(lines, fieldnames=fields):
242        dl = {k: try_float(v) for k, v in row.items() if k != "key"}
243        d[row["key"]] = dl
244
245    return d
246
247
248@cache
249def load_canola_flower_basal_emissions():
250    """Speciated basal emission rate data for canola plant."""
251
252    # basal emissions in ng per floret-hour (ng floret^-1 hr^-1)
253    # from Table 1 of Jakobsen et al. (1994) (the values for light conditions)
254    # doi: https://doi.org/10.1016/S0031-9422(00)90341-8
255    emiss_ng = {
256        "myrcene": 12.3,
257        "limonene": 7.2,
258        "sabinene": 6.5,
259        "farnesene": 5.0,
260        "apinene": 4.2,
261        "linalool": 3.0,
262        "carene": 1.9,
263        "bpinene": 2.0,
264        "thujene": 1.9,
265        "cineole": 1.7,
266    }
267
268    # convert to ug/s units
269    emiss_ug_s = {}
270    for spc, e_ng in emiss_ng.items():
271        # calculate emission in ug (micrograms) per second
272        e_ug_s = e_ng / 1000 / (60 * 60)
273        emiss_ug_s[spc] = e_ug_s
274
275    # reference temperature
276    # near the end of the paper they say that the
277    # Sampling was performed at 15 deg
278    T_S_C = 15.0  # deg. C
279    T_S_K = T_S_C + 273.15  # Kelvin
280
281    return {
282        "emiss_ug_per_floret-s": emiss_ug_s,
283        "T_S_K": T_S_K,
284    }
285
286
287def calc_areal_emission_rates_canola(
288    plant_density: float = 30.0,
289    num_flowers_avg: float = 25.0,
290    T: float = 298.0,
291    beta: float = 0.06,
292):
293    """Calculate areal emission rates (m$^{-2}$ s$^{-1}$) for canola floral volatiles.
294
295    Parameters
296    ----------
297    plant_density
298        Average number of plants per m$^2$.
299    num_flowers_avg
300        Average number of flowers in the plants.
301    T
302        Temperature (K).
303
304        Temperature sensitivity parameter for emissions (K$^{-1}$).
305
306    Returns
307    -------
308    dict
309        *values*: species ASCII; *keys*: dict of areal emissions in different units
310    """
311
312    # load basal data
313    basal = load_canola_flower_basal_emissions()
314    E_si_ug = basal["emiss_ug_per_floret-s"]
315    T_S = basal["T_S_K"]
316
317    # other needed
318    N_A = 6.02214076e23  # also in scipy.constants
319
320    # load canola species additional data
321    data = load_canola_species_data()
322
323    # loop through species
324    E_i = {}
325    for spc, e_si in E_si_ug.items():
326        # ug per floret-s at temperature T
327        e_i = e_si * np.exp(beta * (T - T_S))
328
329        # compute areal emissions
330
331        # ug per s per m^2
332        e_i_areal = e_i * num_flowers_avg * plant_density
333
334        # mol " "
335        mw = data[spc]["MW"]  # molecular weight (g/mol)
336        e_i_areal_mol = e_i_areal / 1e6 / mw  # note ug -> g
337
338        # molec " "
339        e_i_areal_molec = e_i_areal_mol * N_A
340
341        E_i[spc] = {
342            "emiss_areal_ug": e_i_areal,
343            "emiss_areal_mol": e_i_areal_mol,
344            "emiss_areal_molec": e_i_areal_molec,
345        }
346
347    return E_i
348
349
350def calc_gridded_conc_canola(ds):
351    """Some calculations for canola floral volatiles
352    using the fixed oxidants chem.
353
354    Parameters
355    ----------
356    ds : xr.Dataset
357        LPD model output generated by `blpd.model.Model.to_xarray`.
358    """
359    # ! 2-D for now (not real 3-D/volume conc.)
360    p = load_p(ds)
361
362    # 1. Determine floral volatile levels per particle
363    emiss_rates = calc_areal_emission_rates_canola()
364    dt = p["dt"]
365    dNp_per_dt_per_source = p["dNp_per_dt_per_source"]
366    Np_per_sec_per_source = dNp_per_dt_per_source / dt
367    fv_0 = {}  # floral volatile initial amounts in particle
368    for spc, d in emiss_rates.items():
369        # assume 1 m^2 source area
370        # TODO: source's effective area for emissions calculation can be an input somewhere?
371        # and that it does not overlap with other sources
372        molec_per_p = d["emiss_areal_molec"] / Np_per_sec_per_source
373        fv_0[spc] = molec_per_p
374
375    # 2. Compute particle-relative levels
376    #    decreased due to chemical destruction by oxidation
377    canola = load_canola_species_data()
378    ds_fo = calc_relative_levels_fixed_oxidants(ds, species=canola, fv_0_default=1.0)
379    # ^ can also pass fv_0
380    spcs_fo = ds_fo["spc"].values
381
382    # 3. grid
383    X, Y = ds.x.values, ds.y.values
384    dx = X[1] - X[0]
385    dy = Y[1] - Y[0]
386    pos = (X, Y)
387    bins = auto_bins_xy(pos)
388
389    # 4. Calculate concentrations from particle concentration field
390    #    and speciated fv_0
391    res = {}  # store results here
392    ret = None  # for first run we don't have a result yet
393    stats_to_calc = ["sum", "count", "mean", "median", "std"]
394    for spc in spcs_fo:
395        f_r_spc = ds_fo["f_r"].sel(spc=spc).values  # fraction remaining, by particle
396        f_d_o3_spc = ds_fo["f_d_o3"].sel(spc=spc).values  # fraction destroyed by O3
397        # Calculate each of the stats
398        rets = {}
399        variables = {"f_r_spc": f_r_spc, "f_d_o3": f_d_o3_spc}
400        for vn, values in variables.items():
401            rets_vn = {}
402            for stat in stats_to_calc:
403                ret = stats.binned_statistic_dd(
404                    np.column_stack(pos),  # binning by
405                    values,  # calculating the statistic on
406                    statistic=stat,
407                    bins=bins,
408                    binned_statistic_result=ret,
409                )
410                rets_vn[stat] = ret
411            rets[vn] = rets_vn
412
413        # Gridded particle count
414        # should be same for all, don't really need to re-calc this way...
415        n_p_g = rets["f_r_spc"]["count"].statistic
416
417        # Calculate conc. by summing the relative concs. of particles
418        # and scaling by the particle initial # of molec
419        f_r_sum = rets["f_r_spc"]["sum"].statistic
420        fv_spc_g = f_r_sum * fv_0[spc]
421
422        # Link molec destroyed by O3 to OH and HCHO yields
423        # via the ozonolysis oxidation pathway
424        f_d_o3_sum = rets["f_d_o3"]["sum"].statistic
425        oh_yield_oz_spc = canola[spc]["OH_yield"]
426        hcho_yield_oz_spc = canola[spc]["HCHO_yield"]
427        oh_y_tot = f_d_o3_sum * fv_0[spc] * fv_0[spc] * oh_yield_oz_spc
428        hcho_y_tot = f_d_o3_sum * fv_0[spc] * fv_0[spc] * hcho_yield_oz_spc
429
430        # xr.Datset per species then combine later??
431
432        # collect
433        res[spc] = {
434            "particle_count": n_p_g,
435            "molec_count": fv_spc_g,
436            "conc_molec_areal": fv_spc_g / (dx * dy),
437            "oh_yield_count": oh_y_tot,
438            "oh_yield_areal": oh_y_tot / (dx * dy),
439            "hcho_yield_count": hcho_y_tot,
440            "hcho_yield_areal": hcho_y_tot / (dx * dy),
441        }
442
443    # 5. Specify grid centers
444    x, y = ret.bin_edges[0], ret.bin_edges[1]
445    xc = x[:-1] + 0.5 * np.diff(x)
446    yc = y[:-1] + 0.5 * np.diff(y)
447
448    # Create xr.Dataset
449
450    # Specify units and long names for the above-calculated variables
451    units_long_names = {
452        "particle_count": ("", "Lagrangian particle count"),
453        "molec_count": ("molec", "molecule count"),
454        "conc_molec_areal": ("molec m^-2", "areal conc. (molec.)"),
455        "oh_yield_count": ("radical", "OH yield from ozonolysis"),
456        "oh_yield_areal": ("radical m^-2", "areal OH yield from ozonolysis"),
457        "hcho_yield_count": ("molec", "HCHO yield from ozonolysis"),
458        "hcho_yield_areal": ("molec m^-2", "areal HCHO yield from ozonolysis"),
459    }
460    units = {k: v[0] for k, v in units_long_names.items()}
461    long_names = {k: v[1] for k, v in units_long_names.items()}
462
463    # Aggregate: species as a third dim
464    spc_sorted = sorted(spc for spc in res.keys())
465    n_spc = len(spc_sorted)
466    varnames = res[spc_sorted[0]].keys()
467    res_agg = {}
468    for vn in varnames:
469        data = np.zeros((n_spc, xc.size, yc.size))
470        for i, (spc, data_spc) in enumerate(res.items()):
471            data[i, :, :] = data_spc[vn]
472        res_agg[vn] = data
473
474    ds = xr.Dataset(
475        coords={
476            "xe": ("xe", x, {"units": "m", "long_name": "x (bin edge)"}),
477            "ye": ("ye", y, {"units": "m", "long_name": "y (bin edge)"}),
478            "x": ("x", xc, {"units": "m", "long_name": "x (bin center)"}),
479            "y": ("y", yc, {"units": "m", "long_name": "y (bin center)"}),
480            "spc": ("spc", spc_sorted, {"long_name": "chemical species"}),
481        },
482        data_vars={
483            vn: (
484                ("spc", "x", "y"),
485                values,
486                {
487                    "units": units[vn],
488                    "long_name": long_names[vn],
489                },
490            )
491            for vn, values in res_agg.items()
492        },
493        attrs={
494            "t_tot_s": p["t_tot"],
495        },
496    )
497
498    # Fix particle count (doesn't vary with species)
499    ds["particle_count"] = ds["particle_count"].sel(spc="apinene")
500
501    # Add display names
502    display_names = [canola[spc]["display_name"] for spc in spc_sorted]
503    ds["display_name"] = (
504        "spc",
505        display_names,
506        {"long_name": "better chemical species names (UTF-8)"},
507    )
508
509    return ds
510
511
512# Define chem calculation options for the main model class
513chem_calc_options = {"fixed_oxidants": calc_relative_levels_fixed_oxidants}
def calc_areal_emission_rates_canola( plant_density: float = 30.0, num_flowers_avg: float = 25.0, T: float = 298.0, beta: float = 0.06):
288def calc_areal_emission_rates_canola(
289    plant_density: float = 30.0,
290    num_flowers_avg: float = 25.0,
291    T: float = 298.0,
292    beta: float = 0.06,
293):
294    """Calculate areal emission rates (m$^{-2}$ s$^{-1}$) for canola floral volatiles.
295
296    Parameters
297    ----------
298    plant_density
299        Average number of plants per m$^2$.
300    num_flowers_avg
301        Average number of flowers in the plants.
302    T
303        Temperature (K).
304
305        Temperature sensitivity parameter for emissions (K$^{-1}$).
306
307    Returns
308    -------
309    dict
310        *values*: species ASCII; *keys*: dict of areal emissions in different units
311    """
312
313    # load basal data
314    basal = load_canola_flower_basal_emissions()
315    E_si_ug = basal["emiss_ug_per_floret-s"]
316    T_S = basal["T_S_K"]
317
318    # other needed
319    N_A = 6.02214076e23  # also in scipy.constants
320
321    # load canola species additional data
322    data = load_canola_species_data()
323
324    # loop through species
325    E_i = {}
326    for spc, e_si in E_si_ug.items():
327        # ug per floret-s at temperature T
328        e_i = e_si * np.exp(beta * (T - T_S))
329
330        # compute areal emissions
331
332        # ug per s per m^2
333        e_i_areal = e_i * num_flowers_avg * plant_density
334
335        # mol " "
336        mw = data[spc]["MW"]  # molecular weight (g/mol)
337        e_i_areal_mol = e_i_areal / 1e6 / mw  # note ug -> g
338
339        # molec " "
340        e_i_areal_molec = e_i_areal_mol * N_A
341
342        E_i[spc] = {
343            "emiss_areal_ug": e_i_areal,
344            "emiss_areal_mol": e_i_areal_mol,
345            "emiss_areal_molec": e_i_areal_molec,
346        }
347
348    return E_i

Calculate areal emission rates (m$^{-2}$ s$^{-1}$) for canola floral volatiles.

Parameters
  • plant_density: Average number of plants per m$^2$.
  • num_flowers_avg: Average number of flowers in the plants.
  • T: Temperature (K).

Temperature sensitivity parameter for emissions (K$^{-1}$).

Returns
  • dict: values: species ASCII; keys: dict of areal emissions in different units
def calc_gridded_conc_canola(ds):
351def calc_gridded_conc_canola(ds):
352    """Some calculations for canola floral volatiles
353    using the fixed oxidants chem.
354
355    Parameters
356    ----------
357    ds : xr.Dataset
358        LPD model output generated by `blpd.model.Model.to_xarray`.
359    """
360    # ! 2-D for now (not real 3-D/volume conc.)
361    p = load_p(ds)
362
363    # 1. Determine floral volatile levels per particle
364    emiss_rates = calc_areal_emission_rates_canola()
365    dt = p["dt"]
366    dNp_per_dt_per_source = p["dNp_per_dt_per_source"]
367    Np_per_sec_per_source = dNp_per_dt_per_source / dt
368    fv_0 = {}  # floral volatile initial amounts in particle
369    for spc, d in emiss_rates.items():
370        # assume 1 m^2 source area
371        # TODO: source's effective area for emissions calculation can be an input somewhere?
372        # and that it does not overlap with other sources
373        molec_per_p = d["emiss_areal_molec"] / Np_per_sec_per_source
374        fv_0[spc] = molec_per_p
375
376    # 2. Compute particle-relative levels
377    #    decreased due to chemical destruction by oxidation
378    canola = load_canola_species_data()
379    ds_fo = calc_relative_levels_fixed_oxidants(ds, species=canola, fv_0_default=1.0)
380    # ^ can also pass fv_0
381    spcs_fo = ds_fo["spc"].values
382
383    # 3. grid
384    X, Y = ds.x.values, ds.y.values
385    dx = X[1] - X[0]
386    dy = Y[1] - Y[0]
387    pos = (X, Y)
388    bins = auto_bins_xy(pos)
389
390    # 4. Calculate concentrations from particle concentration field
391    #    and speciated fv_0
392    res = {}  # store results here
393    ret = None  # for first run we don't have a result yet
394    stats_to_calc = ["sum", "count", "mean", "median", "std"]
395    for spc in spcs_fo:
396        f_r_spc = ds_fo["f_r"].sel(spc=spc).values  # fraction remaining, by particle
397        f_d_o3_spc = ds_fo["f_d_o3"].sel(spc=spc).values  # fraction destroyed by O3
398        # Calculate each of the stats
399        rets = {}
400        variables = {"f_r_spc": f_r_spc, "f_d_o3": f_d_o3_spc}
401        for vn, values in variables.items():
402            rets_vn = {}
403            for stat in stats_to_calc:
404                ret = stats.binned_statistic_dd(
405                    np.column_stack(pos),  # binning by
406                    values,  # calculating the statistic on
407                    statistic=stat,
408                    bins=bins,
409                    binned_statistic_result=ret,
410                )
411                rets_vn[stat] = ret
412            rets[vn] = rets_vn
413
414        # Gridded particle count
415        # should be same for all, don't really need to re-calc this way...
416        n_p_g = rets["f_r_spc"]["count"].statistic
417
418        # Calculate conc. by summing the relative concs. of particles
419        # and scaling by the particle initial # of molec
420        f_r_sum = rets["f_r_spc"]["sum"].statistic
421        fv_spc_g = f_r_sum * fv_0[spc]
422
423        # Link molec destroyed by O3 to OH and HCHO yields
424        # via the ozonolysis oxidation pathway
425        f_d_o3_sum = rets["f_d_o3"]["sum"].statistic
426        oh_yield_oz_spc = canola[spc]["OH_yield"]
427        hcho_yield_oz_spc = canola[spc]["HCHO_yield"]
428        oh_y_tot = f_d_o3_sum * fv_0[spc] * fv_0[spc] * oh_yield_oz_spc
429        hcho_y_tot = f_d_o3_sum * fv_0[spc] * fv_0[spc] * hcho_yield_oz_spc
430
431        # xr.Datset per species then combine later??
432
433        # collect
434        res[spc] = {
435            "particle_count": n_p_g,
436            "molec_count": fv_spc_g,
437            "conc_molec_areal": fv_spc_g / (dx * dy),
438            "oh_yield_count": oh_y_tot,
439            "oh_yield_areal": oh_y_tot / (dx * dy),
440            "hcho_yield_count": hcho_y_tot,
441            "hcho_yield_areal": hcho_y_tot / (dx * dy),
442        }
443
444    # 5. Specify grid centers
445    x, y = ret.bin_edges[0], ret.bin_edges[1]
446    xc = x[:-1] + 0.5 * np.diff(x)
447    yc = y[:-1] + 0.5 * np.diff(y)
448
449    # Create xr.Dataset
450
451    # Specify units and long names for the above-calculated variables
452    units_long_names = {
453        "particle_count": ("", "Lagrangian particle count"),
454        "molec_count": ("molec", "molecule count"),
455        "conc_molec_areal": ("molec m^-2", "areal conc. (molec.)"),
456        "oh_yield_count": ("radical", "OH yield from ozonolysis"),
457        "oh_yield_areal": ("radical m^-2", "areal OH yield from ozonolysis"),
458        "hcho_yield_count": ("molec", "HCHO yield from ozonolysis"),
459        "hcho_yield_areal": ("molec m^-2", "areal HCHO yield from ozonolysis"),
460    }
461    units = {k: v[0] for k, v in units_long_names.items()}
462    long_names = {k: v[1] for k, v in units_long_names.items()}
463
464    # Aggregate: species as a third dim
465    spc_sorted = sorted(spc for spc in res.keys())
466    n_spc = len(spc_sorted)
467    varnames = res[spc_sorted[0]].keys()
468    res_agg = {}
469    for vn in varnames:
470        data = np.zeros((n_spc, xc.size, yc.size))
471        for i, (spc, data_spc) in enumerate(res.items()):
472            data[i, :, :] = data_spc[vn]
473        res_agg[vn] = data
474
475    ds = xr.Dataset(
476        coords={
477            "xe": ("xe", x, {"units": "m", "long_name": "x (bin edge)"}),
478            "ye": ("ye", y, {"units": "m", "long_name": "y (bin edge)"}),
479            "x": ("x", xc, {"units": "m", "long_name": "x (bin center)"}),
480            "y": ("y", yc, {"units": "m", "long_name": "y (bin center)"}),
481            "spc": ("spc", spc_sorted, {"long_name": "chemical species"}),
482        },
483        data_vars={
484            vn: (
485                ("spc", "x", "y"),
486                values,
487                {
488                    "units": units[vn],
489                    "long_name": long_names[vn],
490                },
491            )
492            for vn, values in res_agg.items()
493        },
494        attrs={
495            "t_tot_s": p["t_tot"],
496        },
497    )
498
499    # Fix particle count (doesn't vary with species)
500    ds["particle_count"] = ds["particle_count"].sel(spc="apinene")
501
502    # Add display names
503    display_names = [canola[spc]["display_name"] for spc in spc_sorted]
504    ds["display_name"] = (
505        "spc",
506        display_names,
507        {"long_name": "better chemical species names (UTF-8)"},
508    )
509
510    return ds

Some calculations for canola floral volatiles using the fixed oxidants chem.

Parameters
def calc_relative_levels_fixed_oxidants( ds, species=None, *, t_out=None, p_overrides=None, fv_0_default: float = 100.0):
 72def calc_relative_levels_fixed_oxidants(
 73    ds,
 74    species=None,
 75    *,
 76    t_out=None,
 77    p_overrides=None,
 78    fv_0_default: float = 100.0  # percentage mode by default
 79):
 80    """Calculate relative levels (rt. values at particle release)
 81    using fixed (over space and time) oxidant levels.
 82
 83    The fixed oxidant levels greatly simplify the problem—fractional chemical destruction only depends on time-since-release.
 84    Since we release particles in a known non-random way (fixed release rate),
 85    we can derive time-since-release after the LPD run has finished,
 86    without even needing the trajectory data.
 87    (This assumes the source strengths remain constant as well.)
 88
 89    Parameters
 90    ----------
 91    ds : xr.Dataset
 92        LPD model output generated by `blpd.model.Model.to_xarray`.
 93    species : dict
 94        *keys*: the species to calculate levels for;
 95        *values*: a `dict` for each species that must at least have the rate constants
 96        ``'kO3'``, ``'kOH'``, ``'kNO3'``
 97        but can also have ``'fv_0'``, the in-particle concentration at release.
 98        By default, the standard dataset `CHEMICAL_SPECIES_DATA` is used.
 99    fv_0_default
100        Set to `1.0` instead for fractional mode (default is percentage mode)
101        or pick a different value.
102        Used for species in `species` that don't provide `'fv_0'` in their dict.
103
104    Returns
105    -------
106    xr.Dataset
107    """
108    if species is None:
109        species = load_default_chemical_species_data()
110
111    p = load_p(ds)
112
113    if p_overrides is not None:
114        p.update(p_overrides)
115
116    # unpack needed model options/params
117    Np_tot = p["Np_tot"]
118
119    if t_out is None:
120        t_out = calc_t_out(p)
121
122    # (molec cm^-3)
123    conc_O3 = p["conc_oxidants"]["O3"]
124    conc_OH = p["conc_oxidants"]["OH"]
125    conc_NO3 = p["conc_oxidants"]["NO3"]
126
127    # Save position dataset to merge with
128    ds0 = ds.copy()
129
130    ip = np.arange(Np_tot)  # for now
131    spcs = sorted(species.keys())  # species keys
132    n_spc = len(spcs)
133
134    def d0():
135        return np.empty((ip.size, n_spc))  # to be filled; or create d0 and use .copy()
136
137    ds = xr.Dataset(
138        coords={
139            "ip": ("ip", ip, {"long_name": "Lagrangian particle index"}),
140            "spc": ("spc", spcs, {"long_name": "chemical species"}),
141        },
142        data_vars={
143            "f_r": (("ip", "spc"), d0(), {"long_name": "fraction remaining"}),
144            "f_d_o3": (("ip", "spc"), d0(), {"long_name": "fraction destroyed by O3"}),
145            "f_d_oh": (("ip", "spc"), d0(), {"long_name": "fraction destroyed by OH"}),
146            "f_d_no3": (("ip", "spc"), d0(), {"long_name": "fraction destroyed by NO3"}),
147        },
148        attrs={
149            "conc_O3": conc_O3,  # molec cm-3
150            "conc_OH": conc_OH,
151            "conc_NO3": conc_NO3,
152            "p_json": ds0.attrs["p_json"],
153        },
154    )
155    for spc, d_spc in species.items():
156        conc0_val = p["fv_0"].get(spc, fv_0_default)
157
158        kO3 = d_spc["kO3"]
159        kOH = d_spc["kOH"]
160        kNO3 = d_spc["kNO3"]
161
162        # k_sum = kO3 + kOH + kNO3
163        # k_inv_sum = 1/kO3 + 1/kOH + 1/kNO3
164        kc_sum = conc_O3 * kO3 + conc_OH * kOH + conc_NO3 * kNO3
165        kc_O3 = kO3 * conc_O3
166        kc_OH = kOH * conc_OH
167        kc_NO3 = kNO3 * conc_NO3
168
169        # fraction remaining
170        f_remaining = (
171            1.0 * np.exp(-kc_O3 * t_out) * np.exp(-kc_OH * t_out) * np.exp(-kc_NO3 * t_out)
172        )
173
174        # fraction destroyed
175        # breakdown by oxidant depends on the rate const and oxidant levels
176        f_destroyed = 1 - f_remaining  # total
177        f_d_O3 = kc_O3 / kc_sum * f_destroyed
178        f_d_OH = kc_OH / kc_sum * f_destroyed
179        f_d_NO3 = kc_NO3 / kc_sum * f_destroyed
180        # note: transforming from f_r to these is linear
181        # so could be done after binning, but we might want to plot these particle-wise
182
183        f_r_spc = np.full((Np_tot,), conc0_val) * f_remaining
184
185        # update dataset values
186        ds["f_r"].loc[:, spc] = f_r_spc
187        ds["f_d_o3"].loc[:, spc] = f_d_O3
188        ds["f_d_oh"].loc[:, spc] = f_d_OH
189        ds["f_d_no3"].loc[:, spc] = f_d_NO3
190
191    ds_ret = xr.merge([ds, ds0[["x", "y", "z"]]])
192    ds_ret.attrs.update(ds.attrs)
193
194    # Add display names
195    display_names = [d["display_name"] for d in species.values()]
196    ds_ret["display_name"] = (
197        "spc",
198        display_names,
199        {"long_name": "better chemical species names (UTF-8)"},
200    )
201
202    return ds_ret

Calculate relative levels (rt. values at particle release) using fixed (over space and time) oxidant levels.

The fixed oxidant levels greatly simplify the problem—fractional chemical destruction only depends on time-since-release. Since we release particles in a known non-random way (fixed release rate), we can derive time-since-release after the LPD run has finished, without even needing the trajectory data. (This assumes the source strengths remain constant as well.)

Parameters
  • ds (xr.Dataset): LPD model output generated by blpd.model.Model.to_xarray.
  • species (dict): keys: the species to calculate levels for; values: a dict for each species that must at least have the rate constants 'kO3', 'kOH', 'kNO3' but can also have 'fv_0', the in-particle concentration at release. By default, the standard dataset CHEMICAL_SPECIES_DATA is used.
  • fv_0_default: Set to 1.0 instead for fractional mode (default is percentage mode) or pick a different value. Used for species in species that don't provide 'fv_0' in their dict.
Returns
  • xr.Dataset
@cache
def load_canola_species_data():
205@cache
206def load_canola_species_data():
207    """Canola species data as `dict` of `dict`s.
208
209    Returns
210    -------
211    dict
212        *keys*: ASCII chemical species names;
213        *values*: another dict with *values*:
214        `'MW'`, `'kO3'`, `'kOH'`, `'kNO3'`, `'OH_yield'`, `'HCHO_yield'`, `'display_name'`
215    """
216
217    def try_float(v):
218        try:
219            return float(v)
220        except ValueError:
221            return v
222
223    fields = ["key", "MW", "kO3", "kOH", "kNO3", "OH_yield", "HCHO_yield", "display_name"]
224    # ,mw,ko3,koh,kno3,oh_yield,hcho_yield,display_name
225    s = """
226apinene,136,8.09e-17,5.33e-11,6.16e-12,0.83,0.19,α-pinene
227bpinene,136,2.35e-17,7.81e-11,2.51e-12,0.35,0.45,β-pinene
228carene,136,3.8e-17,8.7e-11,9.1e-12,0.86,0.21,3-carene
229cineole,154,6e-20,1e-11,1.7e-16,0.01,0,"1,8-cineole"
230farnesene,204,1e-15,3.2e-10,5.5e-11,0.6,0.15,α-farnesene
231limonene,136,2.5e-16,1.61e-10,1.3e-11,0.67,0.19,d-limonene
232linalool,154,4.3e-16,1.6e-10,1.1e-11,0.72,0.36,linalool
233myrcene,154,4.7e-16,2.1e-10,1.27e-11,0.63,0.74,β-myrcene
234sabinene,136,9e-17,1.17e-10,1e-11,0.33,0.5,sabinene
235thujene,136,4.4e-16,8.7e-11,1.1e-11,0.69,0.36,α-thujene
236    """.strip()
237
238    import csv
239
240    lines = s.splitlines()
241    d = {}
242    for row in csv.DictReader(lines, fieldnames=fields):
243        dl = {k: try_float(v) for k, v in row.items() if k != "key"}
244        d[row["key"]] = dl
245
246    return d

Canola species data as dict of dicts.

Returns
  • dict: keys: ASCII chemical species names; values: another dict with values: 'MW', 'kO3', 'kOH', 'kNO3', 'OH_yield', 'HCHO_yield', 'display_name'
@cache
def load_canola_flower_basal_emissions():
249@cache
250def load_canola_flower_basal_emissions():
251    """Speciated basal emission rate data for canola plant."""
252
253    # basal emissions in ng per floret-hour (ng floret^-1 hr^-1)
254    # from Table 1 of Jakobsen et al. (1994) (the values for light conditions)
255    # doi: https://doi.org/10.1016/S0031-9422(00)90341-8
256    emiss_ng = {
257        "myrcene": 12.3,
258        "limonene": 7.2,
259        "sabinene": 6.5,
260        "farnesene": 5.0,
261        "apinene": 4.2,
262        "linalool": 3.0,
263        "carene": 1.9,
264        "bpinene": 2.0,
265        "thujene": 1.9,
266        "cineole": 1.7,
267    }
268
269    # convert to ug/s units
270    emiss_ug_s = {}
271    for spc, e_ng in emiss_ng.items():
272        # calculate emission in ug (micrograms) per second
273        e_ug_s = e_ng / 1000 / (60 * 60)
274        emiss_ug_s[spc] = e_ug_s
275
276    # reference temperature
277    # near the end of the paper they say that the
278    # Sampling was performed at 15 deg
279    T_S_C = 15.0  # deg. C
280    T_S_K = T_S_C + 273.15  # Kelvin
281
282    return {
283        "emiss_ug_per_floret-s": emiss_ug_s,
284        "T_S_K": T_S_K,
285    }

Speciated basal emission rate data for canola plant.

@cache
def load_default_chemical_species_data():
29@cache
30def load_default_chemical_species_data():
31    """Chemical species data used by default in `calc_relative_levels_fixed_oxidants`.
32    Returns
33    -------
34    dict
35        *keys*: ASCII chemical species names;
36        *values*: another dict with *values*:
37        `'display_name'`, `'kO3'`, `'kOH'`, `'kNO3'`
38    """
39    # key, display name string, kO3, kOH, kNO3
40    # for now must have space after `,` !
41    s = """
42apinene, "α-pinene", 8.1e-17, 5.3e-11, 6.2e-12
43bocimene, "β-ocimene", 5.4e-16, 2.52e-10, 2.2e-11
44bpinene, "β-pinene", 2.4e-17, 7.8e-11, 2.5e-12
45carene, "3-carene", 3.8e-17, 8.7e-11, 9.1e-12
46cineole, "1,8-cineole", 6.0e-20, 1.0e-11, 1.7e-16
47farnesene, "α-farnesene", 1.0e-15, 3.2e-10, 5.5e-11
48limonene, "d-limonene", 2.5e-16, 1.6e-10, 1.3e-11
49linalool, "linalool", 4.3e-16, 1.6e-10, 1.1e-11
50myrcene, "β-myrcene", 4.7e-16, 2.1e-10, 1.3e-11
51sabinene, "sabinene", 9.0e-17, 1.2e-10, 1.0e-11
52thujene, "α-thujene", 4.4e-16, 8.7e-11, 1.1e-11
53    """.strip()
54    # TODO: store in separate file that makes it easier to add more (and other data). YAML?
55
56    d = {}
57    for line in s.split("\n"):
58        parts = [p.strip() for p in line.split(", ")]
59        dl = {
60            "display_name": eval(parts[1]),
61            "kO3": float(parts[2]),
62            "kOH": float(parts[3]),
63            "kNO3": float(parts[4]),
64        }
65        key = parts[0]
66        d[key] = dl
67
68    return d

Chemical species data used by default in calc_relative_levels_fixed_oxidants.

Returns
  • dict: keys: ASCII chemical species names; values: another dict with values: 'display_name', 'kO3', 'kOH', 'kNO3'