diff --git a/.clang-format b/.clang-format index 19329cb..1f834af 100644 --- a/.clang-format +++ b/.clang-format @@ -31,7 +31,7 @@ AlignConsecutiveMacros: AlignEscapedNewlines: Left AlignOperands: DontAlign AlignTrailingComments: - Kind: Never # MARK: Adjustable + Kind: Always # MARK: Adjustable OverEmptyLines: 0 AllowAllArgumentsOnNextLine: true AllowAllParametersOfDeclarationOnNextLine: true @@ -196,7 +196,7 @@ RemoveBracesLLVM: false RemoveSemicolon: false RequiresClausePosition: OwnLine RequiresExpressionIndentation: OuterScope -SeparateDefinitionBlocks: Leave +SeparateDefinitionBlocks: Always ShortNamespaceLines: 1 SortIncludes: Never # MARK: Adjustable SortJavaStaticImport: Before diff --git a/Dockerfile b/Dockerfile index 8fefc26..c6bf910 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,5 @@ # syntax=docker/dockerfile:1 -# ................................................................................................. -# FROM python:3.10.14 AS py310 -# USER root -# WORKDIR / - -# ENV PATH="/opt/venv/bin:$PATH" -# RUN python3 -m venv /opt/venv -# COPY tests/requirements.txt . -# RUN pip install --no-cache-dir -r requirements.txt - -# COPY . . -# ENV NZTHERMO_BUILD_COVERAGE 1 -# RUN pip install --no-cache-dir --no-deps --upgrade --target src/ . - -# USER 1001 - # ................................................................................................. FROM python:3.11.9 AS py311 USER root @@ -27,7 +11,7 @@ COPY tests/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . -ENV NZTHERMO_BUILD_COVERAGE 1 +ENV NZTHERMO_BUILD_COVERAGE=1 RUN pip install --no-cache-dir --no-deps --upgrade --target src/ . \ && pytest tests @@ -35,7 +19,6 @@ RUN pip install --no-cache-dir --no-deps --upgrade --target src/ . \ RUN pip install --no-cache-dir Pint==0.24 numpy==2.0.0 --upgrade \ && pytest tests - USER 1001 # ................................................................................................. @@ -49,7 +32,7 @@ COPY tests/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . -ENV NZTHERMO_BUILD_COVERAGE 1 +ENV NZTHERMO_BUILD_COVERAGE=1 RUN pip install --no-cache-dir --no-deps --upgrade --target src/ . \ && pytest tests @@ -57,5 +40,4 @@ RUN pip install --no-cache-dir --no-deps --upgrade --target src/ . \ RUN pip install --no-cache-dir Pint==0.24 numpy==2.0.0 --upgrade \ && pytest tests - USER 1001 diff --git a/pyproject.toml b/pyproject.toml index 8fb4b02..b41e251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ testpaths = ["tests/"] [tool.pytest.ini_options] markers = [ + "regression", + "broadcasting", "self: this is a flag to target a specifc test", "ccl: test functions for convective condensation level", "moist_lapse", @@ -44,14 +46,16 @@ markers = [ "cape_el_top", "cape_el_bottom", "cape_lfc", + "most_unstable_cape_cin", + "most_unstable_parcel", "cape_lfc_top", "cape_lfc_bottom", "mu_cape", "parcel_profile", - "regression", - "broadcasting", + "mixed_layer", + "mixed_parcel", + "mixed_layer_cape_cin", ] - pythonpath = ["src"] [tool.coverage.run] @@ -116,3 +120,4 @@ venvPath = "." venv = ".venv" pythonVersion = "3.12" reportOverlappingOverload = "none" +reportMissingModuleSource = "none" diff --git a/src/include/libthermo.hpp b/src/include/libthermo.hpp index 792a1a6..f312c54 100644 --- a/src/include/libthermo.hpp +++ b/src/include/libthermo.hpp @@ -11,30 +11,24 @@ namespace libthermo { #define DEFAULT_STEP 1000.0 // `(Pa)` - default step for moist_lapse -#define DEFAULT_EPS 0.1 // default epsilon for lcl -#define DEFAULT_ITERS 5 // default number of iterations for lcl +#define DEFAULT_EPS 0.1 // default epsilon for lcl +#define DEFAULT_ITERS 5 // default number of iterations for lcl -/* ........................................{ const }........................................... */ - -static constexpr double T0 = 273.15; /* `(J/kg*K)` - freezing point in kelvin */ -static constexpr double E0 = 611.21; // `(Pa)` - vapor pressure at T0 +static constexpr double T0 = 273.15; /* `(J/kg*K)` - freezing point in kelvin */ +static constexpr double E0 = 611.21; // `(Pa)` - vapor pressure at T0 static constexpr double Cp = 1004.6662184201462; // `(J/kg*K)` - specific heat of dry air static constexpr double Rd = 287.04749097718457; // `(J/kg*K)` - gas constant for dry air static constexpr double Rv = 461.52311572606084; // `(J/kg*K)` - gas constant for water vapor -static constexpr double Lv = 2501000.0; // `(J/kg)` - latent heat of vaporization -static constexpr double P0 = 100000.0; // `(Pa)` - standard pressure at sea level -static constexpr double Mw = 18.01528; // `(g/mol)` - molecular weight of water -static constexpr double Md = 28.96546; // `(g/mol)` - molecular weight of dry air -static constexpr double epsilon = Mw / Md; // `Mw / Md` - molecular weight ratio -static constexpr double kappa = Rd / Cp; // `Rd / Cp` - ratio of gas constants - -/* ........................................{ helper }........................................... */ +static constexpr double Lv = 2501000.0; // `(J/kg)` - latent heat of vaporization +static constexpr double P0 = 100000.0; // `(Pa)` - standard pressure at sea level +static constexpr double Mw = 18.01528; // `(g/mol)` - molecular weight of water +static constexpr double Md = 28.96546; // `(g/mol)` - molecular weight of dry air +static constexpr double epsilon = Mw / Md; // `Mw / Md` - molecular weight ratio +static constexpr double kappa = Rd / Cp; // `Rd / Cp` - ratio of gas constants template constexpr size_t index_pressure(const T x[], const T value, const size_t size) noexcept; -/* ........................................{ struct }........................................... */ - template constexpr T mixing_ratio(const T partial_press, const T total_press) noexcept; @@ -72,15 +66,11 @@ constexpr T wet_bulb_potential_temperature( const T pressure, const T temperature, const T dewpoint ) noexcept; -/* ........................................{ ode }........................................... */ - template constexpr T moist_lapse( const T pressure, const T next_pressure, const T temperature, const T step = DEFAULT_STEP ) noexcept; -/* ........................................{ lcl }........................................... */ - template constexpr T lcl_pressure( const T pressure, @@ -92,15 +82,11 @@ constexpr T lcl_pressure( template class lcl { - // TODO: a base class can be made to avoid code duplication for any struct that - // may be used to produce pressure and temperature fields. Then just overload the - // constructor with the function arguments. public: T pressure, temperature; constexpr lcl() noexcept = default; constexpr lcl(const T pressure, const T temperature) noexcept : pressure(pressure), temperature(temperature){}; - constexpr lcl( const T pressure, const T temperature, @@ -108,7 +94,6 @@ class lcl { const T eps = DEFAULT_EPS, const size_t max_iters = DEFAULT_ITERS ) noexcept; - constexpr T wet_bulb_temperature(const T pressure, const T step = DEFAULT_STEP) noexcept; constexpr size_t index(const T pressure[], const size_t size) noexcept; }; @@ -123,11 +108,7 @@ constexpr T wet_bulb_temperature( const size_t max_iters = DEFAULT_ITERS ) noexcept; -/* ........................................{ sharp }........................................... */ - template constexpr T wobus(T temperature); -/* ........................................{ ecape }........................................... */ - } // namespace libthermo diff --git a/src/include/wind.hpp b/src/include/wind.hpp index 615e760..4f2f103 100644 --- a/src/include/wind.hpp +++ b/src/include/wind.hpp @@ -14,7 +14,6 @@ class wind_components; template constexpr T wind_direction(const T u, const T v) noexcept; - template constexpr T wind_magnitude(const T u, const T v) noexcept; @@ -23,7 +22,7 @@ class wind_components { public: T u, v; constexpr wind_components() noexcept = default; - constexpr wind_components(const T u, const T v) noexcept : u(u), v(v) {} + constexpr wind_components(const T u, const T v) noexcept : u(u), v(v){}; constexpr explicit wind_components(const wind_vector& uv) noexcept; }; @@ -32,7 +31,7 @@ class wind_vector { public: T direction, magnitude; constexpr wind_vector() noexcept = default; - constexpr wind_vector(const T d, const T m) noexcept : direction(d), magnitude(m) {} + constexpr wind_vector(const T d, const T m) noexcept : direction(d), magnitude(m){}; constexpr explicit wind_vector(const wind_components& uv) noexcept; }; diff --git a/src/lib/functional.cpp b/src/lib/functional.cpp index e7b1e45..f26c7c4 100644 --- a/src/lib/functional.cpp +++ b/src/lib/functional.cpp @@ -59,6 +59,7 @@ constexpr T linear_interpolate( ) noexcept { return y0 + (x - x0) * (y1 - y0) / LIMIT_ZERO(x1 - x0); } + /** * @author Kelton Halbert - NWS Storm Prediction Center/OU-CIWRO * @@ -116,6 +117,7 @@ size_t search_sorted(const T x[], const T value, const size_t size, const bool i return upper_bound(x, size, value, std::less_equal()); } + /** * @brief The Heaviside step function, or the unit step function, usually denoted by H or θ, is a * mathematical function that is zero for negative arguments and one for positive arguments. @@ -136,6 +138,7 @@ constexpr T heaviside(const T x, const T h0) noexcept { return 1.0; } + /** * @author Jason Leaver - USAF 557WW/1WXG * @@ -169,6 +172,7 @@ constexpr T rk2(Fn fn, T x0, T x1, T y, T step /* 1000.0 (Pa) */) noexc return y; } + /** * @author Jason Leaver - USAF 557WW/1WXG * @@ -212,6 +216,7 @@ constexpr T fixed_point( return NAN; } + /** * @brief Interpolates a 1D function using a linear interpolation. * @@ -230,6 +235,7 @@ constexpr T interpolate_1d(const T x, const T xp[], const T fp[], const size_t s return linear_interpolate(x, xp[i - 1], xp[i], fp[i - 1], fp[i]); } + /** * @author Jason Leaver - USAF 557WW/1WXG * diff --git a/src/lib/libthermo.cpp b/src/lib/libthermo.cpp index b2bb204..35e6f1b 100644 --- a/src/lib/libthermo.cpp +++ b/src/lib/libthermo.cpp @@ -35,8 +35,6 @@ constexpr size_t index_pressure(const T levels[], const T value, const size_t si return idx; } -// thermodynamic functions - template constexpr T mixing_ratio(const T partial_press, const T total_press) noexcept { return epsilon * partial_press / (total_press - partial_press); @@ -75,7 +73,6 @@ constexpr T dry_lapse(const T pressure, const T reference_pressure, const T temp template constexpr T dewpoint(const T vapor_pressure) noexcept { const T ln = log(vapor_pressure / E0); - return T0 + 243.5 * ln / (17.67 - ln); } @@ -93,6 +90,7 @@ template constexpr T potential_temperature(const T pressure, const T temperature) noexcept { return temperature / exner_function(pressure); } + /** * @brief theta_e * @@ -114,6 +112,7 @@ constexpr T equivalent_potential_temperature( return th_l * exp(r * (1 + 0.448 * r) * (3036.0 / t_l - 1.78)); } + /** * @brief theta_w * diff --git a/src/lib/wind.cpp b/src/lib/wind.cpp index 74bf153..1849752 100644 --- a/src/lib/wind.cpp +++ b/src/lib/wind.cpp @@ -6,6 +6,7 @@ template constexpr T wind_direction(const T u, const T v) noexcept { return fmod(degrees(atan2(u, v)) + 180.0, 360.0); } + template constexpr T wind_direction(const wind_components& uv) noexcept { return wind_direction(uv.u, uv.v); @@ -25,6 +26,7 @@ template constexpr wind_vector::wind_vector(const wind_components& uv) noexcept : direction(wind_direction(uv)), magnitude(wind_magnitude(uv)) { } + template constexpr wind_components::wind_components(const wind_vector& dm) noexcept { const T d = radians(dm.direction); diff --git a/src/nzthermo/_C.pxd b/src/nzthermo/_C.pxd index d0bf950..4910ea7 100644 --- a/src/nzthermo/_C.pxd +++ b/src/nzthermo/_C.pxd @@ -58,6 +58,8 @@ cdef extern from "libthermo.cpp" namespace "libthermo" nogil: # 1x1 T saturation_vapor_pressure[T](T temperature) noexcept + T exner_function[T](T pressure) noexcept + T exner_function[T](T pressure, T reference_pressure) noexcept # .. overload .. # 2x1 T mixing_ratio[T](T pressure, T vapor_pressure) noexcept T mixing_ratio_from_dewpoint[T](T pressure, T dewpoint) noexcept diff --git a/src/nzthermo/__init__.py b/src/nzthermo/__init__.py index 3b132b4..2e3bb92 100644 --- a/src/nzthermo/__init__.py +++ b/src/nzthermo/__init__.py @@ -46,6 +46,8 @@ "el", "lfc", "mixed_layer", + "mixed_layer_cape_cin", + "mixed_parcel", "mixing_ratio", "most_unstable_cape_cin", "most_unstable_parcel", @@ -104,6 +106,8 @@ el, lfc, mixed_layer, + mixed_layer_cape_cin, + mixed_parcel, mixing_ratio, most_unstable_cape_cin, most_unstable_parcel, diff --git a/src/nzthermo/_core.pyi b/src/nzthermo/_core.pyi index d4848a8..8990eb6 100644 --- a/src/nzthermo/_core.pyi +++ b/src/nzthermo/_core.pyi @@ -1,7 +1,8 @@ -from typing import Any, Literal as L, ParamSpec, TypeVar, overload +from typing import Any, Final, Literal as L, ParamSpec, TypeVar, overload import numpy as np +from ._ufunc import pressure_vector from .typing import Kelvin, N, NestedSequence, Pascal, SupportsArray, Z, shape _T = TypeVar("_T") @@ -17,17 +18,17 @@ _float = TypeVar("_float", np.float32, np.float64) OPENMP_ENABLED: bool = ... -T0: float = ... -E0: float = ... -Cp: float = ... -Rd: float = ... -Rv: float = ... -Lv: float = ... -P0: float = ... -Mw: float = ... -Md: float = ... -epsilon: float = ... -kappa: float = ... +T0: Final[float] = ... +E0: Final[float] = ... +Cp: Final[float] = ... +Rd: Final[float] = ... +Rv: Final[float] = ... +Lv: Final[float] = ... +P0: Final[float] = ... +Mw: Final[float] = ... +Md: Final[float] = ... +epsilon: Final[float] = ... +kappa: Final[float] = ... @overload def moist_lapse[T: np.floating[Any]]( @@ -68,14 +69,16 @@ def parcel_profile[T: np.floating[Any]]( where: np.ndarray[shape[N], np.dtype[np.bool_]] | None = ..., ) -> Kelvin[np.ndarray[shape[N, Z], np.dtype[T]]]: ... def parcel_profile_with_lcl[T: np.floating[Any]]( - pressure: Pascal[np.ndarray[shape[Z], np.dtype[T]] | np.ndarray[shape[N, Z], np.dtype[T]]], + pressure: Pascal[ + pressure_vector[shape[Z], np.dtype[T]] | np.ndarray[shape[N, Z], np.dtype[T]] + ], temperature: Kelvin[np.ndarray[shape[N, Z], np.dtype[np.floating[Any]]]], dewpoint: Kelvin[np.ndarray[shape[N, Z], np.dtype[np.floating[Any]]]], /, *, where: np.ndarray[shape[N], np.dtype[np.bool_]] | None = ..., ) -> tuple[ - Pascal[np.ndarray[shape[N, Z], np.dtype[T]]], + Pascal[pressure_vector[shape[N, Z], np.dtype[T]]], Kelvin[np.ndarray[shape[N, Z], np.dtype[T]]], Kelvin[np.ndarray[shape[N, Z], np.dtype[T]]], Kelvin[np.ndarray[shape[N, Z], np.dtype[T]]], diff --git a/src/nzthermo/_core.pyx b/src/nzthermo/_core.pyx index 8f2e56e..d4cd3f5 100644 --- a/src/nzthermo/_core.pyx +++ b/src/nzthermo/_core.pyx @@ -20,12 +20,14 @@ T fn(T ...){...} """ import warnings + from cython.parallel cimport parallel, prange from cython.view cimport array as cvarray import numpy as np cimport numpy as np cimport nzthermo._C as C +from nzthermo._ufunc import pressure_vector np.import_array() np.import_ufunc() @@ -166,8 +168,7 @@ def broadcast_and_nanmask( if where is not None: if not isinstance(where, np.ndarray): raise ValueError("where must be a numpy array.") - - + mode = MATRIX N = temperature.shape[0] Z = temperature.shape[1] @@ -213,7 +214,7 @@ cdef T[:] dispatch( const BroadcastMode mode ) noexcept: """ - ``` + ```python def cape_cin( np.ndarray pressure, np.ndarray temperature, @@ -653,7 +654,7 @@ def parcel_profile_with_lcl( else: out[...] = parcel_profile_with_lcl_2d[float](pressure, temperature, dewpoint, mode) - return out[0], out[1], out[2], out[3] + return out[0].view(pressure_vector), out[1], out[2], out[3] # ............................................................................................... # # interpolation diff --git a/src/nzthermo/_ufunc.pyi b/src/nzthermo/_ufunc.pyi index 8b77d82..1ce4e75 100644 --- a/src/nzthermo/_ufunc.pyi +++ b/src/nzthermo/_ufunc.pyi @@ -1,4 +1,4 @@ -from typing import Annotated, Any, ParamSpec, TypeVar +from typing import Annotated, Any, ParamSpec, TypeVar, overload import numpy as np from numpy.typing import ArrayLike @@ -12,6 +12,10 @@ _T = TypeVar("_T") _DType_T_co = TypeVar("_DType_T_co", bound=np.dtype[Any]) class pressure_vector(np.ndarray[_S, _DType_T_co]): + @overload + def __new__(cls, array: np.ndarray[_S, _DType_T_co]) -> pressure_vector[_S, _DType_T_co]: ... + @overload + def __new__(cls, array: ArrayLike) -> pressure_vector[Any, np.dtype[Any]]: ... def is_above( self, bottom: ArrayLike, close: bool = ... ) -> np.ndarray[_S, np.dtype[np.bool_]]: ... @@ -50,6 +54,8 @@ def wind_components(direction: float, speed: float) -> tuple[float, float]: ... # 1x1 @_ufunc1x1 +def exner_function(pressure: Pascal[float]) -> Pascal[float]: ... +@_ufunc1x1 def dewpoint(vapor_pressure: Pascal[float]) -> Kelvin[float]: ... @_ufunc1x1 def saturation_vapor_pressure(temperature: Kelvin[float]) -> Pascal[float]: ... diff --git a/src/nzthermo/_ufunc.pyx b/src/nzthermo/_ufunc.pyx index 70efd0e..f3acb65 100644 --- a/src/nzthermo/_ufunc.pyx +++ b/src/nzthermo/_ufunc.pyx @@ -19,11 +19,16 @@ import numpy as np cimport cython cimport numpy as np -from libcpp.cmath cimport fabs, isnan cimport nzthermo._C as C from nzthermo._C cimport Md, Mw + +cdef extern from "" namespace "std" nogil: + T fabs[T](T) noexcept + bint isnan(double) noexcept + + np.import_array() np.import_ufunc() @@ -64,6 +69,11 @@ cdef bint between_or_close(T x, T y0, T y1) noexcept nogil: class pressure_vector(np.ndarray[_S, np.dtype[_T]]): def __new__(cls, pressure): + if isinstance(pressure, pressure_vector): + return pressure + elif isinstance(pressure, np.ndarray): + return pressure.view(cls) + return np.asarray(pressure).view(cls) def is_above(self, bottom, close=True): @@ -120,10 +130,13 @@ cdef (double, double) wind_components(T d, T m) noexcept nogil: cdef (double, double) wind_vector(T u, T v) noexcept nogil: cdef C.wind_vector[T] dm = C.wind_vector[T](C.wind_components[T](u, v)) return dm.direction, dm.magnitude - - # 1x1 +@cython.ufunc +cdef T exner_function(T pressure) noexcept nogil: + return C.exner_function(pressure) + + @cython.ufunc cdef T dewpoint(T vapor_pressure) noexcept nogil: return C.dewpoint(vapor_pressure) @@ -231,7 +244,8 @@ cdef T dry_lapse(T pressure, T temperature, T reference_pressure) noexcept nogil @cython.ufunc # theta_e cdef T equivalent_potential_temperature(T pressure, T temperature, T dewpoint) noexcept nogil: - """ + r"""Calculates the equivalent potential temperature. + Parameters ---------- x : array_like diff --git a/src/nzthermo/const.py b/src/nzthermo/const.py deleted file mode 100644 index ed67f69..0000000 --- a/src/nzthermo/const.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Annotated, Final - -# [earth] -g: Final[Annotated[float, "(m / s^2)"]] = 9.80665 -"""standard acceleration due to gravity""" -G: Final[Annotated[float, ...]] = 6.6743e-11 -"""gravitational constant""" -Re: Final[Annotated[float, "m"]] = 6371008.7714 -"""Earth's radius meters""" -# [gas] -R: Final[Annotated[float, " (J mol^-1 K^-1)", "R*"]] = 8.314462618 -"""the molar gas constant (also known as the gas constant, universal gas constant, or ideal gas -constant) is denoted by the symbol R or R""" -# Specific gas constant -Rd: Final[Annotated[float, "(J/kg*K)"]] = 287.04749097718457 -"""constant (Dry Air) (J/kg*K)""" -Rv: Final[Annotated[float, ...]] = 461.52311572606084 -"""constant (Water Vapor) (J/kg*K)""" -# [specific heat] -Cpd: Final[Annotated[float, ...]] = 1004.6662184201462 -Cp = Cpd -# [latent heat] -Lv: Final[Annotated[float, ...]] = 2.50084e6 # (J kg^-1) latent heat of vaporization -"""Latent heat of vaporization (J/kg)""" -P0: Final[Annotated[float, ...]] = 1e5 -T0: Final[Annotated[float, "K"]] = 273.15 -"""freezing point in kelvin""" -E0: Final[Annotated[float, "Pa"]] = 611.21 -"""saturation pressure at freezing in Pa""" -epsilon: Final[Annotated[float, "Rd / Rv"]] = Rd / Rv -"""`Rd / Rv`""" diff --git a/src/nzthermo/core.py b/src/nzthermo/core.py index 7577de1..15c9913 100644 --- a/src/nzthermo/core.py +++ b/src/nzthermo/core.py @@ -15,15 +15,17 @@ from numpy.typing import ArrayLike, NDArray from . import functional as F -from ._core import moist_lapse, parcel_profile_with_lcl +from ._core import Rd, moist_lapse, parcel_profile_with_lcl from ._ufunc import ( dewpoint as _dewpoint, dry_lapse, equivalent_potential_temperature, + exner_function, greater_or_close, lcl, lcl_pressure, mixing_ratio, + potential_temperature, pressure_vector, saturation_mixing_ratio, saturation_vapor_pressure, @@ -31,7 +33,6 @@ virtual_temperature, wet_bulb_temperature, ) -from .const import Rd from .typing import Kelvin, N, Pascal, Z, shape from .utils import Axis, Vector1d, broadcast_nz @@ -60,6 +61,7 @@ def parcel_mixing_ratio( ) r = saturation_mixing_ratio(pressure, dewpoint, out=np.empty_like(temperature), where=where) r = saturation_mixing_ratio(pressure, temperature, out=r, where=~where) + return r @@ -72,9 +74,10 @@ def downdraft_cape( temperature: Kelvin[np.ndarray[shape[N, Z], np.dtype[_T]]], dewpoint: Kelvin[np.ndarray[shape[N, Z], np.dtype[_T]]], /, + *, where: np.ndarray[shape[N, Z], np.dtype[np.bool_]] | None = None, ) -> np.ndarray[shape[N], np.dtype[_T]]: - """Calculate downward CAPE (DCAPE). + r"""Calculate downward CAPE (DCAPE). Calculate the downward convective available potential energy (DCAPE) of a given upper air profile. Downward CAPE is the maximum negative buoyancy energy available to a descending @@ -85,7 +88,14 @@ def downdraft_cape( Parameters ---------- - TODO: add parameters + pressure : `array_like[[N, Z] | [Z], floating]` + Atmospheric pressure profile (Pa). This array must be from high to low pressure. + temperature : `array_like[[N, Z], floating]` + Temperature (K) at the levels given by `pressure` + dewpoint : `array_like[[N, Z], floating]` + Dewpoint (K) at the levels given by `pressure` + where : `array_like[[N, Z], bool]`, optional + Examples -------- @@ -138,17 +148,34 @@ def ccl( height=None, mixed_layer_depth=None, which: L["bottom", "top"] = "bottom", -): - """ - # Convective Condensation Level (CCL) +) -> tuple[Pascal[NDArray[_T]], Kelvin[NDArray[_T]], Kelvin[NDArray[_T]]]: + r"""Calculate convective condensation level (CCL). The Convective Condensation Level (CCL) is the level at which condensation will occur if sufficient afternoon heating causes rising parcels of air to reach saturation. The CCL is greater than or equal in height (lower or equal pressure level) than the LCL. The CCL and the LCL are equal when the atmosphere is saturated. The CCL is found at the intersection of the saturation mixing ratio line (through the surface dewpoint) and the environmental temperature. - """ + Parameters + ---------- + pressure : `array_like[[N, Z] | [Z], floating]` + Atmospheric pressure profile (Pa). This array must be from high to low pressure. + temperature : `array_like[[N, Z], floating]` + Temperature (K) at the levels given by `pressure` + dewpoint : `array_like[[N, Z], floating]` + Dewpoint (K) at the levels given by `pressure` + TODO : ... + + Returns + ------- + TODO : ... + + Examples + -------- + TODO : ... + + """ if mixed_layer_depth is None: r = mixing_ratio(saturation_vapor_pressure(dewpoint[surface]), pressure[surface]) else: @@ -222,13 +249,13 @@ def _el_lfc( log_x=True, ).where_above(LCL) - no_lfc = LFC.is_nan().all(Axis.Z, out=np.empty((N, 1), dtype=np.bool_), keepdims=True) + no_lfc = LFC.is_nan().all(axis=1, out=np.empty((N, 1), dtype=np.bool_), keepdims=True) is_lcl = no_lfc & greater_or_close( # the mask only needs to be applied to either the temperature or parcel_temperature_profile np.where(LCL.is_below(pressure, close=True), parcel_profile, NaN), temperature, - ).any(Axis.Z, out=np.empty((N, 1), dtype=np.bool_), keepdims=True) + ).any(axis=1, out=np.empty((N, 1), dtype=np.bool_), keepdims=True) LFC = LFC.select( [~no_lfc, is_lcl], @@ -252,6 +279,46 @@ def el( *, which: L["top", "bottom"] = "top", ) -> Vector1d[_T]: + r"""Calculate the equilibrium level (EL). + + This works by finding the last intersection of the ideal parcel path and the measured + environmental temperature. If there is one or fewer intersections, there is no equilibrium + level. + + Parameters + ---------- + pressure : `array_like[[N, Z] | [Z], floating]` + Atmospheric pressure profile (Pa). This array must be from high to low pressure. + temperature : `array_like[[N, Z], floating]` + Temperature (K) at the levels given by `pressure` + dewpoint : `array_like[[N, Z], floating]` + Dewpoint (K) at the levels given by `pressure` + parcel_profile : array_like[[N, Z], floating], optional + The parcel's temperature profile from which to calculate the EL. Defaults to the + surface parcel profile. + which : `str`, optional + Pick which EL to return. Options are `top` or `bottom`. Default is `top`. + 'top' returns the lowest-pressure EL, default. + 'bottom' returns the highest-pressure EL. + NOT IMPLEMENTED YET: + 'wide' returns the EL whose corresponding LFC is farthest away. + 'most_cape' returns the EL that results in the most CAPE in the profile. + + Returns + ------- + EL : `(ndarray[[N], floating], ndarray[[N], floating])` + + Examples + -------- + TODO : add examples + + See Also + -------- + lfc + parcel_profile + el_lfc + """ + return _el_lfc("EL", pressure, temperature, dewpoint, parcel_profile, which_el=which) @@ -289,6 +356,26 @@ def el_lfc( which_el: L["bottom", "top"] = "top", dewpoint_start: np.ndarray[shape[N], np.dtype[_T]] | None = None, ) -> tuple[Vector1d[_T], Vector1d[_T]]: + r"""TODO ... + + Parameters + ---------- + pressure : `array_like[[N, Z] | [Z], floating]` + Atmospheric pressure profile (Pa). This array must be from high to low pressure. + temperature : `array_like[[N, Z], floating]` + Temperature (K) at the levels given by `pressure` + dewpoint : `array_like[[N, Z], floating]` + Dewpoint (K) at the levels given by `pressure` + TODO : ... + + Returns + ------- + TODO : ... + + Examples + -------- + TODO : ... + """ return _el_lfc( "BOTH", pressure, @@ -314,6 +401,26 @@ def most_unstable_parcel_index( height: float | None = None, bottom: float | None = None, ) -> np.ndarray[shape[N], np.dtype[np.intp]]: + """TODO ... + + Parameters + ---------- + pressure : `array_like[[N, Z] | [Z], floating]` + Atmospheric pressure profile (Pa). This array must be from high to low pressure. + temperature : `array_like[[N, Z], floating]` + Temperature (K) at the levels given by `pressure` + dewpoint : `array_like[[N, Z], floating]` + Dewpoint (K) at the levels given by `pressure` + TODO : ... + + Returns + ------- + TODO : ... + + Examples + -------- + TODO : ... + """ if height is not None: raise NotImplementedError("height argument is not implemented") @@ -346,6 +453,26 @@ def most_unstable_parcel( Kelvin[np.ndarray[shape[N], np.dtype[_T]]], np.ndarray[shape[N, Z], np.dtype[np.intp]], ]: + r"""Calculate the most unstable parcel profile. + + Parameters + ---------- + pressure : `array_like[[N, Z] | [Z], floating]` + Atmospheric pressure profile (Pa). This array must be from high to low pressure. + temperature : `array_like[[N, Z], floating]` + Temperature (K) at the levels given by `pressure` + dewpoint : `array_like[[N, Z], floating]` + Dewpoint (K) at the levels given by `pressure` + TODO : ... + + Returns + ------- + TODO : ... + + Examples + -------- + TODO : ... + """ idx = most_unstable_parcel_index( pressure, temperature, dewpoint, depth=depth, bottom=bottom, **FASTPATH ) @@ -358,46 +485,6 @@ def most_unstable_parcel( ) -# ------------------------------------------------------------------------------------------------- -# nzthermo.core.mixed_layer -# ------------------------------------------------------------------------------------------------- -@broadcast_nz -def mixed_layer( - pressure: Pascal[pressure_vector[shape[N, Z], np.dtype[_T]]], - temperature: Kelvin[np.ndarray[shape[N, Z], np.dtype[_T]]], - dewpoint: Kelvin[np.ndarray[shape[N, Z], np.dtype[_T]]], - /, - *, - depth: float | NDArray[np.floating[Any]] = 10000.0, - height: ArrayLike | None = None, - bottom: ArrayLike | None = None, - interpolate=False, -) -> tuple[ - np.ndarray[shape[N], np.dtype[_T]], - Kelvin[np.ndarray[shape[N], np.dtype[_T]]], -]: - if height is not None: - raise NotImplementedError("height argument is not implemented") - if interpolate: - raise NotImplementedError("interpolate argument is not implemented") - - bottom = (pressure[surface] if bottom is None else np.asarray(bottom)).reshape(-1, 1) - top = bottom - depth - - where = pressure.is_between(bottom, top) - - depth = np.asarray( - # use asarray otherwise the depth is cast to pressure_vector which doesn't - # make sense for the temperature and dewpoint outputs - np.max(pressure, initial=-np.inf, axis=1, where=where) - - np.min(pressure, initial=np.inf, axis=1, where=where) - ) - - T, Td = F.nantrapz([temperature, dewpoint], pressure, axis=-1, where=where) / -depth - - return T, Td - - # ------------------------------------------------------------------------------------------------- # cape_cin # ------------------------------------------------------------------------------------------------- @@ -412,6 +499,26 @@ def cape_cin( which_lfc: L["bottom", "top"] = "bottom", which_el: L["bottom", "top"] = "top", ) -> tuple[np.ndarray, np.ndarray]: + r"""TODO ... + + Parameters + ---------- + pressure : `array_like[[N, Z] | [Z], floating]` + Atmospheric pressure profile (Pa). This array must be from high to low pressure. + temperature : `array_like[[N, Z], floating]` + Temperature (K) at the levels given by `pressure` + dewpoint : `array_like[[N, Z], floating]` + Dewpoint (K) at the levels given by `pressure` + TODO : ... + + Returns + ------- + TODO : ... + + Examples + -------- + TODO : ... + """ # The mixing ratio of the parcel comes from the dewpoint below the LCL, is saturated # based on the temperature above the LCL @@ -444,6 +551,9 @@ def cape_cin( return CAPE, CIN +# ------------------------------------------------------------------------------------------------- +# nzthermo.core.most_unstable +# ------------------------------------------------------------------------------------------------- @broadcast_nz def most_unstable_cape_cin( pressure: Pascal[pressure_vector[shape[N, Z], np.dtype[_T]]], @@ -454,6 +564,26 @@ def most_unstable_cape_cin( depth: Pascal[float] = 30000.0, bottom: Pascal[float] | None = None, ) -> tuple[np.ndarray, np.ndarray]: + r"""TODO ... + + Parameters + ---------- + pressure : `array_like[[N, Z] | [Z], floating]` + Atmospheric pressure profile (Pa). This array must be from high to low pressure. + temperature : `array_like[[N, Z], floating]` + Temperature (K) at the levels given by `pressure` + dewpoint : `array_like[[N, Z], floating]` + Dewpoint (K) at the levels given by `pressure` + TODO : ... + + Returns + ------- + TODO : ... + + Examples + -------- + TODO : ... + """ idx = most_unstable_parcel_index( pressure, temperature, @@ -472,3 +602,252 @@ def most_unstable_cape_cin( ) return cape_cin(p.view(pressure_vector), t, td, parcel_profile=mu_profile, **FASTPATH) + + +# ------------------------------------------------------------------------------------------------- +# nzthermo.core.mixed_layer +# ------------------------------------------------------------------------------------------------- +@broadcast_nz +def mixed_layer( + pressure: Pascal[pressure_vector[shape[N, Z], np.dtype[_T]]], + temperature: Kelvin[np.ndarray[shape[N, Z], np.dtype[_T]]], + dewpoint: Kelvin[np.ndarray[shape[N, Z], np.dtype[_T]]], + /, + *, + depth: float | NDArray[np.floating[Any]] = 10000.0, + height: ArrayLike | None = None, + bottom: ArrayLike | None = None, + interpolate=False, +) -> tuple[ + np.ndarray[shape[N], np.dtype[_T]], + Kelvin[np.ndarray[shape[N], np.dtype[_T]]], +]: + r"""TODO ... + + Parameters + ---------- + pressure : `array_like[[N, Z] | [Z], floating]` + Atmospheric pressure profile (Pa). This array must be from high to low pressure. + temperature : `array_like[[N, Z], floating]` + Temperature (K) at the levels given by `pressure` + dewpoint : `array_like[[N, Z], floating]` + Dewpoint (K) at the levels given by `pressure` + TODO : ... + + Returns + ------- + TODO : ... + + Examples + -------- + TODO : ... + """ + if height is not None: + raise NotImplementedError("height argument is not implemented") + if interpolate: + raise NotImplementedError("interpolate argument is not implemented") + + bottom = (pressure[surface] if bottom is None else np.asarray(bottom)).reshape(-1, 1) + top = bottom - depth + + where = pressure.is_between(bottom, top) + + depth = np.asarray( + # use asarray otherwise the depth is cast to pressure_vector which doesn't + # make sense for the temperature and dewpoint outputs + np.max(pressure, initial=-np.inf, axis=1, where=where) + - np.min(pressure, initial=np.inf, axis=1, where=where) + ) + + T, Td = F.nantrapz([temperature, dewpoint], pressure, axis=-1, where=where) / -depth + + return T, Td + + +@broadcast_nz +def mixed_parcel( + pressure: Pascal[pressure_vector[shape[N, Z], np.dtype[_T]]], + temperature: Kelvin[np.ndarray[shape[N, Z], np.dtype[_T]]], + dewpoint: Kelvin[np.ndarray[shape[N, Z], np.dtype[_T]]], + /, + *, + parcel_start_pressure: ArrayLike | None = None, + height: ArrayLike | None = None, + bottom: ArrayLike | None = None, + depth: float | NDArray[np.floating[Any]] = 10000.0, + interpolate=False, +) -> tuple[ + np.ndarray[shape[N], np.dtype[_T]], + np.ndarray[shape[N], np.dtype[_T]], + Kelvin[np.ndarray[shape[N], np.dtype[_T]]], +]: + r"""TODO ... + + Parameters + ---------- + pressure : `array_like[[N, Z] | [Z], floating]` + Atmospheric pressure profile (Pa). This array must be from high to low pressure. + temperature : `array_like[[N, Z], floating]` + Temperature (K) at the levels given by `pressure` + dewpoint : `array_like[[N, Z], floating]` + Dewpoint (K) at the levels given by `pressure` + TODO : ... + + Returns + ------- + TODO : ... + + Examples + -------- + TODO : ... + """ + if height is not None: + raise NotImplementedError("height argument is not implemented") + if interpolate: + raise NotImplementedError("interpolate argument is not implemented") + if parcel_start_pressure is None: + parcel_start_pressure = pressure[:, 0] + + theta = potential_temperature(pressure, temperature) + mixing_ratio = saturation_mixing_ratio(pressure, dewpoint) + mean_theta, mean_mixing_ratio = mixed_layer( + pressure, + theta, + mixing_ratio, + bottom=bottom, + height=height, + depth=depth, + interpolate=interpolate, + **FASTPATH, + ) + mean_temperature = mean_theta * exner_function(parcel_start_pressure) + mean_vapor_pressure = vapor_pressure(parcel_start_pressure, mean_mixing_ratio) + mean_dewpoint = _dewpoint(mean_vapor_pressure) + + return ( + # parcel_start_pressure, + np.broadcast_to(parcel_start_pressure, mean_temperature.shape), + mean_temperature, + mean_dewpoint, + ) + + +@broadcast_nz +def mixed_layer_cape_cin( + pressure: Pascal[pressure_vector[shape[N, Z], np.dtype[_T]]], + temperature: Kelvin[np.ndarray[shape[N, Z], np.dtype[_T]]], + dewpoint: Kelvin[np.ndarray[shape[N, Z], np.dtype[_T]]], + /, + *, + parcel_start_pressure: ArrayLike | None = None, + height: ArrayLike | None = None, + bottom: ArrayLike | None = None, + depth: float | NDArray[np.floating[Any]] = 10000.0, + interpolate=False, +): + r"""Calculate mixed-layer CAPE and CIN. + + Calculate the convective available potential energy (CAPE) and convective inhibition (CIN) + of a given upper air profile and mixed-layer parcel path. CIN is integrated between the + surface and LFC, CAPE is integrated between the LFC and EL (or top of sounding). + Intersection points of the measured temperature profile and parcel profile are + logarithmically interpolated. Kwargs for `mixed_parcel` can be provided, such as `depth`. + Default mixed-layer depth is 100 hPa. + + Parameters + ---------- + pressure : `pint.Quantity` + Pressure profile + + temperature : `pint.Quantity` + Temperature profile + + dewpoint : `pint.Quantity` + Dewpoint profile + + kwargs + Additional keyword arguments to pass to `mixed_parcel` + + Returns + ------- + `pint.Quantity` + Mixed-layer Convective Available Potential Energy (CAPE) + `pint.Quantity` + Mixed-layer Convective INhibition (CIN) + + Examples + -------- + >>> from metpy.calc import dewpoint_from_relative_humidity, mixed_layer_cape_cin + >>> from metpy.units import units + >>> # pressure + >>> p = [1008., 1000., 950., 900., 850., 800., 750., 700., 650., 600., + ... 550., 500., 450., 400., 350., 300., 250., 200., + ... 175., 150., 125., 100., 80., 70., 60., 50., + ... 40., 30., 25., 20.] * units.hPa + >>> # temperature + >>> T = [29.3, 28.1, 25.5, 20.9, 18.4, 15.9, 13.1, 10.1, 6.7, 3.1, + ... -0.5, -4.5, -9.0, -14.8, -21.5, -29.7, -40.0, -52.4, + ... -59.2, -66.5, -74.1, -78.5, -76.0, -71.6, -66.7, -61.3, + ... -56.3, -51.7, -50.7, -47.5] * units.degC + >>> # relative humidity + >>> rh = [.85, .75, .56, .39, .82, .72, .75, .86, .65, .22, .52, + ... .66, .64, .20, .05, .75, .76, .45, .25, .48, .76, .88, + ... .56, .88, .39, .67, .15, .04, .94, .35] * units.dimensionless + >>> # calculate dewpoint + >>> Td = dewpoint_from_relative_humidity(T, rh) + >>> mixed_layer_cape_cin(p, T, Td, depth=50 * units.hPa) + (, ) + + See Also + -------- + cape_cin, mixed_parcel, parcel_profile + + Notes + ----- + Only functions on 1D profiles (not higher-dimension vertical cross sections or grids). + Since this function returns scalar values when given a profile, this will return Pint + Quantities even when given xarray DataArray profiles. + + """ + if height is not None: + raise NotImplementedError("height argument is not implemented") + if interpolate: + raise NotImplementedError("interpolate argument is not implemented") + + start_p = np.atleast_1d( + pressure[:, 0] if parcel_start_pressure is None else parcel_start_pressure + ).reshape(-1, 1) + + parcel_pressure, parcel_temp, parcel_dewpoint = np.reshape( + mixed_parcel( + pressure, + temperature, + dewpoint, + parcel_start_pressure=parcel_start_pressure, + **FASTPATH, + ), + (3, -1, 1), + ) + + pressure, temperature, dewpoint = np.where( + pressure <= (start_p - depth), + [np.broadcast_to(pressure, temperature.shape), temperature, dewpoint], + -np.inf, + ) + pressure, temperature, dewpoint = F.map_partial( + np.concatenate, + [ + (parcel_pressure, pressure), + (parcel_temp, temperature), + (parcel_dewpoint, dewpoint), + ], + axis=1, + ) + pressure, temperature, dewpoint = F.sort_nz(np.isneginf, pressure, temperature, dewpoint) + pressure, temperature, dewpoint, profile = parcel_profile_with_lcl( + pressure, temperature, dewpoint + ) + + CAPE, CIN = cape_cin(pressure, temperature, dewpoint, profile, **FASTPATH) + + return CAPE, CIN diff --git a/src/nzthermo/functional.py b/src/nzthermo/functional.py index 09c4f01..22e98a3 100644 --- a/src/nzthermo/functional.py +++ b/src/nzthermo/functional.py @@ -1,6 +1,17 @@ from __future__ import annotations -from typing import Any, Literal as L, SupportsIndex, TypeVar, overload +import functools +from typing import ( + Any, + Callable, + Concatenate, + Iterable, + Literal as L, + ParamSpec, + SupportsIndex, + TypeVar, + overload, +) import numpy as np from numpy._typing._array_like import ( @@ -8,12 +19,55 @@ _ArrayLikeObject_co, _ArrayLikeTD64_co, ) -from numpy.typing import NDArray +from numpy.typing import ArrayLike, NDArray from .typing import N, Z, shape from .utils import Vector2d, exactly_2d _T = TypeVar("_T", bound=np.floating[Any]) +_P = ParamSpec("_P") +_R = TypeVar("_R") + +_T1 = TypeVar("_T1") + + +def map_partial( + f: Callable[Concatenate[_T1, _P], _R], x: Iterable[_T1], *args: _P.args, **kwargs: _P.kwargs +) -> map[_R]: + return map(functools.partial(f, *args, **kwargs), x) + + +def sort_nz( + where: Callable[ + Concatenate[np.ndarray[shape[N, Z], np.dtype[Any]], _P], + np.ndarray[shape[N, Z], np.dtype[np.bool_]], + ] + | np.ndarray[shape[N, Z], np.dtype[np.bool_]], + pressure: np.ndarray[shape[N, Z], np.dtype[_T]], + temperature: np.ndarray[shape[N, Z], np.dtype[_T]], + dewpoint: np.ndarray[shape[N, Z], np.dtype[_T]], + /, + *args: _P.args, + **kwargs: _P.kwargs, +): + sort = ( + np.arange(pressure.shape[0])[:, np.newaxis], + np.argsort(pressure, axis=1, kind="quicksort"), + ) + + pressure = pressure[sort][:, ::-1] + temperature = temperature[sort][:, ::-1] + dewpoint = dewpoint[sort][:, ::-1] + if callable(where): + where = where(pressure, *args, **kwargs) + + pressure[where] = np.nan + temperature[where] = np.nan + dewpoint[where] = np.nan + + clip = max(np.argmax(np.isnan(pressure), axis=1)) + 1 + + return pressure[:, :clip], temperature[:, :clip], dewpoint[:, :clip] def nanwhere( @@ -44,9 +98,6 @@ def nanroll_2d( return args -from numpy.typing import ArrayLike - - def nantrapz( y: _ArrayLikeComplex_co | _ArrayLikeTD64_co | _ArrayLikeObject_co, x: _ArrayLikeComplex_co | _ArrayLikeTD64_co | _ArrayLikeObject_co | None = None, @@ -227,6 +278,6 @@ def zero_crossings( sort = np.arange(N)[:, np.newaxis], np.argsort(x, axis=1, kind="quicksort") x, y = x[sort], y[sort] # clip axis 1 to the last non nan value. - clip = max(np.argmax(np.isnan(x), axis=1)) + clip = max(np.argmax(np.isnan(x), axis=1)) + 1 return Vector2d(x[:, :clip], y[:, :clip]) diff --git a/src/nzthermo/utils.py b/src/nzthermo/utils.py index 18b9062..257f0c4 100644 --- a/src/nzthermo/utils.py +++ b/src/nzthermo/utils.py @@ -294,7 +294,6 @@ def wrapper( ) -> _S: if kwargs.pop("__fastpath", False): return f(pressure, temperature, dewpoint, *args, **kwargs) # type: ignore - # TODO # - add support for squeezing what would have been a 1d input # - add support for reshaping: @@ -305,7 +304,8 @@ def wrapper( magnitude(temperature, "kelvin"), magnitude(dewpoint, "kelvin"), ) - return f(pressure_vector(pressure), temperature, dewpoint, *args, **kwargs) + + return f(pressure.view(pressure_vector), temperature, dewpoint, *args, **kwargs) return wrapper diff --git a/tests/core_test.py b/tests/core_test.py index 38fc7b1..a8d42b7 100644 --- a/tests/core_test.py +++ b/tests/core_test.py @@ -19,6 +19,8 @@ el, lfc, mixed_layer, + mixed_layer_cape_cin, + mixed_parcel, most_unstable_cape_cin, most_unstable_parcel, most_unstable_parcel_index, @@ -40,6 +42,8 @@ PRESSURE_ABSOLUTE_TOLERANCE = 1e-3 PRESSURE_RELATIVE_TOLERANCE = 1.5e-1 TEMPERATURE_ABSOLUTE_TOLERANCE = 1.0 # temperature is within 1 degree +CAPE_ABSOLUTE_TOLERANCE = 20.0 +CIN_ABSOLUTE_TOLERANCE = 40.0 def assert_nan(value: np.ndarray, value_units=None): @@ -1228,8 +1232,70 @@ def test_lfc_metpy_regression(which) -> None: assert_allclose(lfc_t[i], lfc_t_.m, atol=1.0) +# ----------------------------------------------------------------------------------------------- # +# CAPE CIN +# ----------------------------------------------------------------------------------------------- # # ............................................................................................... # -# nzthermo.core.most_unstable_parcel +# nzthermo.core.cape_cin +# ............................................................................................... # +@pytest.mark.cape_cin +@pytest.mark.broadcasting +def test_cape_cin_broadcasting(): + assert_array_equal( + cape_cin(P, T, Td, _C.parcel_profile(P, T[:, 0], Td[:, 0])), + cape_cin( + np.broadcast_to(P, T.shape), + T, + Td, + _C.parcel_profile(np.broadcast_to(P, T.shape), T[:, 0], Td[:, 0]), + ), + ) + + +@pytest.mark.cape_cin +@pytest.mark.regression +@pytest.mark.parametrize( + "which_lfc, which_el", + itertools.product(["top", "bottom"], ["top", "bottom"]), +) +def test_cape_cin_metpy_regression(which_lfc, which_el) -> None: + """ + TODO currently this test is passing on 95% of the cases, need to investigate the. + there error appears to be something in the logic block of the el_lfc function. + + The current test cases run 500 samples and we are failing on 17 of them specifically when + `which_el=bottom` parameter is used. realistically using the lower EL is not a typical use + case but it should still be tested. + """ + parcel_profile = _C.parcel_profile(P, T[:, 0], Td[:, 0]) + CAPE, CIN = cape_cin( + P, + T, + Td, + parcel_profile, + which_lfc=which_lfc, + which_el=which_el, + ) + + for i in range(T.shape[0]): + CAPE_, CIN_ = mpcalc.cape_cin( + P * Pa, + T[i] * K, + Td[i] * K, + parcel_profile[i] * K, + which_lfc=which_lfc, + which_el=which_el, + ) + + assert_allclose(CAPE[i], CAPE_.m, atol=10) + assert_allclose(CIN[i], CIN_.m, atol=10) + + +# ----------------------------------------------------------------------------------------------- # +# MOST UNSTABLE +# ----------------------------------------------------------------------------------------------- # +# ............................................................................................... # +# nzthermo.core.most_unstable_parcel_index # ............................................................................................... # @pytest.mark.broadcasting @pytest.mark.most_unstable_parcel @@ -1255,9 +1321,9 @@ def test_most_unstable_parcel_index(depth) -> None: ) -# ------------------------------------------------------------------------------------------------- -# nzthermo.core.mixed_layer -# ------------------------------------------------------------------------------------------------- +# ............................................................................................... # +# nzthermo.core.most_unstable_parcel +# ............................................................................................... # @pytest.mark.broadcasting @pytest.mark.most_unstable_parcel @pytest.mark.parametrize("depth", [30000.0]) @@ -1285,6 +1351,48 @@ def test_most_unstable_parcel_regression(depth) -> None: assert_array_equal(idx[i], idx_) +# ............................................................................................... # +# nzthermo.core.most_unstable_cape_cin +# ............................................................................................... # +@pytest.mark.broadcasting +@pytest.mark.most_unstable_cape_cin +def test_most_unstable_cape_cin_broadcasting(): + assert_array_equal( + most_unstable_cape_cin(P, T, Td, depth=30000.0), + most_unstable_cape_cin( + np.broadcast_to(P, T.shape), + T, + Td, + depth=30000.0, + ), + ) + + +@pytest.mark.regression +@pytest.mark.most_unstable_cape_cin +@pytest.mark.parametrize("depth", [30000.0]) +def test_most_unstable_cape_cin_metpy_regression(depth) -> None: + CAPE, CIN = most_unstable_cape_cin( + P, + T, + Td, + depth=depth, + ) + + for i in range(T.shape[0]): + CAPE_, CIN_ = mpcalc.most_unstable_cape_cin( + P * Pa, + T[i] * K, + Td[i] * K, + depth=depth * Pa, + ) + assert_allclose(CAPE[i], CAPE_.m, atol=10) + assert_allclose(CIN[i], CIN_.m, atol=20) + + +# ----------------------------------------------------------------------------------------------- # +# MIXED LAYER +# ----------------------------------------------------------------------------------------------- # # ............................................................................................... # # nzthermo.core.mixed_layer # ............................................................................................... # @@ -1329,95 +1437,93 @@ def test_mixed_layer_regression() -> None: # ............................................................................................... # -# nzthermo.core.cape_cin +# nzthermo.core.mixed_parcel # ............................................................................................... # -@pytest.mark.cape_cin @pytest.mark.broadcasting -def test_cape_cin_broadcasting(): - assert_array_equal( - cape_cin(P, T, Td, _C.parcel_profile(P, T[:, 0], Td[:, 0])), - cape_cin( - np.broadcast_to(P, T.shape), - T, - Td, - _C.parcel_profile(np.broadcast_to(P, T.shape), T[:, 0], Td[:, 0]), - ), - ) - - -@pytest.mark.cape_cin -@pytest.mark.regression -@pytest.mark.parametrize( - "which_lfc, which_el", - itertools.product(["top", "bottom"], ["top", "bottom"]), -) -def test_cape_cin_metpy_regression(which_lfc, which_el) -> None: +@pytest.mark.mixed_parcel +def test_mixed_parcel_broadcasting() -> None: """ - TODO currently this test is passing on 95% of the cases, need to investigate the. - there error appears to be something in the logic block of the el_lfc function. + NOTE: using assert_array_equal I'm not entirely sure wy broadcasting the pressure + is causing causing some 1e-5 differences in the results, but atol of 1e-5 is well within + and acceptable range for the test to pass. - The current test cases run 500 samples and we are failing on 17 of them specifically when - `which_el=bottom` parameter is used. realistically using the lower EL is not a typical use - case but it should still be tested. + ```bash + E Mismatched elements: 233 / 1080 (21.6%) + E Max absolute difference among violations: 0.000031 + E Max relative difference among violations: 0. + ``` """ - parcel_profile = _C.parcel_profile(P, T[:, 0], Td[:, 0]) - CAPE, CIN = cape_cin( - P, - T, - Td, - parcel_profile, - which_lfc=which_lfc, - which_el=which_el, + + assert_allclose( + mixed_parcel(P, T, Td), + mixed_parcel(np.broadcast_to(P, T.shape), T, Td), + atol=TEMPERATURE_ABSOLUTE_TOLERANCE, ) + +@pytest.mark.regression +@pytest.mark.mixed_parcel +def test_mixed_parcel_regression() -> None: + p, t, td = mixed_parcel(P, T, Td) + for i in range(T.shape[0]): - CAPE_, CIN_ = mpcalc.cape_cin( - P * Pa, - T[i] * K, - Td[i] * K, - parcel_profile[i] * K, - which_lfc=which_lfc, - which_el=which_el, + p_, t_, td_ = mpcalc.mixed_parcel(P * Pa, T[i] * K, Td[i] * K, interpolate=False) + assert_allclose( + p[i], + p_.m, + atol=PRESSURE_ABSOLUTE_TOLERANCE, ) - assert_allclose(CAPE[i], CAPE_.m, atol=10) - assert_allclose(CIN[i], CIN_.m, atol=10) + assert_allclose( + t[i], + t_.m, + atol=TEMPERATURE_ABSOLUTE_TOLERANCE, + ) + assert_allclose( + td[i], + td_.m, + atol=TEMPERATURE_ABSOLUTE_TOLERANCE, + ) # ............................................................................................... # -# nzthermo.core.most_unstable_cape_cin +# nzthermo.core.mixed_layer_cape_cin # ............................................................................................... # +# @pytest.mark.skip @pytest.mark.broadcasting -@pytest.mark.most_unstable_cape_cin -def test_most_unstable_cape_cin_broadcasting(): - assert_array_equal( - most_unstable_cape_cin(P, T, Td, depth=30000.0), - most_unstable_cape_cin( - np.broadcast_to(P, T.shape), - T, - Td, - depth=30000.0, - ), +@pytest.mark.mixed_layer_cape_cin +def test_mixed_layer_cape_cin_broadcasting() -> None: + """ + NOTE: using assert_array_equal I'm not entirely sure wy broadcasting the pressure + is causing causing some 1e-5 differences in the results, but atol of 1e-5 is well within + and acceptable range for the test to pass. + + ```bash + E Mismatched elements: 233 / 1080 (21.6%) + E Max absolute difference among violations: 0.000031 + E Max relative difference among violations: 0. + ``` + """ + + assert_allclose( + mixed_layer_cape_cin(P, T, Td), + mixed_layer_cape_cin(np.broadcast_to(P, T.shape), T, Td), + atol=TEMPERATURE_ABSOLUTE_TOLERANCE, ) +# @pytest.mark.skip @pytest.mark.regression -@pytest.mark.most_unstable_cape_cin -@pytest.mark.parametrize("depth", [30000.0]) -def test_most_unstable_cape_cin_metpy_regression(depth) -> None: - CAPE, CIN = most_unstable_cape_cin( - P, - T, - Td, - depth=depth, - ) +@pytest.mark.mixed_layer_cape_cin +def test_mixed_layer_cape_cin_regression() -> None: + CAPE, CIN = mixed_layer_cape_cin(P, T, Td) + CAPE_ = [] + CIN_ = [] for i in range(T.shape[0]): - CAPE_, CIN_ = mpcalc.most_unstable_cape_cin( - P * Pa, - T[i] * K, - Td[i] * K, - depth=depth * Pa, - ) - assert_allclose(CAPE[i], CAPE_.m, atol=10) - assert_allclose(CIN[i], CIN_.m, atol=20) + cape_, cin_ = mpcalc.mixed_layer_cape_cin(P * Pa, T[i] * K, Td[i] * K, interpolate=False) + CAPE_.append(cape_.m) + CIN_.append(cin_.m) + + assert_allclose(CAPE, CAPE_, atol=20) + assert_allclose(CIN, CIN_, atol=200) diff --git a/tests/ufunc_test.py b/tests/ufunc_test.py index 16b3de5..3452891 100644 --- a/tests/ufunc_test.py +++ b/tests/ufunc_test.py @@ -9,6 +9,7 @@ equivalent_potential_temperature, lcl, potential_temperature, + pressure_vector, wet_bulb_potential_temperature, wet_bulb_temperature, wind_components, @@ -22,6 +23,19 @@ WIND_MAGNITUDES = np.array([10, 20, 30, 40, 50]) +def test_pressure_vector() -> None: + v = pressure_vector([1000.0, 900.0, 800.0, 700.0, 600.0]) + assert v.is_above(1013.25).all() + assert v.is_above(1013.25, close=True).all() + print(v.is_above(1000.0, close=True)) + + assert v.is_below(200.0).all() + assert v.is_below(200.0, close=True).all() + + assert v.is_between(1013.25, 200.0).all() + assert v.is_between(1013.25, 200.0, close=True).all() + + def test_dewpoints() -> None: pressure = 101325.0 sh = 0.01