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)
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.
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 (dict):
User-supplied input parameters (to update the defaults (
INPUT_PARAM_DEFAULTS
)).Model.update_p
can also be used to update parameters after creating theModel
instance.
Model parameters dict. This includes both input and derived
parameters and so should not be modified directly
(instead use Model.update_p
).
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).
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
.
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.
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.
- single release:
blpd.plot.trajectories
- continuous release:
blpd.plot.final_pos_scatter
Input parameter defaults.
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
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.