snesbitt's picture
Initial commit — UpdraftForcing Dash app
87602e0
Raw
History Blame Contribute Delete
8.56 kB
"""Weisman-Klemp analytic sounding and 1-D parcel lift utilities."""
from __future__ import annotations
import numpy as np
from scipy.integrate import cumulative_trapezoid
# --------------------------------------------------------------------------
# Physical constants
# --------------------------------------------------------------------------
G = 9.80665 # m s⁻²
RD = 287.04 # J kg⁻¹ K⁻¹
RV = 461.5 # J kg⁻¹ K⁻¹
CP = 1005.7 # J kg⁻¹ K⁻¹
LV = 2.501e6 # J kg⁻¹
EPS = RD / RV # 0.6220
KAPPA = RD / CP # 0.2854
P0 = 1000.0 # hPa reference pressure
def _sat_vapor_pressure(T_K: np.ndarray) -> np.ndarray:
"""Bolton (1980) saturation vapor pressure (hPa) from T in Kelvin."""
T_C = T_K - 273.15
return 6.112 * np.exp(17.67 * T_C / (T_C + 243.5))
def _dewpoint_from_qv(qv: np.ndarray, p_hPa: np.ndarray) -> np.ndarray:
"""Dewpoint (K) from mixing ratio (kg/kg) and pressure (hPa)."""
e = p_hPa * qv / (EPS + qv)
e = np.maximum(e, 1e-6)
ln_term = np.log(e / 6.112)
T_C = 243.5 * ln_term / (17.67 - ln_term)
return T_C + 273.15
def _virtual_temp(T_K: np.ndarray, qv: np.ndarray) -> np.ndarray:
"""Virtual temperature (K)."""
return T_K * (1.0 + qv / EPS) / (1.0 + qv)
def _sat_mixing_ratio(T_K: np.ndarray, p_hPa: np.ndarray) -> np.ndarray:
"""Saturation mixing ratio (kg/kg)."""
es = _sat_vapor_pressure(T_K)
return EPS * es / (p_hPa - es)
def wk_sounding(
z: np.ndarray,
theta_ml: float = 300.0,
qv_ml_gkg: float = 14.0,
z_ml_m: float = 1000.0,
z_trop_m: float = 12000.0,
T_trop_K: float = 213.0,
gamma_ft: float = 1.25,
p_sfc_hPa: float = 1000.0,
) -> dict:
"""Build the Weisman-Klemp analytic sounding on height grid ``z`` (meters AGL).
Returns a dict with arrays: T_K, Td_K, theta, qv, rho, p_hPa (all shape z).
"""
z = np.asarray(z, dtype=float)
qv_ml = qv_ml_gkg * 1e-3 # convert to kg/kg
# Estimate tropopause pressure with scale-height formula (T_mean ≈ 255 K)
# so that θ_trop = T_trop * (P0/p_trop)^κ gives the right temperature.
p_trop_est = p_sfc_hPa * np.exp(-G * z_trop_m / (RD * 255.0))
theta_trop = T_trop_K * (P0 / p_trop_est) ** KAPPA
# ---- potential temperature profile ----
theta = np.empty_like(z)
N2_strat = 4e-4 # N² = 4×10⁻⁴ s⁻² above tropopause
for k, zk in enumerate(z):
if zk <= z_ml_m:
theta[k] = theta_ml
elif zk <= z_trop_m:
frac = (zk - z_ml_m) / (z_trop_m - z_ml_m)
theta[k] = theta_ml + (theta_trop - theta_ml) * frac ** gamma_ft
else:
# Strong isothermal-like stratosphere: θ increases exponentially
theta[k] = theta_trop * np.exp(N2_strat * (zk - z_trop_m) / G)
# ---- hydrostatic pressure integration ----
# Start from p_sfc; integrate dp/dz = -ρ g = -p g / (Rd Tv)
# Moisture profile
qv = np.empty_like(z)
for k, zk in enumerate(z):
if zk <= z_ml_m:
qv[k] = qv_ml
else:
# RH = 45% in free troposphere; use preliminary T to get qvs
# We will need to iterate once to get a consistent T and qv
qv[k] = qv_ml # placeholder
# Build p(z) iteratively (one pass sufficient)
p_hPa = np.empty_like(z)
p_hPa[0] = p_sfc_hPa
for k in range(1, len(z)):
dz = z[k] - z[k - 1]
# Mid-level T from θ and p (use previous p for first estimate)
T_lo = theta[k - 1] * (p_hPa[k - 1] / P0) ** KAPPA
T_hi_est = theta[k] * (p_hPa[k - 1] / P0) ** KAPPA # first guess
Tv_mid = _virtual_temp(0.5 * (T_lo + T_hi_est), 0.5 * (qv[k - 1] + qv[k]))
p_hPa[k] = p_hPa[k - 1] * np.exp(-G * dz / (RD * float(Tv_mid)))
# Recompute moisture using actual pressures (RH = 45% above BL)
for k, zk in enumerate(z):
T_k = theta[k] * (p_hPa[k] / P0) ** KAPPA
if zk <= z_ml_m:
qv[k] = qv_ml
else:
qvs = _sat_mixing_ratio(np.array([T_k]), np.array([p_hPa[k]]))[0]
RH = 0.45
qv[k] = min(RH * qvs, qv_ml) # cap at mixed-layer value
# Recompute p(z) with corrected moisture
p_hPa[0] = p_sfc_hPa
for k in range(1, len(z)):
dz = z[k] - z[k - 1]
T_lo = theta[k - 1] * (p_hPa[k - 1] / P0) ** KAPPA
T_hi_est = theta[k] * (p_hPa[k - 1] / P0) ** KAPPA
Tv_lo = _virtual_temp(np.array([T_lo]), np.array([qv[k - 1]]))[0]
Tv_hi = _virtual_temp(np.array([T_hi_est]), np.array([qv[k]]))[0]
Tv_mid = 0.5 * (Tv_lo + Tv_hi)
p_hPa[k] = p_hPa[k - 1] * np.exp(-G * dz / (RD * Tv_mid))
# Temperature and dewpoint
T_K = theta * (p_hPa / P0) ** KAPPA
Td_K = _dewpoint_from_qv(qv, p_hPa)
Td_K = np.minimum(Td_K, T_K) # Td cannot exceed T
# Density
Tv = _virtual_temp(T_K, qv)
rho = p_hPa * 100.0 / (RD * Tv) # kg m⁻³
return {
"T_K": T_K,
"Td_K": Td_K,
"theta": theta,
"qv": qv,
"rho": rho,
"p_hPa": p_hPa,
}
def _moist_lapse_rate(T_K: float, p_hPa: float) -> float:
"""Saturated adiabatic lapse rate dT/dz (K/m), negative (decreases with height)."""
rs = float(_sat_mixing_ratio(np.array([T_K]), np.array([p_hPa]))[0])
numer = G / CP * (1.0 + LV * rs / (RD * T_K))
denom = 1.0 + LV ** 2 * rs / (CP * RV * T_K ** 2)
return -numer / denom # negative
def lift_parcel(
z: np.ndarray,
T_env: np.ndarray,
qv_env: np.ndarray,
p_hPa: np.ndarray,
delta_T_K: float = 0.0,
) -> dict:
"""Lift a surface parcel with temperature excess ``delta_T_K`` above the environment.
Returns dict: T_parcel, Td_parcel, LCL_m, LFC_m, EL_m, CAPE, CIN, B (buoyancy profile).
"""
z = np.asarray(z, dtype=float)
T_env = np.asarray(T_env, dtype=float)
qv_env = np.asarray(qv_env, dtype=float)
p_hPa = np.asarray(p_hPa, dtype=float)
dz = z[1] - z[0]
N = len(z)
T_p0 = T_env[0] + delta_T_K
qv_p0 = qv_env[0] # conserve mixing ratio
# Surface θ_e (approximately conserved during moist ascent)
theta_p0 = T_p0 * (P0 / p_hPa[0]) ** KAPPA
T_parcel = np.empty(N)
T_parcel[0] = T_p0
LCL_m = None
# Dry adiabatic ascent until T_parcel == Td_parcel (LCL)
above_lcl = False
T_p = T_p0
p_p = p_hPa[0]
for k in range(1, N):
if not above_lcl:
# Dry adiabatic: conserve θ
T_p = theta_p0 * (p_hPa[k] / P0) ** KAPPA
# Dewpoint of parcel (mixing ratio conserved below LCL)
Td_p = float(_dewpoint_from_qv(np.array([qv_p0]), np.array([p_hPa[k]]))[0])
if T_p <= Td_p + 0.01 and LCL_m is None:
LCL_m = float(z[k])
above_lcl = True
if above_lcl:
# Moist adiabatic: integrate dT/dz numerically
gamma_m = _moist_lapse_rate(T_p, float(p_hPa[k]))
T_p = T_p + gamma_m * dz
T_parcel[k] = T_p
if LCL_m is None:
LCL_m = float(z[-1])
# Dewpoint of parcel: conserved below LCL, saturated above
Td_parcel = np.empty(N)
for k in range(N):
if z[k] <= LCL_m:
Td_parcel[k] = float(_dewpoint_from_qv(np.array([qv_p0]), np.array([p_hPa[k]]))[0])
else:
Td_parcel[k] = T_parcel[k] # saturated above LCL
# Virtual temperature correction
qv_parcel = np.empty(N)
for k in range(N):
if z[k] <= LCL_m:
qv_parcel[k] = qv_p0
else:
qv_parcel[k] = float(_sat_mixing_ratio(np.array([T_parcel[k]]), np.array([p_hPa[k]]))[0])
Tv_parcel = _virtual_temp(T_parcel, qv_parcel)
Tv_env = _virtual_temp(T_env, qv_env)
# Buoyancy profile B(z) = g * (Tv_p - Tv_e) / Tv_e
B = G * (Tv_parcel - Tv_env) / Tv_env
# LFC and EL: first and last level where B > 0 above LCL
LFC_m = LCL_m
EL_m = LCL_m
for k in range(N):
if z[k] >= LCL_m and B[k] > 0:
if LFC_m == LCL_m or z[k] < LFC_m:
LFC_m = float(z[k])
EL_m = float(z[k])
# CAPE and CIN
B_pos = np.where(B > 0, B, 0.0)
B_neg = np.where((B < 0) & (z < LFC_m), B, 0.0)
CAPE = float(np.trapezoid(B_pos, z))
CIN = float(np.trapezoid(B_neg, z))
return {
"T_parcel": T_parcel,
"Td_parcel": Td_parcel,
"qv_parcel": qv_parcel,
"LCL_m": LCL_m,
"LFC_m": LFC_m,
"EL_m": EL_m,
"CAPE": CAPE,
"CIN": CIN,
"B": B,
}