Source code for tephpy.calc

# Copyright (c) 2026, tephpy Contributors.
#
# This file is part of tephpy and is distributed under the 3-Clause BSD license.
# See the LICENSE file in the package root directory for licensing details.
"""Tephigram-native thermodynamic analysis over :mod:`metpy.calc` (spec §3.3).

Physics is delegated to MetPy; only tephigram-native compositions live
here, and everything returns pint quantities on the shared registry
(spec §5). Sounding-level functions take a :class:`~tephpy.sounding.Sounding`
— constructing one already validates units, monotonic pressure, and
dewpoint ≤ temperature — while :func:`normand_point` is the one
quantity-level function. MetPy stays behind function-local imports so that
``import tephpy`` stays light (spec §10 item 10).

Analysis results distinguish "does not exist" from "zero" (spec §6):
``metpy.calc`` returns NaN quantities for a missing LFC/EL and ``0 J/kg``
— never NaN — for zero CAPE/CIN, and tephpy passes both through,
documented per :class:`SoundingIndices` field.
"""

from __future__ import annotations

import dataclasses
from typing import TYPE_CHECKING, Final, Literal
import warnings

import numpy as np

from tephpy._constants import MOIST_ADIABAT_PRESSURE_STEP
from tephpy._units import as_quantity, check_units_mapping
from tephpy.exceptions import (
    DewpointExceedsTemperatureError,
    MissingDataError,
    ProfileTooShortError,
    TephpyValidationError,
)

if TYPE_CHECKING:
    from collections.abc import Mapping

    import pint

    from tephpy.sounding import Sounding

__all__ = ["Profile", "SoundingIndices", "indices", "normand_point", "parcel_path"]

#: The parcel-selection options (spec §3.3).
_PARCELS: Final[tuple[str, ...]] = ("surface", "mixed-layer")

#: The mixed-layer depth, in hPa (the operational convention and
#: ``metpy.calc.mixed_parcel``'s default); passed explicitly so tephpy owns
#: it and the sounding-span guard shares the single source of truth.
_MIXED_LAYER_DEPTH_HPA: Final[float] = 100.0

#: The ``Profile`` data fields with their required dimensionalities (spec §5).
_PROFILE_DIMENSIONS: Final[dict[str, str]] = {
    "pressure": "[pressure]",
    "temperature": "[temperature]",
    "lcl_pressure": "[pressure]",
    "lcl_temperature": "[temperature]",
}

#: The ``SoundingIndices`` fields with their required dimensionalities
#: (spec §5). CAPE/CIN are specific energies (J/kg); the lifted index is a
#: temperature difference, so its pint dimensionality is a temperature.
_INDEX_DIMENSIONS: Final[dict[str, str]] = {
    "cape": "[energy] / [mass]",
    "cin": "[energy] / [mass]",
    "lcl_pressure": "[pressure]",
    "lcl_temperature": "[temperature]",
    "lfc_pressure": "[pressure]",
    "lfc_temperature": "[temperature]",
    "el_pressure": "[pressure]",
    "el_temperature": "[temperature]",
    "theta_w": "[temperature]",
    "lifted_index": "[temperature]",
}

#: The MetPy warning suppressed at the ``lifted_index`` call site: a profile
#: topping out below 500 hPa makes the index NaN *with* a ``UserWarning``,
#: and the NaN field is the meteorological answer (spec §6, §10 item 11).
_OUT_OF_BOUNDS_MESSAGE: Final[str] = (
    "Interpolation point out of data bounds encountered"
)


