blpd.lpd
Lagrangian stochastic particle dispersion. Using Numba, particles are integrated in parallel.
Although it is possible to use these functions directly,
the intention is for blpd.model.Model
to be used.
1""" 2Lagrangian stochastic particle dispersion. 3Using [Numba](https://numba.pydata.org/), particles are integrated in parallel. 4 5Although it is possible to use these functions directly, 6the intention is for `blpd.model.Model` to be used. 7""" 8 9import numpy as np 10from numba import njit 11from numba import prange 12 13 14@njit 15def _calc_fd_params_above_canopy(x, p): 16 """Calculate the dispersion parameters above the canopy.""" 17 z = x[2] 18 19 ustar = p["ustar"] 20 kconstant = p["von_Karman_constant"] 21 d = p["displacement_height"] 22 23 z0 = p["roughness_length"] 24 gam1 = p["MW_gam1"] 25 gam2 = p["MW_gam2"] 26 gam3 = p["MW_gam3"] 27 28 umean = (ustar / kconstant) * np.log((z - d) / z0) 29 dumeandz = (ustar / (kconstant)) * (1 / (z - d)) 30 31 tau11 = (ustar * gam1) ** 2 32 tau22 = (ustar * gam2) ** 2 33 tau33 = (ustar * gam3) ** 2 34 tau13 = -(ustar**2) 35 36 dtau11dz = 0 37 dtau22dz = 0 38 dtau33dz = 0 39 dtau13dz = 0 40 41 lam11, lam22, lam33, lam13 = _calc_Rodean_lambdas(tau11, tau22, tau33, tau13) 42 43 # Dissipation - above canopy (log wind profile) 44 # ref: e.g., MW Eq. 12 45 epsilon = (ustar**3) / (kconstant * (z - d)) 46 47 return ( 48 umean, 49 dumeandz, 50 dtau11dz, 51 dtau22dz, 52 dtau33dz, 53 dtau13dz, 54 lam11, 55 lam22, 56 lam33, 57 lam13, 58 epsilon, 59 ) 60 61 62@njit 63def _calc_fd_params_in_canopy(x, p): 64 """Calculate the dispersion parameters within the canopy.""" 65 z = x[2] 66 67 ustar = p["ustar"] # at h? 68 kconstant = p["von_Karman_constant"] 69 d = p["displacement_height"] # for log wind profile; d < h 70 h = p["canopy_height"] 71 uh = p["U_h"] # wind speed at canopy height 72 cd = p["foliage_drag_coeff"] 73 LAI = p["total_LAI"] 74 75 # z0 = p['roughness_length'] 76 gam1 = p["MW_gam1"] 77 gam2 = p["MW_gam2"] 78 gam3 = p["MW_gam3"] 79 80 nu1 = p["MW_nu1"] 81 # nu2 = p['MW_nu2'] # unused? 82 nu3 = p["MW_nu3"] 83 Lam = p["MW_Lam"] 84 n = p["MW_n"] 85 B1 = p["MW_B1"] 86 alpha = p["MW_alpha"] 87 88 epflag = p["MW_epsilon_ah_gt_ch"] 89 epsilon_ah = p["MW_epsilon_a_h"] 90 epsilon_ch = p["MW_epsilon_c_h"] 91 92 # zeta(z): "cumulative leaf drag area per unit planform area" (MW p. 82) (ΞΆ) 93 # The code we started from replace terms zeta(z)/zeta(h) by z/h. 94 # This assumes a vertically homogeneous LAI dist (LAD const with z in canopy) 95 # that accumulates from z=0, not z=h_c like LAI. 96 # zeta(h) is replaced by cd*LAI 97 98 umean = uh * np.exp(-n * (1 - (z / h))) 99 dumeandz = uh * (n / h) * np.exp(-n * (1 - (z / h))) 100 101 zet_h = cd * LAI # zeta(h) total leaf drag area 102 103 # ref: MW eqn. 10, p. 87 104 sig_e = ustar * ( 105 nu3 * np.exp(-Lam * zet_h * (1 - z / h)) 106 + B1 * (np.exp(-3 * n * (1 - z / h)) - np.exp(-Lam * zet_h * (1 - z / h))) 107 ) ** (1.0 / 3) 108 109 # I can't find a source for this eqn 110 dsig_e2dz = ( 111 (2 / 3) 112 * ustar**2 113 * ( 114 ( 115 nu3 * np.exp(-Lam * zet_h * (1 - z / h)) 116 + B1 * (np.exp(-3 * n * (1 - z / h)) - np.exp(-Lam * zet_h * (1 - z / h))) 117 ) 118 ** (-1.0 / 3) 119 ) 120 * ( 121 (nu3 * Lam * zet_h / h) * np.exp(-Lam * zet_h * (1 - z / h)) 122 + B1 123 * ( 124 (3 * n / h) * np.exp(-3 * n * (1 - z / h)) 125 - (Lam * zet_h / h) * np.exp(-Lam * zet_h * (1 - z / h)) 126 ) 127 ) 128 ) 129 130 # From MW eq. 11, in the canopy: 131 # gam_i * nu_1 * sig_e = sig_i 132 # and above the canopy: 133 # sig_i/u_star = gam_i (p. 86) 134 135 tau11 = (gam1 * nu1 * sig_e) ** 2 136 dtau11dz = ((gam1 * nu1) ** 2) * dsig_e2dz 137 138 tau22 = (gam2 * nu1 * sig_e) ** 2 139 dtau22dz = ((gam2 * nu1) ** 2) * dsig_e2dz 140 141 tau33 = (gam3 * nu1 * sig_e) ** 2 142 dtau33dz = ((gam3 * nu1) ** 2) * dsig_e2dz 143 144 tau13 = -(ustar**2) * np.exp(-2 * n * (1 - (z / h))) 145 dtau13dz = -(ustar**2) * (2 * n / h) * np.exp(-2 * n * (1 - (z / h))) 146 147 # Dissipation 148 # ref: MW p. 88 149 if epflag: 150 epsilon = (sig_e**3) * zet_h / (h * nu3 * alpha) * (epsilon_ah / epsilon_ch) 151 else: 152 if z <= d: # 0 < z <= d 153 epsilon = (sig_e**3) * zet_h / (h * nu3 * alpha) 154 else: # d < z <= h_c 155 scale1 = zet_h * sig_e**3 / (nu3 * alpha * ustar**3) 156 scale2 = h / (kconstant * (z - d)) 157 # ^ this is a potential source of div by 0 (if z v close to d) 158 epsilon = (ustar**3 / h) * min(scale1, scale2) 159 160 lam11, lam22, lam33, lam13 = _calc_Rodean_lambdas(tau11, tau22, tau33, tau13) 161 162 return ( 163 umean, 164 dumeandz, 165 dtau11dz, 166 dtau22dz, 167 dtau33dz, 168 dtau13dz, 169 lam11, 170 lam22, 171 lam33, 172 lam13, 173 epsilon, 174 ) 175 176 177@njit 178def _calc_Rodean_lambdas(tau11, tau22, tau33, tau13): 179 # ref: Pratt thesis Eq. 2.14, p. 15 180 lam11 = 1.0 / (tau11 - ((tau13**2) / tau33)) 181 lam22 = 1.0 / tau22 182 lam33 = 1.0 / (tau33 - ((tau13**2) / tau11)) 183 lam13 = 1.0 / (tau13 - ((tau11 * tau33) / tau13)) 184 185 return lam11, lam22, lam33, lam13 186 187 188@njit 189def calc_xtend(x, u, p): 190 r"""Calculate the position tendencies 191 (i.e., the velocity components $u_i + \delta u_i$) 192 for one particle with position vector `x` and velocity vector `u`. 193 194 `p` (parameters) must be a Numba typed dict (:class:`numba.typed.Dict`). 195 """ 196 z = x[2] # x, y, z 197 u1, u2, u3 = u # u, v, w 198 199 h_c = p["canopy_height"] 200 C0 = p["Kolmogorov_C0"] # a Kolmogorov constant (3--10) 201 dt = p["dt"] 202 203 # Calculate fd (fluid dynamics) params 204 if z >= h_c: 205 ( 206 U1, 207 dU1dx3, 208 dtau11dx3, 209 dtau22dx3, 210 dtau33dx3, 211 dtau13dx3, 212 lam11, 213 lam22, 214 lam33, 215 lam13, 216 eps, 217 ) = _calc_fd_params_above_canopy(x, p) 218 else: 219 ( 220 U1, 221 dU1dx3, 222 dtau11dx3, 223 dtau22dx3, 224 dtau33dx3, 225 dtau13dx3, 226 lam11, 227 lam22, 228 lam33, 229 lam13, 230 eps, 231 ) = _calc_fd_params_in_canopy(x, p) 232 233 # Calculate new positions 234 # ref: Pratt thesis Eqs. 2.12-13, p. 15 235 # dW_j is an incremental Wiener process with a mean of 0, and a variance of dt 236 # simulated as sqrt(dt)*N(0,1) (p. 12) 237 238 sqrt_C0eps = np.sqrt(C0 * eps) 239 sqrt_dt = np.sqrt(dt) 240 241 # TODO: if wanted to be able to use other sorts of integrators, 242 # would have to freeze the random seed or something? 243 # (otherwise the tends will be different every time) 244 # or separate the random part from the rest of the tend calculation. 245 # The dist to draw from could be an input. 246 247 # x-1 component 248 randn = np.random.standard_normal() # different for each component 249 dW1 = sqrt_dt * randn 250 du1 = ( 251 (-C0 * eps / 2 * (lam11 * (u1 - U1) + lam13 * u3) + dU1dx3 * u3 + dtau13dx3 / 2) * dt 252 + ( 253 dtau11dx3 * (lam11 * (u1 - U1) + lam13 * u3) 254 + dtau13dx3 * (lam13 * (u1 - U1) + lam33 * u3) 255 ) 256 * (u3 / 2 * dt) 257 + sqrt_C0eps * dW1 258 ) 259 260 # x-2 component 261 randn = np.random.standard_normal() 262 dW2 = sqrt_dt * randn 263 du2 = (-C0 * eps / 2 * lam22 * u2 + dtau22dx3 * lam22 * u2 * u3 / 2) * dt + sqrt_C0eps * dW2 264 265 # x-3 component 266 randn = np.random.standard_normal() 267 dW3 = sqrt_dt * randn 268 du3 = ( 269 (-C0 * eps / 2 * (lam13 * (u1 - U1) + lam33 * u3) + dtau33dx3 / 2) * dt 270 + ( 271 dtau13dx3 * (lam11 * (u1 - U1) + lam13 * u3) 272 + dtau33dx3 * (lam13 * (u1 - U1) + lam33 * u3) 273 ) 274 * (u3 / 2 * dt) 275 + sqrt_C0eps * dW3 276 ) 277 278 # Check for too-high values 279 if np.any(np.abs(np.array([du1, du2, du3])) > 50): 280 print("one du is too high:", du1, du2, du3) 281 print("umean, dumeandz, epsilon", U1, dU1dx3, eps) 282 if z >= h_c: 283 print(" z >= h_c") 284 else: 285 print(" z < h_c, z =", z) 286 print("resetting new ws to 0") 287 du1 = 0.0 288 du2 = 0.0 289 du3 = 0.0 290 u1 = 0.0 291 u2 = 0.0 292 u3 = 0.0 293 294 return [u1 + du1, u2 + du2, u3 + du3] 295 296 297@njit 298def _integrate_particle_one_timestep(x, u, p): 299 """Take the state for one particle and integrate.""" 300 dt = p["dt"] 301 302 dxdt = calc_xtend(x, u, p) # u, v, w 303 304 # Adjustments for undesired new z position 305 # (below z = 0 or above PBL inversion) 306 # TODO: original code noted that this needs work 307 # This current method seems to correspond to completely elastic collision, 308 # but maybe inelastic or some probability of deposition could be another option. 309 z = x[2] # before integration 310 znew = z + dxdt[2] * dt 311 if znew <= 0.1: 312 dxdt = [dxdt[0], dxdt[1], -dxdt[2]] 313 xnew = [x[0] + dxdt[0] * dt, x[1] + dxdt[1] * dt, 0.1] 314 315 elif znew >= 800.0: # reflect at z_i too 316 dxdt = [dxdt[0], dxdt[1], -dxdt[2]] 317 xnew = [x[0] + dxdt[0] * dt, x[1] + dxdt[1] * dt, 800.0] 318 319 else: 320 xnew = [xi + dxidt * dt for (xi, dxidt) in zip(x, dxdt)] 321 322 # The new position (x) tendencies are the (new) velocity components 323 unew = dxdt 324 325 return xnew, unew 326 327 328# @njit 329@njit(parallel=True) 330def integrate_particles_one_timestep(state, p): 331 """Integrate all particles one time step, modifying the current `state` (in place). 332 333 `state` and `p` (parameters) must be Numba typed dicts (:class:`numba.typed.Dict`) 334 in order for `@njit` to work if Numba is not disabled. 335 """ 336 Np_k = int(state["Np_k"][0]) 337 xp = state["xp"] 338 yp = state["yp"] 339 zp = state["zp"] 340 up = state["up"] 341 vp = state["vp"] 342 wp = state["wp"] 343 344 # for i in range(Np_k): 345 for i in prange(Np_k): # pylint: disable=not-an-iterable 346 # State for particle i: position and (wind) velocity 347 x = [xp[i], yp[i], zp[i]] # position of particle i at current time step 348 u = [up[i], vp[i], wp[i]] # local wind speed for particle 349 350 # Calculate new state 351 x, u = _integrate_particle_one_timestep(x, u, p) 352 353 # Update state arrays in place 354 xp[i] = x[0] 355 yp[i] = x[1] 356 zp[i] = x[2] 357 up[i] = u[0] 358 vp[i] = u[1] 359 wp[i] = u[2] 360 361 # Note that we are not really time-integrating the wind speed. 362 # We are making assumptions about the distribution of perturbations 363 # about a mean x-direction wind! 364 365 # del x, u 366 # ^ seems like Numba might require this? (gave errors otherwise) 367 # Now, using numba v0.49.1 this raises error: 368 # `CompilerError: Illegal IR, del found at: del pos` 369 # If trying to use sufficiently older versions of numba, may need to put it back. 370 # TODO: maybe change numba version spec in setup.cfg to >= 0.49
@njit
def
calc_xtend(x, u, p):
189@njit 190def calc_xtend(x, u, p): 191 r"""Calculate the position tendencies 192 (i.e., the velocity components $u_i + \delta u_i$) 193 for one particle with position vector `x` and velocity vector `u`. 194 195 `p` (parameters) must be a Numba typed dict (:class:`numba.typed.Dict`). 196 """ 197 z = x[2] # x, y, z 198 u1, u2, u3 = u # u, v, w 199 200 h_c = p["canopy_height"] 201 C0 = p["Kolmogorov_C0"] # a Kolmogorov constant (3--10) 202 dt = p["dt"] 203 204 # Calculate fd (fluid dynamics) params 205 if z >= h_c: 206 ( 207 U1, 208 dU1dx3, 209 dtau11dx3, 210 dtau22dx3, 211 dtau33dx3, 212 dtau13dx3, 213 lam11, 214 lam22, 215 lam33, 216 lam13, 217 eps, 218 ) = _calc_fd_params_above_canopy(x, p) 219 else: 220 ( 221 U1, 222 dU1dx3, 223 dtau11dx3, 224 dtau22dx3, 225 dtau33dx3, 226 dtau13dx3, 227 lam11, 228 lam22, 229 lam33, 230 lam13, 231 eps, 232 ) = _calc_fd_params_in_canopy(x, p) 233 234 # Calculate new positions 235 # ref: Pratt thesis Eqs. 2.12-13, p. 15 236 # dW_j is an incremental Wiener process with a mean of 0, and a variance of dt 237 # simulated as sqrt(dt)*N(0,1) (p. 12) 238 239 sqrt_C0eps = np.sqrt(C0 * eps) 240 sqrt_dt = np.sqrt(dt) 241 242 # TODO: if wanted to be able to use other sorts of integrators, 243 # would have to freeze the random seed or something? 244 # (otherwise the tends will be different every time) 245 # or separate the random part from the rest of the tend calculation. 246 # The dist to draw from could be an input. 247 248 # x-1 component 249 randn = np.random.standard_normal() # different for each component 250 dW1 = sqrt_dt * randn 251 du1 = ( 252 (-C0 * eps / 2 * (lam11 * (u1 - U1) + lam13 * u3) + dU1dx3 * u3 + dtau13dx3 / 2) * dt 253 + ( 254 dtau11dx3 * (lam11 * (u1 - U1) + lam13 * u3) 255 + dtau13dx3 * (lam13 * (u1 - U1) + lam33 * u3) 256 ) 257 * (u3 / 2 * dt) 258 + sqrt_C0eps * dW1 259 ) 260 261 # x-2 component 262 randn = np.random.standard_normal() 263 dW2 = sqrt_dt * randn 264 du2 = (-C0 * eps / 2 * lam22 * u2 + dtau22dx3 * lam22 * u2 * u3 / 2) * dt + sqrt_C0eps * dW2 265 266 # x-3 component 267 randn = np.random.standard_normal() 268 dW3 = sqrt_dt * randn 269 du3 = ( 270 (-C0 * eps / 2 * (lam13 * (u1 - U1) + lam33 * u3) + dtau33dx3 / 2) * dt 271 + ( 272 dtau13dx3 * (lam11 * (u1 - U1) + lam13 * u3) 273 + dtau33dx3 * (lam13 * (u1 - U1) + lam33 * u3) 274 ) 275 * (u3 / 2 * dt) 276 + sqrt_C0eps * dW3 277 ) 278 279 # Check for too-high values 280 if np.any(np.abs(np.array([du1, du2, du3])) > 50): 281 print("one du is too high:", du1, du2, du3) 282 print("umean, dumeandz, epsilon", U1, dU1dx3, eps) 283 if z >= h_c: 284 print(" z >= h_c") 285 else: 286 print(" z < h_c, z =", z) 287 print("resetting new ws to 0") 288 du1 = 0.0 289 du2 = 0.0 290 du3 = 0.0 291 u1 = 0.0 292 u2 = 0.0 293 u3 = 0.0 294 295 return [u1 + du1, u2 + du2, u3 + du3]
Calculate the position tendencies
(i.e., the velocity components $u_i + \delta u_i$)
for one particle with position vector x
and velocity vector u
.
p
(parameters) must be a Numba typed dict (numba.typed.Dict
).
@njit(parallel=True)
def
integrate_particles_one_timestep(state, p):
330@njit(parallel=True) 331def integrate_particles_one_timestep(state, p): 332 """Integrate all particles one time step, modifying the current `state` (in place). 333 334 `state` and `p` (parameters) must be Numba typed dicts (:class:`numba.typed.Dict`) 335 in order for `@njit` to work if Numba is not disabled. 336 """ 337 Np_k = int(state["Np_k"][0]) 338 xp = state["xp"] 339 yp = state["yp"] 340 zp = state["zp"] 341 up = state["up"] 342 vp = state["vp"] 343 wp = state["wp"] 344 345 # for i in range(Np_k): 346 for i in prange(Np_k): # pylint: disable=not-an-iterable 347 # State for particle i: position and (wind) velocity 348 x = [xp[i], yp[i], zp[i]] # position of particle i at current time step 349 u = [up[i], vp[i], wp[i]] # local wind speed for particle 350 351 # Calculate new state 352 x, u = _integrate_particle_one_timestep(x, u, p) 353 354 # Update state arrays in place 355 xp[i] = x[0] 356 yp[i] = x[1] 357 zp[i] = x[2] 358 up[i] = u[0] 359 vp[i] = u[1] 360 wp[i] = u[2] 361 362 # Note that we are not really time-integrating the wind speed. 363 # We are making assumptions about the distribution of perturbations 364 # about a mean x-direction wind! 365 366 # del x, u 367 # ^ seems like Numba might require this? (gave errors otherwise) 368 # Now, using numba v0.49.1 this raises error: 369 # `CompilerError: Illegal IR, del found at: del pos` 370 # If trying to use sufficiently older versions of numba, may need to put it back. 371 # TODO: maybe change numba version spec in setup.cfg to >= 0.49
Integrate all particles one time step, modifying the current state
(in place).
state
and p
(parameters) must be Numba typed dicts (numba.typed.Dict
)
in order for @njit
to work if Numba is not disabled.