blpd.model

Model class for running the LPD model.

  1"""
  2`Model` class for running the LPD model.
  3"""
  4
  5import importlib
  6import math
  7import warnings
  8from copy import deepcopy as copy
  9
 10import numpy as np
 11
 12from . import lpd
 13from .utils import disable_numba
 14from .utils import enable_numba
 15from .utils import numbify
 16from .utils import unnumbify
 17
 18__all__ = (
 19    "Model",
 20    "INPUT_PARAM_DEFAULTS",
 21    "calc_MW_derived_params",
 22    "compare_params",
 23)
 24
 25
 26INPUT_PARAM_DEFAULTS = {
 27    #
 28    # particle sources
 29    "release_height": 1.05,  # m; really should draw from a distribution of release heights for each particle!!
 30    "source_positions": [(0, 0)],  # point locs of the particle sources; must be iterable
 31    "dNp_per_dt_per_source": 2,  # should be int
 32    #
 33    # canopy
 34    "canopy_height": 1.1,  # m; h / h_c
 35    "total_LAI": 2.0,  # (total) leaf area index
 36    "foliage_drag_coeff": 0.2,  # C_d
 37    #
 38    # turbulence
 39    "ustar": 0.25,  # m; u_*; friction velocity above canopy (assuming const shear layer)
 40    "von_Karman_constant": 0.4,  # k
 41    "Kolmogorov_C0": 5.5,
 42    #
 43    # run options
 44    "dt": 0.25,  # s; time step for the 1-O Newton FT scheme; this is what Pratt used
 45    "t_tot": 100.0,  # s; total time of the run
 46    "dt_out": 1.0,
 47    "continuous_release": True,
 48    "use_numba": True,
 49    "chemistry_on": False,
 50    #
 51    # chemistry
 52    "fv_0": {},  # fv: floral volatiles. initial values (mol, ug, or somesuch) can be provided here.
 53    "n_air_cm3": 2.62e19,  # (dry) air number density (molec cm^-3)
 54    "oxidants_ppbv": {"O3": 40.0, "OH": 1.0e-4, "NO3": 1.0e-5},
 55    #
 56    # Massman and Weil (MW) canopy wind model parameters (could be dict)
 57    "MW_c1": 0.28,  # c's for above-canopy wind profile
 58    "MW_c2": 0.37,
 59    "MW_c3": 15.1,
 60    "MW_gam1": 2.40,  # gam_i = sig_i/u_star (velocity std's above canopy, in sfc layer)
 61    "MW_gam2": 1.90,
 62    "MW_gam3": 1.25,
 63    "MW_alpha": 0.05,  # parameter that controls in-canopy sigma_w and sigma_u
 64    "MW_A2": 0.6,  # appears to be unused, but is part of their model
 65    # only alpha and A_1 are empirical constants (MW p. 89)
 66}
 67"""
 68Input parameter defaults.
 69"""
 70# could do more dict nesting like in pyAPES...
 71
 72
 73# TODO: opposite of this -- calculate above MW params from normal wind params
 74def calc_MW_derived_params(p):
 75    r"""Calculate Massman and Weil (1999) parameters
 76    from the base wind profile parameters
 77    $c_i$, $\gamma_i$, $\alpha$ ($i = 1\colon3$)
 78    and other model input parameters.
 79
 80    References
 81    ----------
 82    * [Massman and Weil (1999)](https://doi.org/10.1023/A:1001810204560) [MW]
 83    """
 84    cd = p["foliage_drag_coeff"]
 85    ustar = p["ustar"]
 86    LAI = p["total_LAI"]
 87    h = p["canopy_height"]
 88    kconstant = p["von_Karman_constant"]
 89
 90    c1 = p["MW_c1"]
 91    c2 = p["MW_c2"]
 92    c3 = p["MW_c3"]
 93    gam1 = p["MW_gam1"]
 94    gam2 = p["MW_gam2"]
 95    gam3 = p["MW_gam3"]
 96    alpha = p["MW_alpha"]
 97
 98    # derived params
 99    nu1 = (gam1**2 + gam2**2 + gam3**2) ** (-0.5)  # MW p. 86