[docs] @dataclasses.dataclass(frozen=True, eq=False) class Profile: """One computed parcel ascent, ready to plot (spec §3.3). Plain plottable data: ``plot_profile`` draws it and the shading builders consume it, and neither re-derives the LCL. Construction mirrors ``Sounding``: bare arrays take the ``units=`` mapping, fields are dimension-checked quantities on the shared registry, and validation happens at construction. Attributes ---------- pressure : pint.Quantity Path pressures, surface-first (strictly decreasing), at least two levels. temperature : pint.Quantity Parcel temperatures along the path. lcl_pressure : pint.Quantity Scalar pressure of the Normand's point the path actually uses — the corrected one when a correction was requested — inside the path's pressure span. lcl_temperature : pint.Quantity Scalar temperature at that point. parcel : str The lifted parcel: ``"surface"`` or ``"mixed-layer"``. label : str or None Legend text; ``None`` means no legend entry. units : mapping of str to str, optional Construction-only (not stored): unit strings for bare-array fields, keyed by field name (spec §5). """ pressure: pint.Quantity temperature: pint.Quantity lcl_pressure: pint.Quantity lcl_temperature: pint.Quantity parcel: Literal["surface", "mixed-layer"] = "surface" label: str | None = None units: dataclasses.InitVar[Mapping[str, str] | None] = None def __post_init__(self, units: Mapping[str, str] | None) -> None: """Coerce and validate the constructed profile. Parameters ---------- units : mapping of str to str or None The ``units=`` mapping for bare-array fields. """ mapping = check_units_mapping(units, allowed=_PROFILE_DIMENSIONS) for name, dimension in _PROFILE_DIMENSIONS.items(): quantity = as_quantity( getattr(self, name), name=name, units=mapping.get(name), dimension=dimension, ) object.__setattr__(self, name, quantity) self._validate_arrays() self._validate_lcl() if self.parcel not in _PARCELS: msg = f"parcel must be one of {_PARCELS!r}, got {self.parcel!r}" raise ValueError(msg) def _validate_arrays(self) -> None: """Require 1-D equal-length arrays with strictly decreasing pressure.""" pressure = self.pressure.magnitude temperature = self.temperature.magnitude for name, magnitude in (("pressure", pressure), ("temperature", temperature)): if magnitude.ndim != 1: msg = f"{name!r} must be 1-D, got {magnitude.ndim}-D" raise TephpyValidationError(msg) if pressure.size != temperature.size: msg = ( "pressure and temperature must be equal length, got " f"{pressure.size} and {temperature.size}" ) raise TephpyValidationError(msg) if pressure.size < 2: msg = f"a profile needs at least 2 levels, got {pressure.size}" raise TephpyValidationError(msg) offending = np.flatnonzero(~(np.diff(pressure) < 0.0)) + 1 if offending.size: levels = tuple(int(index) for index in offending) msg = ( "profile pressure must be strictly decreasing " f"(surface-first); offending levels {levels}" ) raise TephpyValidationError(msg, levels=levels) def _validate_lcl(self) -> None: """Require a scalar LCL inside the path's pressure span.""" for name in ("lcl_pressure", "lcl_temperature"): magnitude = getattr(self, name).magnitude if magnitude.ndim != 0: msg = f"{name!r} must be a scalar, got shape {magnitude.shape}" raise TephpyValidationError(msg) pressure = self.pressure.m_as("hPa") lcl = float(self.lcl_pressure.m_as("hPa")) if not pressure[-1] <= lcl <= pressure[0]: msg = ( f"lcl_pressure ({lcl:g} hPa) must lie inside the path's " f"pressure span [{pressure[-1]:g}, {pressure[0]:g}] hPa" ) raise TephpyValidationError(msg)
[docs] @dataclasses.dataclass(frozen=True, eq=False) class SoundingIndices: """Derived thermodynamic parameters for one sounding (spec §3.3). Ten scalar quantity fields, each dimension-checked at construction. There is no cross-field validation: NaN fields are answers, not errors — analysis results distinguish "does not exist" (NaN) from "zero" (spec §6). Attributes ---------- cape : pint.Quantity Convective available potential energy (J/kg); ``0 J/kg`` — never NaN — when the parcel has no positive-buoyancy region. cin : pint.Quantity Convective inhibition (J/kg, non-positive); ``0 J/kg`` when there is no LFC or no negative-buoyancy region below it. lcl_pressure : pint.Quantity Pressure of the lifting condensation level the parcel uses (the corrected one when a correction was requested); always defined. lcl_temperature : pint.Quantity Temperature at that level; always defined. lfc_pressure : pint.Quantity Pressure of the level of free convection; NaN when the parcel never becomes positively buoyant. lfc_temperature : pint.Quantity Temperature at that level; NaN with `lfc_pressure`. el_pressure : pint.Quantity Pressure of the equilibrium level; NaN when it does not exist — including while ``cape > 0`` with the parcel still buoyant at the profile top. el_temperature : pint.Quantity Temperature at that level; NaN with `el_pressure`. theta_w : pint.Quantity Wet-bulb potential temperature of the lifted parcel, evaluated at the parcel start, so it follows the ``parcel=`` option; always defined. lifted_index : pint.Quantity Lifted index (a temperature difference at 500 hPa); NaN when the profile tops out below 500 hPa. units : mapping of str to str, optional Construction-only (not stored): unit strings for bare scalar fields, keyed by field name (spec §5). """ cape: pint.Quantity cin: pint.Quantity lcl_pressure: pint.Quantity lcl_temperature: pint.Quantity lfc_pressure: pint.Quantity lfc_temperature: pint.Quantity el_pressure: pint.Quantity el_temperature: pint.Quantity theta_w: pint.Quantity lifted_index: pint.Quantity units: dataclasses.InitVar[Mapping[str, str] | None] = None def __post_init__(self, units: Mapping[str, str] | None) -> None: """Coerce and dimension-check the constructed indices. Parameters ---------- units : mapping of str to str or None The ``units=`` mapping for bare scalar fields. """ mapping = check_units_mapping(units, allowed=_INDEX_DIMENSIONS) for name, dimension in _INDEX_DIMENSIONS.items(): quantity = as_quantity( getattr(self, name), name=name, units=mapping.get(name), dimension=dimension, ) if quantity.magnitude.ndim != 0: msg = f"{name!r} must be a scalar, got shape {quantity.magnitude.shape}" raise TephpyValidationError(msg) object.__setattr__(self, name, quantity)
[docs] def normand_point( pressure: object, temperature: object, dewpoint: object, *, units: Mapping[str, str] | None = None, ) -> tuple[pint.Quantity, pint.Quantity]: """Construct Normand's point — the LCL — for one parcel (spec §3.3). The geometric construction: the dry adiabat through (`pressure`, `temperature`) meets the humidity mixing-ratio line through (`pressure`, `dewpoint`) at the lifting condensation level. This is always the uncorrected construction; the operational cloud-base correction is :func:`parcel_path`'s concern. Parameters ---------- pressure : pint.Quantity or float Scalar parcel pressure; a bare value takes the ``units=`` mapping. temperature : pint.Quantity or float Scalar parcel temperature. dewpoint : pint.Quantity or float Scalar parcel dewpoint; must not exceed `temperature` (equality — saturation — is physical, and puts Normand's point at the parcel). units : mapping of str to str, optional Unit strings for bare values, keyed by argument name, e.g. ``units={"pressure": "hPa", "temperature": "degC"}`` (spec §5). Returns ------- tuple of pint.Quantity The scalar ``(pressure, temperature)`` of Normand's point, in hPa and degrees Celsius. Raises ------ TephpyUnitsError For unit-less bare values, ambiguous or unparsable units, or the wrong dimensionality. DewpointExceedsTemperatureError If `dewpoint` exceeds `temperature`. TephpyValidationError If an argument is not a scalar. """ mapping = check_units_mapping( units, allowed=("pressure", "temperature", "dewpoint") ) p = _scalar_quantity(pressure, "pressure", mapping, "[pressure]") t = _scalar_quantity(temperature, "temperature", mapping, "[temperature]") td = _scalar_quantity(dewpoint, "dewpoint", mapping, "[temperature]") if float(td.m_as("degC")) > float(t.m_as("degC")): msg = ( "dewpoint exceeds temperature (equality is saturation and " "accepted); no Normand's point exists" ) raise DewpointExceedsTemperatureError(msg) # Function-local so `import tephpy` stays light (spec §3.3, §10 item 10). from metpy.calc import lcl # noqa: PLC0415 lcl_pressure, lcl_temperature = lcl(p, t, td) return lcl_pressure.to("hPa"), lcl_temperature.to("degC")
def _scalar_quantity( value: object, name: str, mapping: Mapping[str, str], dimension: str ) -> pint.Quantity: """Coerce one scalar boundary argument (spec §5). Parameters ---------- value : object The argument value: a pint quantity, or a bare value with a `mapping` entry. name : str The argument name, used in error messages. mapping : mapping of str to str The boundary's validated ``units=`` mapping. dimension : str The required pint dimensionality. Returns ------- pint.Quantity The scalar quantity on MetPy's registry. Raises ------ TephpyUnitsError For unit-less bare values, ambiguous or unparsable units, or the wrong dimensionality. TephpyValidationError If the value is not a scalar. """ quantity = as_quantity( value, name=name, units=mapping.get(name), dimension=dimension ) if quantity.magnitude.ndim != 0: msg = f"{name!r} must be a scalar, got shape {quantity.magnitude.shape}" raise TephpyValidationError(msg) return quantity
[docs] def parcel_path( snd: Sounding, *, parcel: Literal["surface", "mixed-layer"] = "surface", cloud_base_correction: object = None, label: str | None = None, ) -> Profile: """Compute a parcel's ascent path over the sounding's span (spec §3.3). Dry adiabat from the parcel start to Normand's point, then moist adiabat to the profile top. Both legs sample the background moist adiabats' 5 hPa step, the moist leg is integrated with ``metpy.calc.moist_lapse(..., reference_pressure=lcl_pressure)`` — same integrator, same sampling, same anchoring as the background family — and the LCL vertex is spliced in exactly. Parameters ---------- snd : Sounding The environment sounding; must carry dewpoint. parcel : str, default: "surface" The lifted parcel: ``"surface"`` starts from the lowest level; ``"mixed-layer"`` starts from :func:`metpy.calc.mixed_parcel` (its 100 hPa default depth is the operational convention). cloud_base_correction : pint.Quantity, optional A pressure-dimension correction added to the LCL pressure, applied only when explicitly requested; the operational -25 mb value lives in ``tephpy._constants.CLOUD_BASE_CORRECTION``. The corrected LCL temperature is re-read from the dry adiabat at the corrected pressure. label : str, optional Legend text for the profile; ``None`` means no legend entry. Returns ------- Profile The parcel path, surface-first, with the LCL it actually uses. Raises ------ MissingDataError If the sounding has no dewpoint. ProfileTooShortError If the profile tops out at or below the LCL the path would use (the corrected one when a correction is requested), or a mixed-layer parcel is requested but the sounding spans less than the mixed-layer depth. TephpyUnitsError If `cloud_base_correction` is not a pressure-dimension quantity. TephpyValidationError If the selected parcel start has an undefined (NaN) temperature or dewpoint, or the correction places the LCL below the parcel start. ValueError If `parcel` is not a known option. """ start_pressure, start_temperature, start_dewpoint = _parcel_start(snd, parcel) lcl_pressure, lcl_temperature = _lcl_used( start_pressure, start_temperature, start_dewpoint, cloud_base_correction ) _require_moist_ascent(snd, lcl_pressure) # Function-local so `import tephpy` stays light (spec §3.3, §10 item 10). from metpy.calc import dry_lapse, moist_lapse # noqa: PLC0415 from metpy.units import units as registry # noqa: PLC0415 p0 = float(start_pressure.m_as("hPa")) lcl_hpa = float(lcl_pressure.m_as("hPa")) top = float(snd.pressure[-1].m_as("hPa")) step = MOIST_ADIABAT_PRESSURE_STEP dry_pressure = np.arange(p0, lcl_hpa, -step) moist_pressure = np.concatenate([np.arange(lcl_hpa - step, top, -step), [top]]) if dry_pressure.size: dry_temperature = dry_lapse( registry.Quantity(dry_pressure, "hPa"), start_temperature, reference_pressure=start_pressure, ).m_as("degC") else: # A saturated parcel: Normand's point is the parcel start. dry_temperature = np.empty(0, dtype=np.float64) moist_temperature = moist_lapse( registry.Quantity(moist_pressure, "hPa"), lcl_temperature, reference_pressure=lcl_pressure, ).m_as("degC") pressure = np.concatenate([dry_pressure, [lcl_hpa], moist_pressure]) temperature = np.concatenate( [dry_temperature, [float(lcl_temperature.m_as("degC"))], moist_temperature] ) return Profile( pressure=registry.Quantity(pressure, "hPa"), temperature=registry.Quantity(temperature, "degC"), lcl_pressure=lcl_pressure, lcl_temperature=lcl_temperature, parcel=parcel, label=label, )
def _parcel_start( snd: Sounding, parcel: str ) -> tuple[pint.Quantity, pint.Quantity, pint.Quantity]: """Select the lifted parcel's starting point (spec §3.3). Parameters ---------- snd : Sounding The environment sounding. parcel : str The parcel option: ``"surface"`` or ``"mixed-layer"``. Returns ------- tuple of pint.Quantity Scalar ``(pressure, temperature, dewpoint)`` of the parcel start. Raises ------ MissingDataError If the sounding has no dewpoint. ProfileTooShortError If a mixed-layer parcel is requested but the sounding spans less than the mixed-layer depth. TephpyValidationError If the selected parcel start has an undefined (NaN) temperature or dewpoint. ValueError If `parcel` is not a known option. """ if parcel not in _PARCELS: msg = f"parcel must be one of {_PARCELS!r}, got {parcel!r}" raise ValueError(msg) if snd.dewpoint is None: msg = "parcel analysis needs dewpoint: this sounding has none (spec §3.4)" raise MissingDataError(msg) if parcel == "mixed-layer": # Function-local so `import tephpy` stays light (spec §10 item 10). from metpy.calc import mixed_parcel # noqa: PLC0415 from metpy.units import units as registry # noqa: PLC0415 # `mixed_parcel` integrates over the bottom `_MIXED_LAYER_DEPTH_HPA`; # a shallower sounding otherwise raises a bare out-of-range # ValueError from MetPy instead of the spec §6 hierarchy. span = float(snd.pressure[0].m_as("hPa")) - float(snd.pressure[-1].m_as("hPa")) if span < _MIXED_LAYER_DEPTH_HPA: msg = ( f"mixed-layer parcel needs a sounding spanning at least its " f"{_MIXED_LAYER_DEPTH_HPA:g} hPa depth; this sounding spans only " f"{span:g} hPa (spec §6)" ) raise ProfileTooShortError(msg) pressure, temperature, dewpoint = mixed_parcel( snd.pressure, snd.temperature, snd.dewpoint, depth=registry.Quantity(_MIXED_LAYER_DEPTH_HPA, "hPa"), ) start = pressure.to("hPa"), temperature.to("degC"), dewpoint.to("degC") # A NaN inside the mixed layer propagates into an undefined start. _require_defined_start(start, levels=()) return start start = snd.pressure[0], snd.temperature[0], snd.dewpoint[0] # `Sounding` admits NaN temperature/dewpoint; a NaN lowest level would # otherwise yield a NaN LCL and bare downstream ValueErrors. _require_defined_start(start, levels=(0,)) return start def _require_defined_start( start: tuple[pint.Quantity, pint.Quantity, pint.Quantity], *, levels: tuple[int, ...], ) -> None: """Reject a parcel start with an undefined (NaN) temperature or dewpoint. Parameters ---------- start : tuple of pint.Quantity The scalar ``(pressure, temperature, dewpoint)`` parcel start. levels : tuple of int The offending level indices to attribute the failure to; empty when no single level is responsible (e.g. a mixed-layer average). Raises ------ TephpyValidationError If the start temperature or dewpoint is not finite. """ _, temperature, dewpoint = start finite = np.isfinite( [float(temperature.m_as("degC")), float(dewpoint.m_as("degC"))] ) if not finite.all(): msg = ( "parcel start has an undefined (NaN) temperature or dewpoint; " "no parcel can be lifted from it (spec §6)" ) raise TephpyValidationError(msg, levels=levels) def _lcl_used( start_pressure: pint.Quantity, start_temperature: pint.Quantity, start_dewpoint: pint.Quantity, cloud_base_correction: object, ) -> tuple[pint.Quantity, pint.Quantity]: """Locate the LCL the ascent uses, applying any requested correction. Parameters ---------- start_pressure : pint.Quantity Scalar parcel-start pressure. start_temperature : pint.Quantity Scalar parcel-start temperature. start_dewpoint : pint.Quantity Scalar parcel-start dewpoint. cloud_base_correction : pint.Quantity or None The pressure-dimension correction, or ``None`` for the plain Normand's point. Returns ------- tuple of pint.Quantity The scalar ``(pressure, temperature)`` of the LCL the path uses, in hPa and degrees Celsius. The corrected LCL temperature is re-read from the dry adiabat at the corrected pressure. Raises ------ TephpyUnitsError If the correction is not a pressure-dimension quantity. TephpyValidationError If the correction places the LCL below the parcel start. """ lcl_pressure, lcl_temperature = normand_point( start_pressure, start_temperature, start_dewpoint ) if cloud_base_correction is None: return lcl_pressure, lcl_temperature correction = _scalar_quantity( cloud_base_correction, "cloud_base_correction", {}, "[pressure]" ) corrected = (lcl_pressure + correction).to("hPa") if float(corrected.m_as("hPa")) > float(start_pressure.m_as("hPa")): msg = ( f"cloud_base_correction ({correction:~P}) places the LCL at " f"{corrected:~P}, below the {start_pressure:~P} parcel start" ) raise TephpyValidationError(msg) # Function-local so `import tephpy` stays light (spec §10 item 10). from metpy.calc import dry_lapse # noqa: PLC0415 corrected_temperature = dry_lapse( corrected, start_temperature, reference_pressure=start_pressure ) return corrected, corrected_temperature.to("degC") def _require_moist_ascent(snd: Sounding, lcl_pressure: pint.Quantity) -> None: """Require the profile to extend above the LCL the ascent uses. Parameters ---------- snd : Sounding The environment sounding. lcl_pressure : pint.Quantity Scalar pressure of the LCL the ascent uses. Raises ------ ProfileTooShortError If the profile tops out at or below the LCL — no moist ascent exists (spec §6). """ top = float(snd.pressure[-1].m_as("hPa")) lcl_hpa = float(lcl_pressure.m_as("hPa")) if top >= lcl_hpa: msg = ( f"the profile tops out at {top:g} hPa, at or below the parcel's " f"{lcl_hpa:g} hPa LCL: no moist ascent exists" ) raise ProfileTooShortError(msg)
[docs] def indices( snd: Sounding, *, parcel: Literal["surface", "mixed-layer"] = "surface", cloud_base_correction: object = None, ) -> SoundingIndices: """Compute the derived thermodynamic parameters (spec §3.3). The mechanism: derive the parcel curve on the environment levels under the same parcel-selection and correction rules as :func:`parcel_path`, then feed it to the generic :mod:`metpy.calc` functions that take a parcel-profile argument (``cape_cin``, ``lfc``, ``el``, ``lifted_index``). With the defaults this reduces to plain surface-parcel delegation. The ``lcl_*`` fields report the point the path uses (corrected when requested) and `theta_w` the parcel start, mirroring :class:`Profile`. `theta_w` is computed with ``wet_bulb_potential_temperature``, whose Davies-Jones formulation differs from the moist-adiabat integrator by ≲0.1 °C: the path is drawn by the integrator, the number by the named function (spec §3.3). Parameters ---------- snd : Sounding The environment sounding; must carry dewpoint. parcel : str, default: "surface" The lifted parcel, as for :func:`parcel_path`. cloud_base_correction : pint.Quantity, optional The LCL correction, as for :func:`parcel_path`. Returns ------- SoundingIndices The ten derived parameters, with the spec §6 NaN-versus-zero semantics documented per field. Raises ------ MissingDataError If the sounding has no dewpoint. ProfileTooShortError If the profile tops out at or below the LCL the parcel would use, or a mixed-layer parcel is requested but the sounding spans less than the mixed-layer depth. TephpyUnitsError If `cloud_base_correction` is not a pressure-dimension quantity. TephpyValidationError If the selected parcel start has an undefined (NaN) temperature or dewpoint, or the correction places the LCL below the parcel start. ValueError If `parcel` is not a known option. """ start_pressure, start_temperature, start_dewpoint = _parcel_start(snd, parcel) lcl_pressure, lcl_temperature = _lcl_used( start_pressure, start_temperature, start_dewpoint, cloud_base_correction ) _require_moist_ascent(snd, lcl_pressure) curve = _parcel_curve( snd, start_pressure, start_temperature, start_dewpoint, lcl_pressure, lcl_temperature, corrected=cloud_base_correction is not None, ) # Function-local so `import tephpy` stays light (spec §3.3, §10 item 10). from metpy.calc import ( # noqa: PLC0415 cape_cin, el, lfc, lifted_index, wet_bulb_potential_temperature, ) cape, cin = cape_cin(snd.pressure, snd.temperature, snd.dewpoint, curve) lfc_pressure, lfc_temperature = lfc( snd.pressure, snd.temperature, snd.dewpoint, parcel_temperature_profile=curve ) el_pressure, el_temperature = el( snd.pressure, snd.temperature, snd.dewpoint, parcel_temperature_profile=curve ) with warnings.catch_warnings(): # A profile topping out below 500 hPa makes the index NaN *with* a # UserWarning; the NaN field is the answer (spec §6, §10 item 11). warnings.filterwarnings( "ignore", message=_OUT_OF_BOUNDS_MESSAGE, category=UserWarning ) lifted = lifted_index(snd.pressure, snd.temperature, curve)[0] theta_w = wet_bulb_potential_temperature( start_pressure, start_temperature, start_dewpoint ) return SoundingIndices( cape=cape.to("J/kg"), cin=cin.to("J/kg"), lcl_pressure=lcl_pressure, lcl_temperature=lcl_temperature, lfc_pressure=lfc_pressure.to("hPa"), lfc_temperature=lfc_temperature.to("degC"), el_pressure=el_pressure.to("hPa"), el_temperature=el_temperature.to("degC"), theta_w=theta_w.to("degC"), lifted_index=lifted.to("delta_degC"), )
def _parcel_curve( # noqa: PLR0913 snd: Sounding, start_pressure: pint.Quantity, start_temperature: pint.Quantity, start_dewpoint: pint.Quantity, lcl_pressure: pint.Quantity, lcl_temperature: pint.Quantity, *, corrected: bool, ) -> pint.Quantity: """Derive the parcel curve on the environment levels (spec §3.3). Uncorrected ascents delegate to ``metpy.calc.parcel_profile`` — the plain delegation the spec §7 field-equality test targets. A corrected ascent has no MetPy one-liner: its curve is the dry adiabat from the parcel start on the levels at or below the corrected LCL, and the corrected-LCL-anchored moist adiabat above. Parameters ---------- snd : Sounding The environment sounding. start_pressure : pint.Quantity Scalar parcel-start pressure. start_temperature : pint.Quantity Scalar parcel-start temperature. start_dewpoint : pint.Quantity Scalar parcel-start dewpoint. lcl_pressure : pint.Quantity Scalar pressure of the LCL the ascent uses. lcl_temperature : pint.Quantity Scalar temperature at that LCL. corrected : bool Whether a cloud-base correction was requested. Returns ------- pint.Quantity Parcel temperatures on the environment pressure levels. """ # Function-local so `import tephpy` stays light (spec §3.3, §10 item 10). from metpy.calc import dry_lapse, moist_lapse, parcel_profile # noqa: PLC0415 from metpy.units import units as registry # noqa: PLC0415 if not corrected: return parcel_profile(snd.pressure, start_temperature, start_dewpoint) pressure = snd.pressure.m_as("hPa") below = pressure >= float(lcl_pressure.m_as("hPa")) curve = np.empty(pressure.size, dtype=np.float64) if below.any(): curve[below] = dry_lapse( registry.Quantity(pressure[below], "hPa"), start_temperature, reference_pressure=start_pressure, ).m_as("degC") curve[~below] = moist_lapse( registry.Quantity(pressure[~below], "hPa"), lcl_temperature, reference_pressure=lcl_pressure, ).m_as("degC") return registry.Quantity(curve, "degC")