snesbitt's picture
Initial commit — UpdraftForcing Dash app
87602e0
Raw
History Blame Contribute Delete
5.79 kB
"""Updraft/downdraft field prescription from a 1-D parcel model."""
from __future__ import annotations
import numpy as np
from scipy.interpolate import CubicSpline
from .sounding import G, lift_parcel
def diagnose_w_profile(
z: np.ndarray,
T_env: np.ndarray,
qv_env: np.ndarray,
p_hPa: np.ndarray,
delta_T_K: float = 2.0,
) -> dict:
"""Diagnose the updraft vertical velocity profile from a 1-D parcel model.
The parcel is initialized with a surface temperature excess of ``delta_T_K``
K above the environment. Vertical velocity is computed from the cumulative
integral of buoyancy:
w²(z) = 2 · ∫₀ᶻ B(z') dz' (zero w² is floor-clipped)
Above the EL the buoyancy is negative; w decelerates to zero at the
overshoot top z_top.
Returns dict: w_z, B_z, LCL_m, LFC_m, EL_m, CAPE, CIN, z_top_m.
"""
parcel = lift_parcel(z, T_env, qv_env, p_hPa, delta_T_K)
B = parcel["B"]
EL_m = parcel["EL_m"]
# Cumulative KE: w² = 2 · ∫₀ᶻ B dz
# scipy.integrate.cumulative_trapezoid returns N-1 values; prepend 0
from scipy.integrate import cumulative_trapezoid as cumtrapz
ke2 = np.empty_like(z)
ke2[0] = 0.0
ke2[1:] = 2.0 * cumtrapz(B, z)
ke2 = np.maximum(ke2, 0.0)
w_z = np.sqrt(ke2)
# Above EL: continue the integration with negative B until w → 0
el_idx = int(np.searchsorted(z, EL_m))
w_el_sq = ke2[el_idx] if el_idx < len(z) else 0.0
z_top_m = EL_m
if el_idx < len(z) - 1:
w_sq_above = w_el_sq + 2.0 * cumtrapz(B[el_idx:], z[el_idx:], initial=0.0)
w_sq_above = np.maximum(w_sq_above, 0.0)
zero_cross = np.where(w_sq_above <= 0.0)[0]
if len(zero_cross) > 0:
first_zero = el_idx + zero_cross[0]
z_top_m = float(z[first_zero])
w_z[first_zero:] = 0.0
else:
# Overshoot extends beyond domain top; continue deceleration profile
w_z[el_idx:] = np.sqrt(w_sq_above)
z_top_m = float(z[-1])
return {
"w_z": w_z,
"B_z": B,
"LCL_m": parcel["LCL_m"],
"LFC_m": parcel["LFC_m"],
"EL_m": EL_m,
"CAPE": parcel["CAPE"],
"CIN": parcel["CIN"],
"z_top_m": z_top_m,
"T_parcel": parcel["T_parcel"],
}
def tophat_profile(r: np.ndarray, r0: float, rolloff_frac: float = 0.10) -> np.ndarray:
"""Radial shape: uniform core, cosine taper in outer ``rolloff_frac`` fraction."""
r = np.asarray(r, dtype=float)
r_inner = r0 * (1.0 - rolloff_frac)
out = np.where(r <= r_inner, 1.0, 0.0)
taper_mask = (r > r_inner) & (r <= r0)
out = np.where(
taper_mask,
0.5 * (1.0 + np.cos(np.pi * (r - r_inner) / (r0 - r_inner))),
out,
)
return out
def cosine_profile(r: np.ndarray, r0: float) -> np.ndarray:
"""Radial shape: cosine bell w(r) = cos(π r / 2r₀) for r < r₀."""
r = np.asarray(r, dtype=float)
return np.where(r < r0, np.cos(0.5 * np.pi * r / r0), 0.0)
def build_updraft_fields(
X: np.ndarray,
Y: np.ndarray,
z: np.ndarray,
r0: float,
shape: str,
w_z: np.ndarray,
zeta_cpts: np.ndarray,
zeta_z_km: np.ndarray,
env_u: np.ndarray,
env_v: np.ndarray,
theta_env: np.ndarray,
theta_parcel: np.ndarray,
) -> dict:
"""Construct 3-D updraft fields on the (Nx, Ny, Nz) grid.
Parameters
----------
X, Y : (Nx, Ny) horizontal coordinate arrays (meters)
z : (Nz,) height array (meters)
r0 : updraft radius (m)
shape : 'tophat' or 'cosine'
w_z : (Nz,) diagnosed vertical velocity profile (m/s)
zeta_cpts : (Ncpts,) prescribed vorticity values (s⁻¹) representing
accumulated rotation from tilting/stretching in a mature storm
zeta_z_km : (Ncpts,) heights of control points (km)
env_u, env_v : (Nz,) environmental wind components (m/s)
theta_env, theta_parcel : (Nz,) potential temperature arrays (K)
Returns dict of 3-D arrays: w3d, u3d, v3d, zeta3d, theta_prime3d.
"""
Nx, Ny = X.shape
Nz = len(z)
# Radial distance from domain center
xc = X.mean()
yc = Y.mean()
R = np.sqrt((X - xc) ** 2 + (Y - yc) ** 2) # (Nx, Ny)
# Radial shape function
if shape == "tophat":
shape_fn = tophat_profile(R, r0)
else:
shape_fn = cosine_profile(R, r0)
# Interpolate prescribed vorticity control points to full z grid
if len(zeta_cpts) >= 2:
z_cpts_m = np.asarray(zeta_z_km) * 1000.0
cs = CubicSpline(z_cpts_m, np.asarray(zeta_cpts), extrapolate=True)
zeta_z = cs(z)
else:
zeta_z = np.zeros(Nz)
# Build 3-D fields
w3d = np.empty((Nx, Ny, Nz))
u3d = np.empty((Nx, Ny, Nz))
v3d = np.empty((Nx, Ny, Nz))
zeta3d = np.zeros((Nx, Ny, Nz))
theta_prime3d = np.zeros((Nx, Ny, Nz))
# Azimuthal angle from center
phi = np.arctan2(Y - yc, X - xc) # (Nx, Ny)
for k in range(Nz):
w3d[:, :, k] = shape_fn * w_z[k]
# Solid-body rotation: v_θ = ζ(z) * r / 2
v_theta = zeta_z[k] * R / 2.0
u_core = -v_theta * np.sin(phi)
v_core = v_theta * np.cos(phi)
# Apply radial taper to the rotational wind too
u3d[:, :, k] = env_u[k] + shape_fn * u_core
v3d[:, :, k] = env_v[k] + shape_fn * v_core
# Vertical vorticity within core: ζ_z ≈ shape_fn * zeta_z[k]
zeta3d[:, :, k] = shape_fn * zeta_z[k]
# Potential temperature perturbation within core
theta_prime3d[:, :, k] = shape_fn * (theta_parcel[k] - theta_env[k])
return {
"w3d": w3d,
"u3d": u3d,
"v3d": v3d,
"zeta3d": zeta3d,
"theta_prime3d": theta_prime3d,
}