100    nu3 = (gam1**2 + gam2**2 + gam3**2) ** (1.5)
101    nu2 = nu3 / 6 - gam3**2 / (2 * nu1)
102    # Lam2 = 7/(3*alpha**2*nu1*nu3) + [1/3 - gam3**2*nu1**2]/(3*alpha**2*nu1*nu2)  # the first Lambda^2
103    Lam2 = 3 * nu1**2 / alpha**2  # the simplified Lambda^2 expr; MW p. 87
104    Lam = math.sqrt(Lam2)
105    uh = ustar / (c1 - c2 * math.exp(-c3 * cd * LAI))  # u(h); MW Eq. 5
106    n = cd * LAI / (2 * ustar**2 / uh**2)  # MW Eq. 4, definitely here "n" not "nu"
107    B1 = -(9 * ustar / uh) / (2 * alpha * nu1 * (9 / 4 - Lam**2 * ustar**4 / uh**4))
108
109    d = h * (1 - (1 / (2 * n)) * (1 - math.exp(-2 * n)))  # displacement height
110
111    z0 = (h - d) * math.exp(-kconstant * uh / ustar)  # roughness length
112
113    # Calculate dissipation at canopy top to choose matching approach (Massman and Weil)
114    epsilon_ah = (ustar**3) / (kconstant * (h - d))
115    sig_eh = ustar * (nu3) ** (1 / 3)  # not used elsewhere
116    epsilon_ch = sig_eh**3 * (cd * LAI / h) / (nu3 * alpha)
117    if epsilon_ah >= epsilon_ch:
118        epflag = True
119    else:  # eps_a(h) < eps_c(h)  => usually indicates a relatively dense canopy
120        epflag = False
121
122    r = {
123        "MW_nu1": nu1,
124        "MW_nu2": nu2,
125        "MW_nu3": nu3,
126        "MW_Lam": Lam,
127        "MW_n": n,  # should be eta? (think not)
128        "MW_B1": B1,
129        "U_h": uh,  # mean wind speed at canopy height U(h)
130        "displacement_height": d,
131        "roughness_length": z0,
132        "MW_epsilon_a_h": epsilon_ah,  # above-canopy TKE dissipation rate at h
133        "MW_epsilon_c_h": epsilon_ch,  # in-canopy " "
134        "MW_epsilon_ah_gt_ch": epflag,  # name needs to be more descriptive
135    }
136
137    return r
138
139
140def compare_params(p, p0=None, input_params_only=False, print_message=True):
141    """Compare parameters dict `p` to reference `p0`.
142    `p0` is assumed to be the default parameters dict if not provided.
143    Use `input_params_only=True` to only see those differences in the message.
144    """
145    if p0 is None:
146        p0 = Model().p
147        p0_name = "default"
148    else:
149        p0_name = "reference"
150
151    same = True
152    if any(p[k] != p0[k] for k in p0):
153        same = False
154
155        if input_params_only:
156            p0 = {k: p0[k] for k in p0 if k in INPUT_PARAM_DEFAULTS}
157
158        if print_message:
159            t = f"parameter: {p0_name} --> current"
160            print(t)
161            print("-" * len(t))
162        for k, v0 in sorted(p0.items(), key=lambda x: x[0].lower()):  # don't separate uppercase
163            v = p[k]
164            if v0 != v:
165                if print_message:
166                    print(f"'{k}': {v0} --> {v}")
167
168    if same:
169        if print_message:
170            print(f"all params same as {p0_name}")
171
172    return same
173
174
175class Model:
176    """The LPD model."""
177
178    # class variables (as opposed to instance)
179    _p_user_input_default = INPUT_PARAM_DEFAULTS
180    _p_default_MW = calc_MW_derived_params(_p_user_input_default)
181    _p_input_default = {**_p_user_input_default, **_p_default_MW}
182
183    def __init__(self, p=None):
184        """
185        Parameters
186        ----------
187        p : dict
188            User-supplied input parameters (to update the defaults (`INPUT_PARAM_DEFAULTS`)).
189            `Model.update_p` can also be used to update parameters
190            after creating the `Model` instance.
191        """
192        self.p = copy(Model._p_input_default)  # start with defaults
193        """Model parameters dict. This includes both *input* and *derived*
194        parameters and so should not be modified directly
195        (instead use `Model.update_p`).
196        """
197        if p is None:
198            p = {}
199        self.update_p(**p)  # calculate derived params
200
201        # checks (could move to separate `check_p` method or to `update_p`)
202        assert (
203            self.p["release_height"] <= self.p["canopy_height"]
204        ), "particles must be released within canopy"
205        assert (
206            np.modf(self.p["dt_out"] / self.p["dt"])[0] == 0
207        ), "output interval must be a multiple of dt"
208
209        self._init_state()
210        self._init_hist()
211
212    def update_p(self, **kwargs):
213        """Use `**kwargs` (allowed user input parameters only) to check/update all model parameters
214        (in place).
215        """
216        allowed_keys = self._p_user_input_default.keys()
217        for k, v in kwargs.items():
218            if k not in allowed_keys:
219                msg = f"key '{k}' is not in the default parameter list. ignoring it."
220                warnings.warn(msg)
221            else:
222                if isinstance(v, dict):
223                    self.p[k].update(v)
224                else:
225                    self.p[k] = v
226
227        # calculated parameters (probably should be in setter methods or something to prevent inconsistencies)
228        # i.e., user changing them without using `update_p`
229        self.p["N_sources"] = len(self.p["source_positions"])
230
231        # calculate oxidant concentrations from ppbv values and air number density
232        n_a = self.p["n_air_cm3"]
233        conc_ox = {}
234        for ox_name, ox_ppbv in self.p["oxidants_ppbv"].items():
235            conc_ox[ox_name] = n_a * ox_ppbv * 1e-9
236        self.p.update({"conc_oxidants": conc_ox})
237
238        # calculate number of time steps: N_t from t_tot
239        t_tot = self.p["t_tot"]
240        dt = self.p["dt"]
241        N_t = math.floor(t_tot / dt)  # number of time steps
242        if abs(N_t - t_tot / dt) > 0.01:
243            msg = f"N was rounded down from {t_tot/dt:.4f} to {N_t}"
244            warnings.warn(msg)
245        self.p["N_t"] = N_t  # TODO: consistentify style between N_p and N_t
246
247        # calculate total run number of particles: Np_tot
248        dNp_dt_ds = self.p["dNp_per_dt_per_source"]
249        N_s = self.p["N_sources"]
250        if self.p["continuous_release"]:
251            Np_tot = N_t * dNp_dt_ds * N_s
252            Np_tot_per_source = int(Np_tot / N_s)
253
254        else:
255            Np_tot = dNp_dt_ds * N_s
256            Np_tot_per_source = dNp_dt_ds
257        self.p["Np_tot"] = Np_tot
258        self.p["Np_tot_per_source"] = Np_tot_per_source
259
260        # some variables change the lengths of the state and hist arrays
261        if any(
262            k in kwargs
263            for k in ["t_tot", "dNp_per_dt_per_source", "N_sources", "continuous_release"]
264        ):
265            self._init_state()
266            self._init_hist()
267
268        # some variables affect the derived MW variables
269        #
270        # these are the non-model-parameter inputs:
271        MW_inputs = [
272            "foliage_drag_coeff",
273            "ustar",
274            "total_LAI",
275            "canopy_height",
276            "von_Karman_constant",
277        ]
278        # check for these and also the MW model parameters
279        if any(k in kwargs for k in MW_inputs) or any(k[:2] == "MW" for k in kwargs):
280            self.p.update(calc_MW_derived_params(self.p))
281
282        return self
283
284    # TODO: could change self.p to self._p, but have self.p return a view,
285    #       but give error if user tries to set items
286
287    # TODO: init conc at this time too?
288    def _init_state(self):
289        Np_tot = self.p["Np_tot"]
290        # also could do as 3-D? (x,y,z coords)
291
292        # particle positions
293        xp = np.empty((Np_tot,))
294        yp = np.empty((Np_tot,))
295        zp = np.empty((Np_tot,))
296
297        # local wind speed (perturbation?) at particle positions
298        up = np.zeros((Np_tot,))  # should initial u be = horiz wind speed? or + a perturbation?
299        vp = np.zeros((Np_tot,))
300        wp = np.zeros((Np_tot,))
301
302        # seems ideal to generate sources and initial pos for each before going into time loop
303        # can't be generator because we go over them multiple times
304        # but could have a generator that generatoes that sources for each time step?
305        N_sources = self.p["N_sources"]
306        Np_tot_per_source = self.p["Np_tot_per_source"]
307        release_height = self.p["release_height"]
308        source_positions = self.p["source_positions"]
309        for isource in range(N_sources):
310            ib = isource * Np_tot_per_source
311            ie = (isource + 1) * Np_tot_per_source
312            xp[ib:ie] = source_positions[isource][0]
313            yp[ib:ie] = source_positions[isource][1]
314            zp[ib:ie] = release_height
315
316        # rearrange by time (instead of by source)
317        for p_ in (xp, yp, zp):
318            groups = []
319            for isource in range(N_sources):
320                ib = isource * Np_tot_per_source
321                ie = (isource + 1) * Np_tot_per_source
322                groups.append(p_[ib:ie])
323            p_[:] = np.column_stack(groups).flatten()
324        # assuming sources same strength everywhere for now
325
326        self.state = {
327            # 'k': 0,
328            # 't': 0,
329            # 'Np_k': 0,
330            "xp": xp,
331            "yp": yp,
332            "zp": zp,
333            "up": up,
334            "vp": vp,
335            "wp": wp,
336        }
337
338    def _init_hist(self):
339        if self.p["continuous_release"]:
340            hist = False
341        else:
342            # set up hist if dt_out
343            # should use xarray for this (and for other stuff too; like sympl)
344            if self.p["dt_out"] <= 0:
345                raise ValueError("dt_out must be pos. to use single-release mode")
346                # TODO: should be ok to do continuous_release without hist if want
347
348            t_tot = self.p["t_tot"]
349            dt_out = self.p["dt_out"]
350            Np_tot = self.p["Np_tot"]
351            hist = dict()
352            N_t_hist = int(t_tot / dt_out) + 1
353            hist["pos"] = np.empty((Np_tot, N_t_hist, 3))  # particle, x, y, z
354            hist["ws"] = np.empty((Np_tot, N_t_hist, 3))
355            # could use list instead of particle dim
356            # to allow for those with different record lengths
357
358            xp = self.state["xp"]
359            yp = self.state["yp"]
360            zp = self.state["zp"]
361            up = self.state["up"]
362            vp = self.state["vp"]
363            wp = self.state["wp"]
364            hist["pos"][:, 0, :] = np.column_stack((xp, yp, zp))  # initial positions
365            hist["ws"][:, 0, :] = np.column_stack((up, vp, wp))
366
367        self.hist = hist
368
369    def run(self):
370        """Run the LPD model, integrating the particles using
371        `blpd.lpd.integrate_particles_one_timestep`.
372        """
373        import datetime
374
375        # TODO: change to `time.perf_counter` for this?
376        self._clock_time_run_start = datetime.datetime.now()
377
378        Np_k = 0  # initially tracking 0 particles
379        # Np_tot = self.p['Np_tot']
380        dt = self.p["dt"]
381        dt_out = self.p["dt_out"]
382        N_t = self.p["N_t"]  # number of time steps
383        # t_tot = self.p['t_tot']
384        dNp_dt_ds = self.p["dNp_per_dt_per_source"]
385        N_s = self.p["N_sources"]
386        # outer loop could be particles instead of time. might make some parallelization easier
387
388        # init of hist and state could go here
389
390        if self.p["use_numba"]:
391            enable_numba()  # ensure numba compilation is not disabled
392
393            # > prepare p for numba
394            p_for_nb = {k: v for k, v in self.p.items() if not isinstance(v, (str, list, dict))}
395            # p_for_nb = {k: v for k, v in self.p.items() if isinstance(v, (int, float, np.ndarray))}
396            # p_nb = numbify(p_for_nb)
397            p_nb = numbify(p_for_nb, zerod_only=True)  # only floats/ints
398
399            # > prepare state for numba
400            #  leaving out t and k for now, pass as arguments instead
401            # state_for_nb = {k: v for k, v in self.state.items() if k in ('xp', 'yp', 'zp', 'up', 'vp', 'wp')}
402            state_for_nb = self.state
403            state_nb = numbify(state_for_nb)
404
405            # for debug
406            self.p_nb = p_nb
407            self.state_nb = state_nb
408
409            state_run = state_nb
410            p_run = p_nb
411
412        else:  # model is set not to use numba (for checking the performance advantage of using numba)
413            disable_numba()  # disable numba compilation
414
415            state_run = self.state
416            p_run = self.p
417
418        # changing numba config in lpd redefines the njit decorated functions
419        # but that isn't recognized right away unless we do this re-import
420        # reloading numba in lpd doesn't seem to work
421        # - at least not the first time trying to run after changing use_numba True->False
422        importlib.reload(lpd)
423
424        # print(state_run['xp'].shape)
425
426        for k in range(1, N_t + 1):
427            if self.p["continuous_release"]:
428                Np_k += dNp_dt_ds * N_s
429            else:  # only release at k=1 (at k=0 the particles are inside their release point)
430                if k == 1:
431                    Np_k += dNp_dt_ds * N_s
432                else:
433                    pass
434
435            t = k * dt  # current (elapsed) time
436
437            if self.p["use_numba"]:
438                state_run.update(numbify({"k": k, "t": t, "Np_k": Np_k}))
439            else:
440                state_run.update({"k": [k], "t": [t], "Np_k": [Np_k]})
441            # print(state_run['xp'].shape)
442
443            # pass numba-ified dicts here
444
445            lpd.integrate_particles_one_timestep(state_run, p_run)
446            # integrate_particles_one_timestep(state_run, p_run)
447
448            # TODO: option to save avg / other stats in addition to instantaneous? or specify avg vs instant?
449            if self.hist is not False:
450                if t % dt_out == 0:
451                    o = int(t // dt_out)  # note that `int()` floors anyway
452                    xp = state_run["xp"]
453                    yp = state_run["yp"]
454                    zp = state_run["zp"]
455                    up = state_run["up"]
456                    vp = state_run["vp"]
457                    wp = state_run["wp"]
458                    self.hist["pos"][:, o, :] = np.column_stack((xp, yp, zp))
459                    self.hist["ws"][:, o, :] = np.column_stack((up, vp, wp))
460
461        if self.p["use_numba"]:
462            self.state = unnumbify(state_run)
463        else:
464            self.state = state_run
465
466        self._clock_time_run_end = datetime.datetime.now()
467
468        # self._maybe_run_chem()
469
470        return self
471
472    # def _maybe_run_chem(self):
473    #     # note: adds conc dataset to self.state
474
475    #     # this check/correction logic could be somewhere else
476    #     if self.p['chemistry_on']:
477    #         if not self.p['continuous_release']:
478    #             warnings.warn(
479    #                 'chemistry is calculated only for the continuous release option (`continuous_release=True`). not calculating chemistry',
480    #                 stacklevel=2,
481    #             )
482    #             self.p['chemistry_on'] = False
483
484    #     if self.p["chemistry_on"]:
485    #         conc = chem_calc_options["fixed_oxidants"](self.to_xarray())
486
487    #     else:
488    #         conc = False
489
490    #     self.state.update({
491    #         'conc': conc
492    #     })
493    #     # maybe should instead remove 'conc' from state if not doing chem
494
495    def to_xarray(self):
496        """Create and return an `xr.Dataset` of the LPD run."""
497        # TODO: smoothing/skipping options to reduce storage needed?
498        import json
499
500        import xarray as xr
501
502        ip_coord_tup = (
503            "ip",
504            np.arange(self.p["Np_tot"]),
505            {"long_name": "Lagrangian particle index"},
506        )
507        if self.hist:  # continuous release run
508            t = np.arange(0, self.p["t_tot"] + self.p["dt_out"], self.p["dt_out"])
509            # ^ note can use `pd.to_timedelta(t, unit="s")`
510            dims = ("ip", "t")
511            coords = {
512                "ip": ip_coord_tup,
513                "t": ("t", t, {"long_name": "Simulation elapsed time", "units": "s"}),
514            }
515            x = self.hist["pos"][..., 0]
516            y = self.hist["pos"][..., 1]
517            z = self.hist["pos"][..., 2]
518            u = self.hist["ws"][..., 0]
519            v = self.hist["ws"][..., 1]
520            w = self.hist["ws"][..., 2]
521        else:  # no hist, only current state
522            dims = ("ip",)
523            coords = {"ip": ip_coord_tup}
524            x = self.state["xp"]
525            y = self.state["yp"]
526            z = self.state["zp"]
527            u = self.state["up"]
528            v = self.state["vp"]
529            w = self.state["wp"]
530
531        data_vars = {
532            "x": (dims, x, {"long_name": "$x$", "units": "m"}),
533            "y": (dims, y, {"long_name": "$y$", "units": "m"}),
534            "z": (dims, z, {"long_name": "$z$", "units": "m"}),
535            "u": (dims, u, {"long_name": "$u$", "units": "m s$^{-1}$"}),
536            "v": (dims, v, {"long_name": "$v$", "units": "m s$^{-1}$"}),
537            "w": (dims, w, {"long_name": "$w$", "units": "m s$^{-1}$"}),
538        }
539
540        # Serialize model parameters in JSON to allow saving in netCDF and loading later
541        attrs = {
542            "run_completed": self._clock_time_run_end,
543            "run_runtime": self._clock_time_run_end - self._clock_time_run_start,
544            # TODO: package version once the packaging is better...
545            "p_json": json.dumps(self.p),
546        }
547
548        ds = xr.Dataset(
549            coords=coords,
550            data_vars=data_vars,
551            attrs=attrs,
552        )
553
554        # TODO: Add extra useful coordinates: t_out for non-hist and t as Timedelta for hist
555
556        return ds
557
558    def to_xr(self):
559        """Alias of `Model.to_xarray`."""
560
561    def plot(self, **kwargs):
562        """Make a plot of the results (type based on run type).
563        `**kwargs` are passed through to the relevant plotting function.
564        * single release: `blpd.plot.trajectories`
565        * continuous release: `blpd.plot.final_pos_scatter`
566        """
567        # first check if model has been run
568        p = self.p
569        state = self.state
570        hist = self.hist
571        from . import plot
572
573        if np.all(state["up"] == 0):  # model probably hasn't been run
574            pass  # silently do nothing for now
575        else:
576            if (p["continuous_release"] is False) and hist:
577                plot.trajectories(self.to_xarray(), **kwargs)
578            else:
579                plot.final_pos_scatter(self.to_xarray(), **kwargs)
class Model:
176class Model:
177    """The LPD model."""
178
179    # class variables (as opposed to instance)
180    _p_user_input_default = INPUT_PARAM_DEFAULTS
181    _p_default_MW = calc_MW_derived_params(_p_user_input_default)
182    _p_input_default = {**_p_user_input_default, **_p_default_MW}
183
184    def __init__(self, p=None):
185        """
186        Parameters
187        ----------
188        p : dict
189            User-supplied input parameters (to update the defaults (`INPUT_PARAM_DEFAULTS`)).
190            `Model.update_p` can also be used to update parameters
191            after creating the `Model` instance.
192        """
193        self.p = copy(Model._p_input_default)  # start with defaults
194        """Model parameters dict. This includes both *input* and *derived*
195        parameters and so should not be modified directly
196        (instead use `Model.update_p`).
197        """
198        if p is None:
199            p = {}
200        self.update_p(**p)  # calculate derived params
201
202        # checks (could move to separate `check_p` method or to `update_p`)
203        assert (
204            self.p["release_height"] <= self.p["canopy_height"]
205        ), "particles must be released within canopy"
206        assert (
207            np.modf(self.p["dt_out"] / self.p["dt"])[0] == 0
208        ), "output interval must be a multiple of dt"
209
210        self._init_state()
211        self._init_hist()
212
213    def update_p(self, **kwargs):
214        """Use `**kwargs` (allowed user input parameters only) to check/update all model parameters
215        (in place).
216        """
217        allowed_keys = self._p_user_input_default.keys()
218        for k, v in kwargs.items():
219            if k not in allowed_keys:
220                msg = f"key '{k}' is not in the default parameter list. ignoring it."
221                warnings.warn(msg)
222            else:
223                if isinstance(v, dict):
224                    self.p[k].update(v)
225                else:
226                    self.p[k] = v
227
228        # calculated parameters (probably should be in setter methods or something to prevent inconsistencies)
229        # i.e., user changing them without using `update_p`
230        self.p["N_sources"] = len(self.p["source_positions"])
231
232        # calculate oxidant concentrations from ppbv values and air number density
233        n_a = self.p["n_air_cm3"]
234        conc_ox = {}
235        for ox_name, ox_ppbv in self.p["oxidants_ppbv"].items():
236            conc_ox[ox_name] = n_a * ox_ppbv * 1e-9
237        self.p.update({"conc_oxidants": conc_ox})
238
239        # calculate number of time steps: N_t from t_tot
240        t_tot = self.p["t_tot"]
241        dt = self.p["dt"]
242        N_t = math.floor(t_tot / dt)  # number of time steps
243        if abs(N_t - t_tot / dt) > 0.01:
244            msg = f"N was rounded down from {t_tot/dt:.4f} to {N_t}"
245            warnings.warn(msg)
246        self.p["N_t"] = N_t  # TODO: consistentify style between N_p and N_t
247
248        # calculate total run number of particles: Np_tot
249        dNp_dt_ds = self.p["dNp_per_dt_per_source"]
250        N_s = self.p["N_sources"]
251        if self.p["continuous_release"]:
252            Np_tot = N_t * dNp_dt_ds * N_s
253            Np_tot_per_source = int(Np_tot / N_s)
254
255        else:
256            Np_tot = dNp_dt_ds * N_s
257            Np_tot_per_source = dNp_dt_ds
258        self.p["Np_tot"] = Np_tot
259        self.p["Np_tot_per_source"] = Np_tot_per_source
260
261        # some variables change the lengths of the state and hist arrays
262        if any(
263            k in kwargs
264            for k in ["t_tot", "dNp_per_dt_per_source", "N_sources", "continuous_release"]
265        ):
266            self._init_state()
267            self._init_hist()
268
269        # some variables affect the derived MW variables
270        #
271        # these are the non-model-parameter inputs:
272        MW_inputs = [
273            "foliage_drag_coeff",
274            "ustar",
275            "total_LAI",
276            "canopy_height",
277            "von_Karman_constant",
278        ]
279        # check for these and also the MW model parameters
280        if any(k in kwargs for k in MW_inputs) or any(k[:2] == "MW" for k in kwargs):
281            self.p.update(calc_MW_derived_params(self.p))
282
283        return self
284
285    # TODO: could change self.p to self._p, but have self.p return a view,
286    #       but give error if user tries to set items
287
288    # TODO: init conc at this time too?
289    def _init_state(self):
290        Np_tot = self.p["Np_tot"]
291        # also could do as 3-D? (x,y,z coords)
292
293        # particle positions
294        xp = np.empty((Np_tot,))
295        yp = np.empty((Np_tot,))
296        zp = np.empty((Np_tot,))
297
298        # local wind speed (perturbation?) at particle positions
299        up = np.zeros((Np_tot,))  # should initial u be = horiz wind speed? or + a perturbation?
300        vp = np.zeros((Np_tot,))
301        wp = np.zeros((Np_tot,))
302
303        # seems ideal to generate sources and initial pos for each before going into time loop
304        # can't be generator because we go over them multiple times
305        # but could have a generator that generatoes that sources for each time step?
306        N_sources = self.p["N_sources"]
307        Np_tot_per_source = self.p["Np_tot_per_source"]
308        release_height = self.p["release_height"]
309        source_positions = self.p["source_positions"]
310        for isource in range(N_sources):
311            ib = isource * Np_tot_per_source
312            ie = (isource + 1) * Np_tot_per_source
313            xp[ib:ie] = source_positions[isource][0]
314            yp[ib:ie] = source_positions[isource][1]
315            zp[ib:ie] = release_height
316
317        # rearrange by time (instead of by source)
318        for p_ in (xp, yp, zp):
319            groups = []
320            for isource in range(N_sources):
321                ib = isource * Np_tot_per_source
322                ie = (isource + 1) * Np_tot_per_source
323                groups.append(p_[ib:ie])
324            p_[:] = np.column_stack(groups).flatten()
325        # assuming sources same strength everywhere for now
326
327        self.state = {
328            # 'k': 0,
329            # 't': 0,
330            # 'Np_k': 0,
331            "xp": xp,
332            "yp": yp,
333            "zp": zp,
334            "up": up,
335            "vp": vp,
336            "wp": wp,
337        }
338
339    def _init_hist(self):
340        if self.p["continuous_release"]:
341            hist = False
342        else:
343            # set up hist if dt_out
344            # should use xarray for this (and for other stuff too; like sympl)
345            if self.p["dt_out"] <= 0:
346                raise ValueError("dt_out must be pos. to use single-release mode")
347                # TODO: should be ok to do continuous_release without hist if want
348
349            t_tot = self.p["t_tot"]
350            dt_out = self.p["dt_out"]
351            Np_tot = self.p["Np_tot"]
352            hist = dict()
353            N_t_hist = int(t_tot / dt_out) + 1
354            hist["pos"] = np.empty((Np_tot, N_t_hist, 3))  # particle, x, y, z
355            hist["ws"] = np.empty((Np_tot, N_t_hist, 3))
356            # could use list instead of particle dim
357            # to allow for those with different record lengths
358
359            xp = self.state["xp"]
360            yp = self.state["yp"]
361            zp = self.state["zp"]
362            up = self.state["up"]
363            vp = self.state["vp"]
364            wp = self.state["wp"]
365            hist["pos"][:, 0, :] = np.column_stack((xp, yp, zp))  # initial positions
366            hist["ws"][:, 0, :] = np.column_stack((up, vp, wp))
367
368        self.hist = hist
369
370    def run(self):
371        """Run the LPD model, integrating the particles using
372        `blpd.lpd.integrate_particles_one_timestep`.
373        """
374        import datetime
375
376        # TODO: change to `time.perf_counter` for this?
377        self._clock_time_run_start = datetime.datetime.now()
378
379        Np_k = 0  # initially tracking 0 particles
380        # Np_tot = self.p['Np_tot']
381        dt = self.p["dt"]
382        dt_out = self.p["dt_out"]
383        N_t = self.p["N_t"]  # number of time steps
384        # t_tot = self.p['t_tot']
385        dNp_dt_ds = self.p["dNp_per_dt_per_source"]
386        N_s = self.p["N_sources"]
387        # outer loop could be particles instead of time. might make some parallelization easier
388
389        # init of hist and state could go here
390
391        if self.p["use_numba"]:
392            enable_numba()  # ensure numba compilation is not disabled
393
394            # > prepare p for numba
395            p_for_nb = {k: v for k, v in self.p.items() if not isinstance(v, (str, list, dict))}
396            # p_for_nb = {k: v for k, v in self.p.items() if isinstance(v, (int, float, np.ndarray))}
397            # p_nb = numbify(p_for_nb)
398            p_nb = numbify(p_for_nb, zerod_only=True)  # only floats/ints
399
400            # > prepare state for numba
401            #  leaving out t and k for now, pass as arguments instead
402            # state_for_nb = {k: v for k, v in self.state.items() if k in ('xp', 'yp', 'zp', 'up', 'vp', 'wp')}
403            state_for_nb = self.state
404            state_nb = numbify(state_for_nb)
405
406            # for debug
407            self.p_nb = p_nb
408            self.state_nb = state_nb
409
410            state_run = state_nb
411            p_run = p_nb
412
413        else:  # model is set not to use numba (for checking the performance advantage of using numba)
414            disable_numba()  # disable numba compilation
415
416            state_run = self.state
417            p_run = self.p
418
419        # changing numba config in lpd redefines the njit decorated functions
420        # but that isn't recognized right away unless we do this re-import
421        # reloading numba in lpd doesn't seem to work
422        # - at least not the first time trying to run after changing use_numba True->False
423        importlib.reload(lpd)
424
425        # print(state_run['xp'].shape)
426
427        for k in range(1, N_t + 1):
428            if self.p["continuous_release"]:
429                Np_k += dNp_dt_ds * N_s
430            else:  # only release at k=1 (at k=0 the particles are inside their release point)
431                if k == 1:
432                    Np_k += dNp_dt_ds * N_s
433                else:
434                    pass
435
436            t = k * dt  # current (elapsed) time
437
438            if self.p["use_numba"]:
439                state_run.update(numbify({"k": k, "t": t, "Np_k": Np_k}))
440            else:
441                state_run.update({"k": [k], "t": [t], "Np_k": [Np_k]})
442            # print(state_run['xp'].shape)
443
444            # pass numba-ified dicts here
445
446            lpd.integrate_particles_one_timestep(state_run, p_run)
447            # integrate_particles_one_timestep(state_run, p_run)
448
449            # TODO: option to save avg / other stats in addition to instantaneous? or specify avg vs instant?
450            if self.hist is not False:
451                if t % dt_out == 0:
452                    o = int(t // dt_out)  # note that `int()` floors anyway
453                    xp = state_run["xp"]
454                    yp = state_run["yp"]
455                    zp = state_run["zp"]
456                    up = state_run["up"]
457                    vp = state_run["vp"]
458                    wp = state_run["wp"]
459                    self.hist["pos"][:, o, :] = np.column_stack((xp, yp, zp))
460                    self.hist["ws"][:, o, :] = np.column_stack((up, vp, wp))
461
462        if self.p["use_numba"]:
463            self.state = unnumbify(state_run)
464        else:
465            self.state = state_run
466
467        self._clock_time_run_end = datetime.datetime.now()
468
469        # self._maybe_run_chem()
470
471        return self
472
473    # def _maybe_run_chem(self):
474    #     # note: adds conc dataset to self.state
475
476    #     # this check/correction logic could be somewhere else
477    #     if self.p['chemistry_on']:
478    #         if not self.p['continuous_release']:
479    #             warnings.warn(
480    #                 'chemistry is calculated only for the continuous release option (`continuous_release=True`). not calculating chemistry',
481    #                 stacklevel=2,
482    #             )
483    #             self.p['chemistry_on'] = False
484
485    #     if self.p["chemistry_on"]:
486    #         conc = chem_calc_options["fixed_oxidants"](self.to_xarray())
487
488    #     else:
489    #         conc = False
490
491    #     self.state.update({
492    #         'conc': conc
493    #     })
494    #     # maybe should instead remove 'conc' from state if not doing chem
495
496    def to_xarray(self):
497        """Create and return an `xr.Dataset` of the LPD run."""
498        # TODO: smoothing/skipping options to reduce storage needed?
499        import json
500
501        import xarray as xr
502
503        ip_coord_tup = (
504            "ip",
505            np.arange(self.p["Np_tot"]),
506            {"long_name": "Lagrangian particle index"},
507        )
508        if self.hist:  # continuous release run
509            t = np.arange(0, self.p["t_tot"] + self.p["dt_out"], self.p["dt_out"])
510            # ^ note can use `pd.to_timedelta(t, unit="s")`
511            dims = ("ip", "t")
512            coords = {
513                "ip": ip_coord_tup,
514                "t": ("t", t, {"long_name": "Simulation elapsed time", "units": "s"}),
515            }
516            x = self.hist["pos"][..., 0]
517            y = self.hist["pos"][..., 1]
518            z = self.hist["pos"][..., 2]
519            u = self.hist["ws"][..., 0]
520            v = self.hist["ws"][..., 1]
521            w = self.hist["ws"][..., 2]
522        else:  # no hist, only current state
523            dims = ("ip",)
524            coords = {"ip": ip_coord_tup}
525            x = self.state["xp"]
526            y = self.state["yp"]
527            z = self.state["zp"]
528            u = self.state["up"]
529            v = self.state["vp"]
530            w = self.state["wp"]
531
532        data_vars = {
533            "x": (dims, x, {"long_name": "$x$", "units": "m"}),
534            "y": (dims, y, {"long_name": "$y$", "units": "m"}),
535            "z": (dims, z, {"long_name": "$z$", "units": "m"}),
536            "u": (dims, u, {"long_name": "$u$", "units": "m s$^{-1}$"}),
537            "v": (dims, v, {"long_name": "$v$", "units": "m s$^{-1}$"}),
538            "w": (dims, w, {"long_name": "$w$", "units": "m s$^{-1}$"}),
539        }
540
541        # Serialize model parameters in JSON to allow saving in netCDF and loading later
542        attrs = {
543            "run_completed": self._clock_time_run_end,
544            "run_runtime": self._clock_time_run_end - self._clock_time_run_start,
545            # TODO: package version once the packaging is better...
546            "p_json": json.dumps(self.p),
547        }
548
549        ds = xr.Dataset(
550            coords=coords,
551            data_vars=data_vars,
552            attrs=attrs,
553        )
554
555        # TODO: Add extra useful coordinates: t_out for non-hist and t as Timedelta for hist
556
557        return ds
558
559    def to_xr(self):
560        """Alias of `Model.to_xarray`."""
561
562    def plot(self, **kwargs):
563        """Make a plot of the results (type based on run type).
564        `**kwargs` are passed through to the relevant plotting function.
565        * single release: `blpd.plot.trajectories`
566        * continuous release: `blpd.plot.final_pos_scatter`
567        """
568        # first check if model has been run
569        p = self.p
570        state = self.state
571        hist = self.hist
572        from . import plot
573
574        if np.all(state["up"] == 0):  # model probably hasn't been run
575            pass  # silently do nothing for now
576        else:
577            if (p["continuous_release"] is False) and hist:
578                plot.trajectories(self.to_xarray(), **kwargs)
579            else:
580                plot.final_pos_scatter(self.to_xarray(), **kwargs)

The LPD model.

Model(p=None)
184    def __init__(self, p=None):
185        """
186        Parameters
187        ----------
188        p : dict
189            User-supplied input parameters (to update the defaults (`INPUT_PARAM_DEFAULTS`)).
190            `Model.update_p` can also be used to update parameters
191            after creating the `Model` instance.
192        """
193        self.p = copy(Model._p_input_default)  # start with defaults
194        """Model parameters dict. This includes both *input* and *derived*
195        parameters and so should not be modified directly
196        (instead use `Model.update_p`).
197        """
198        if p is None:
199            p = {}
200        self.update_p(**p)  # calculate derived params
201
202        # checks (could move to separate `check_p` method or to `update_p`)
203        assert (
204            self.p["release_height"] <= self.p["canopy_height"]
205        ), "particles must be released within canopy"
206        assert (
207            np.modf(self.p["dt_out"] / self.p["dt"])[0] == 0
208        ), "output interval must be a multiple of dt"
209
210        self._init_state()
211        self._init_hist()
Parameters
p

Model parameters dict. This includes both input and derived parameters and so should not be modified directly (instead use Model.update_p).

def update_p(self, **kwargs):
213    def update_p(self, **kwargs):
214        """Use `**kwargs` (allowed user input parameters only) to check/update all model parameters
215        (in place).
216        """
217        allowed_keys = self._p_user_input_default.keys()
218        for k, v in kwargs.items():
219            if k not in allowed_keys:
220                msg = f"key '{k}' is not in the default parameter list. ignoring it."
221                warnings.warn(msg)
222            else:
223                if isinstance(v, dict):
224                    self.p[k].update(v)
225                else:
226                    self.p[k] = v
227
228        # calculated parameters (probably should be in setter methods or something to prevent inconsistencies)
229        # i.e., user changing them without using `update_p`
230        self.p["N_sources"] = len(self.p["source_positions"])
231
232        # calculate oxidant concentrations from ppbv values and air number density
233        n_a = self.p["n_air_cm3"]
234        conc_ox = {}
235        for ox_name, ox_ppbv in self.p["oxidants_ppbv"].items():
236            conc_ox[ox_name] = n_a * ox_ppbv * 1e-9
237        self.p.update({"conc_oxidants": conc_ox})
238
239        # calculate number of time steps: N_t from t_tot
240        t_tot = self.p["t_tot"]
241        dt = self.p["dt"]
242        N_t = math.floor(t_tot / dt)  # number of time steps
243        if abs(N_t - t_tot / dt) > 0.01:
244            msg = f"N was rounded down from {t_tot/dt:.4f} to {N_t}"
245            warnings.warn(msg)
246        self.p["N_t"] = N_t  # TODO: consistentify style between N_p and N_t
247
248        # calculate total run number of particles: Np_tot
249        dNp_dt_ds = self.p["dNp_per_dt_per_source"]
250        N_s = self.p["N_sources"]
251        if self.p["continuous_release"]:
252            Np_tot = N_t * dNp_dt_ds * N_s
253            Np_tot_per_source = int(Np_tot / N_s)
254
255        else:
256            Np_tot = dNp_dt_ds * N_s
257            Np_tot_per_source = dNp_dt_ds
258        self.p["Np_tot"] = Np_tot
259        self.p["Np_tot_per_source"] = Np_tot_per_source
260
261        # some variables change the lengths of the state and hist arrays
262        if any(
263            k in kwargs
264            for k in ["t_tot", "dNp_per_dt_per_source", "N_sources", "continuous_release"]
265        ):
266            self._init_state()
267            self._init_hist()
268
269        # some variables affect the derived MW variables
270        #
271        # these are the non-model-parameter inputs:
272        MW_inputs = [
273            "foliage_drag_coeff",
274            "ustar",
275            "total_LAI",
276            "canopy_height",
277            "von_Karman_constant",
278        ]
279        # check for these and also the MW model parameters
280        if any(k in kwargs for k in MW_inputs) or any(k[:2] == "MW" for k in kwargs):
281            self.p.update(calc_MW_derived_params(self.p))
282
283        return self

Use **kwargs (allowed user input parameters only) to check/update all model parameters (in place).

def run(self):
370    def run(self):
371        """Run the LPD model, integrating the particles using
372        `blpd.lpd.integrate_particles_one_timestep`.
373        """
374        import datetime
375
376        # TODO: change to `time.perf_counter` for this?
377        self._clock_time_run_start = datetime.datetime.now()
378
379        Np_k = 0  # initially tracking 0 particles
380        # Np_tot = self.p['Np_tot']
381        dt = self.p["dt"]
382        dt_out = self.p["dt_out"]
383        N_t = self.p["N_t"]  # number of time steps
384        # t_tot = self.p['t_tot']
385        dNp_dt_ds = self.p["dNp_per_dt_per_source"]
386        N_s = self.p["N_sources"]
387        # outer loop could be particles instead of time. might make some parallelization easier
388
389        # init of hist and state could go here
390
391        if self.p["use_numba"]:
392            enable_numba()  # ensure numba compilation is not disabled
393
394            # > prepare p for numba
395            p_for_nb = {k: v for k, v in self.p.items() if not isinstance(v, (str, list, dict))}
396            # p_for_nb = {k: v for k, v in self.p.items() if isinstance(v, (int, float, np.ndarray))}
397            # p_nb = numbify(p_for_nb)
398            p_nb = numbify(p_for_nb, zerod_only=True)  # only floats/ints
399
400            # > prepare state for numba
401            #  leaving out t and k for now, pass as arguments instead
402            # state_for_nb = {k: v for k, v in self.state.items() if k in ('xp', 'yp', 'zp', 'up', 'vp', 'wp')}
403            state_for_nb = self.state
404            state_nb = numbify(state_for_nb)
405
406            # for debug
407            self.p_nb = p_nb
408            self.state_nb = state_nb
409
410            state_run = state_nb
411            p_run = p_nb
412
413        else:  # model is set not to use numba (for checking the performance advantage of using numba)
414            disable_numba()  # disable numba compilation
415
416            state_run = self.state
417            p_run = self.p
418
419        # changing numba config in lpd redefines the njit decorated functions
420        # but that isn't recognized right away unless we do this re-import
421        # reloading numba in lpd doesn't seem to work
422        # - at least not the first time trying to run after changing use_numba True->False
423        importlib.reload(lpd)
424
425        # print(state_run['xp'].shape)
426
427        for k in range(1, N_t + 1):
428            if self.p["continuous_release"]:
429                Np_k += dNp_dt_ds * N_s
430            else:  # only release at k=1 (at k=0 the particles are inside their release point)
431                if k == 1:
432                    Np_k += dNp_dt_ds * N_s
433                else:
434                    pass
435
436            t = k * dt  # current (elapsed) time
437
438            if self.p["use_numba"]:
439                state_run.update(numbify({"k": k, "t": t, "Np_k": Np_k}))
440            else:
441                state_run.update({"k": [k], "t": [t], "Np_k": [Np_k]})
442            # print(state_run['xp'].shape)
443
444            # pass numba-ified dicts here
445
446            lpd.integrate_particles_one_timestep(state_run, p_run)
447            # integrate_particles_one_timestep(state_run, p_run)
448
449            # TODO: option to save avg / other stats in addition to instantaneous? or specify avg vs instant?
450            if self.hist is not False:
451                if t % dt_out == 0:
452                    o = int(t // dt_out)  # note that `int()` floors anyway
453                    xp = state_run["xp"]
454                    yp = state_run["yp"]
455                    zp = state_run["zp"]
456                    up = state_run["up"]
457                    vp = state_run["vp"]
458                    wp = state_run["wp"]
459                    self.hist["pos"][:, o, :] = np.column_stack((xp, yp, zp))
460                    self.hist["ws"][:, o, :] = np.column_stack((up, vp, wp))
461
462        if self.p["use_numba"]:
463            self.state = unnumbify(state_run)
464        else:
465            self.state = state_run
466
467        self._clock_time_run_end = datetime.datetime.now()
468
469        # self._maybe_run_chem()
470
471        return self

Run the LPD model, integrating the particles using blpd.lpd.integrate_particles_one_timestep.

def to_xarray(self):
496    def to_xarray(self):
497        """Create and return an `xr.Dataset` of the LPD run."""
498        # TODO: smoothing/skipping options to reduce storage needed?
499        import json
500
501        import xarray as xr
502
503        ip_coord_tup = (
504            "ip",
505            np.arange(self.p["Np_tot"]),
506            {"long_name": "Lagrangian particle index"},
507        )
508        if self.hist:  # continuous release run
509            t = np.arange(0, self.p["t_tot"] + self.p["dt_out"], self.p["dt_out"])
510            # ^ note can use `pd.to_timedelta(t, unit="s")`
511            dims = ("ip", "t")
512            coords = {
513                "ip": ip_coord_tup,
514                "t": ("t", t, {"long_name": "Simulation elapsed time", "units": "s"}),
515            }
516            x = self.hist["pos"][..., 0]
517            y = self.hist["pos"][..., 1]
518            z = self.hist["pos"][..., 2]
519            u = self.hist["ws"][..., 0]
520            v = self.hist["ws"][..., 1]
521            w = self.hist["ws"][..., 2]
522        else:  # no hist, only current state
523            dims = ("ip",)
524            coords = {"ip": ip_coord_tup}
525            x = self.state["xp"]
526            y = self.state["yp"]
527            z = self.state["zp"]
528            u = self.state["up"]
529            v = self.state["vp"]
530            w = self.state["wp"]
531
532        data_vars = {
533            "x": (dims, x, {"long_name": "$x$", "units": "m"}),
534            "y": (dims, y, {"long_name": "$y$", "units": "m"}),
535            "z": (dims, z, {"long_name": "$z$", "units": "m"}),
536            "u": (dims, u, {"long_name": "$u$", "units": "m s$^{-1}$"}),
537            "v": (dims, v, {"long_name": "$v$", "units": "m s$^{-1}$"}),
538            "w": (dims, w, {"long_name": "$w$", "units": "m s$^{-1}$"}),
539        }
540
541        # Serialize model parameters in JSON to allow saving in netCDF and loading later
542        attrs = {
543            "run_completed": self._clock_time_run_end,
544            "run_runtime": self._clock_time_run_end - self._clock_time_run_start,
545            # TODO: package version once the packaging is better...
546            "p_json": json.dumps(self.p),
547        }
548
549        ds = xr.Dataset(
550            coords=coords,
551            data_vars=data_vars,
552            attrs=attrs,
553        )
554
555        # TODO: Add extra useful coordinates: t_out for non-hist and t as Timedelta for hist
556
557        return ds

Create and return an xr.Dataset of the LPD run.

def to_xr(self):
559    def to_xr(self):
560        """Alias of `Model.to_xarray`."""

Alias of Model.to_xarray.

def plot(self, **kwargs):
562    def plot(self, **kwargs):
563        """Make a plot of the results (type based on run type).
564        `**kwargs` are passed through to the relevant plotting function.
565        * single release: `blpd.plot.trajectories`
566        * continuous release: `blpd.plot.final_pos_scatter`
567        """
568        # first check if model has been run
569        p = self.p
570        state = self.state
571        hist = self.hist
572        from . import plot
573
574        if np.all(state["up"] == 0):  # model probably hasn't been run
575            pass  # silently do nothing for now
576        else:
577            if (p["continuous_release"] is False) and hist:
578                plot.trajectories(self.to_xarray(), **kwargs)
579            else:
580                plot.final_pos_scatter(self.to_xarray(), **kwargs)

Make a plot of the results (type based on run type). **kwargs are passed through to the relevant plotting function.

INPUT_PARAM_DEFAULTS = {'release_height': 1.05, 'source_positions': [(0, 0)], 'dNp_per_dt_per_source': 2, 'canopy_height': 1.1, 'total_LAI': 2.0, 'foliage_drag_coeff': 0.2, 'ustar': 0.25, 'von_Karman_constant': 0.4, 'Kolmogorov_C0': 5.5, 'dt': 0.25, 't_tot': 100.0, 'dt_out': 1.0, 'continuous_release': True, 'use_numba': True, 'chemistry_on': False, 'fv_0': {}, 'n_air_cm3': 2.62e+19, 'oxidants_ppbv': {'O3': 40.0, 'OH': 0.0001, 'NO3': 1e-05}, 'MW_c1': 0.28, 'MW_c2': 0.37, 'MW_c3': 15.1, 'MW_gam1': 2.4, 'MW_gam2': 1.9, 'MW_gam3': 1.25, 'MW_alpha': 0.05, 'MW_A2': 0.6}

Input parameter defaults.

def calc_MW_derived_params(p):
 75def calc_MW_derived_params(p):
 76    r"""Calculate Massman and Weil (1999) parameters
 77    from the base wind profile parameters
 78    $c_i$, $\gamma_i$, $\alpha$ ($i = 1\colon3$)
 79    and other model input parameters.
 80
 81    References
 82    ----------
 83    * [Massman and Weil (1999)](https://doi.org/10.1023/A:1001810204560) [MW]
 84    """
 85    cd = p["foliage_drag_coeff"]
 86    ustar = p["ustar"]
 87    LAI = p["total_LAI"]
 88    h = p["canopy_height"]
 89    kconstant = p["von_Karman_constant"]
 90
 91    c1 = p["MW_c1"]
 92    c2 = p["MW_c2"]
 93    c3 = p["MW_c3"]
 94    gam1 = p["MW_gam1"]
 95    gam2 = p["MW_gam2"]
 96    gam3 = p["MW_gam3"]
 97    alpha = p["MW_alpha"]
 98
 99    # derived params
100    nu1 = (gam1**2 + gam2**2 + gam3**2) ** (-0.5)  # MW p. 86
101    nu3 = (gam1**2 + gam2**2 + gam3**2) ** (1.5)
102    nu2 = nu3 / 6 - gam3**2 / (2 * nu1)
103    # Lam2 = 7/(3*alpha**2*nu1*nu3) + [1/3 - gam3**2*nu1**2]/(3*alpha**2*nu1*nu2)  # the first Lambda^2
104    Lam2 = 3 * nu1**2 / alpha**2  # the simplified Lambda^2 expr; MW p. 87
105    Lam = math.sqrt(Lam2)
106    uh = ustar / (c1 - c2 * math.exp(-c3 * cd * LAI))  # u(h); MW Eq. 5
107    n = cd * LAI / (2 * ustar**2 / uh**2)  # MW Eq. 4, definitely here "n" not "nu"
108    B1 = -(9 * ustar / uh) / (2 * alpha * nu1 * (9 / 4 - Lam**2 * ustar**4 / uh**4))
109
110    d = h * (1 - (1 / (2 * n)) * (1 - math.exp(-2 * n)))  # displacement height
111
112    z0 = (h - d) * math.exp(-kconstant * uh / ustar)  # roughness length
113
114    # Calculate dissipation at canopy top to choose matching approach (Massman and Weil)
115    epsilon_ah = (ustar**3) / (kconstant * (h - d))
116    sig_eh = ustar * (nu3) ** (1 / 3)  # not used elsewhere
117    epsilon_ch = sig_eh**3 * (cd * LAI / h) / (nu3 * alpha)
118    if epsilon_ah >= epsilon_ch:
119        epflag = True
120    else:  # eps_a(h) < eps_c(h)  => usually indicates a relatively dense canopy
121        epflag = False
122
123    r = {
124        "MW_nu1": nu1,
125        "MW_nu2": nu2,
126        "MW_nu3": nu3,
127        "MW_Lam": Lam,
128        "MW_n": n,  # should be eta? (think not)
129        "MW_B1": B1,
130        "U_h": uh,  # mean wind speed at canopy height U(h)
131        "displacement_height": d,
132        "roughness_length": z0,
133        "MW_epsilon_a_h": epsilon_ah,  # above-canopy TKE dissipation rate at h
134        "MW_epsilon_c_h": epsilon_ch,  # in-canopy " "
135        "MW_epsilon_ah_gt_ch": epflag,  # name needs to be more descriptive
136    }
137
138    return r

Calculate Massman and Weil (1999) parameters from the base wind profile parameters $c_i$, $\gamma_i$, $\alpha$ ($i = 1\colon3$) and other model input parameters.

References
def compare_params(p, p0=None, input_params_only=False, print_message=True):
141def compare_params(p, p0=None, input_params_only=False, print_message=True):
142    """Compare parameters dict `p` to reference `p0`.
143    `p0` is assumed to be the default parameters dict if not provided.
144    Use `input_params_only=True` to only see those differences in the message.
145    """
146    if p0 is None:
147        p0 = Model().p
148        p0_name = "default"
149    else:
150        p0_name = "reference"
151
152    same = True
153    if any(p[k] != p0[k] for k in p0):
154        same = False
155
156        if input_params_only:
157            p0 = {k: p0[k] for k in p0 if k in INPUT_PARAM_DEFAULTS}
158
159        if print_message:
160            t = f"parameter: {p0_name} --> current"
161            print(t)
162            print("-" * len(t))
163        for k, v0 in sorted(p0.items(), key=lambda x: x[0].lower()):  # don't separate uppercase
164            v = p[k]
165            if v0 != v:
166                if print_message:
167                    print(f"'{k}': {v0} --> {v}")
168
169    if same:
170        if print_message:
171            print(f"all params same as {p0_name}")
172
173    return same

Compare parameters dict p to reference p0. p0 is assumed to be the default parameters dict if not provided. Use input_params_only=True to only see those differences in the message.