Source code for tephpy.plotting.shading

# 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.
"""CAPE/CIN shading geometry for the tephigram (spec §3.2).

Free builders in the ``isopleths`` pattern: pure functions over bare numpy
arrays in diagram-native units (hPa, °C — the spec §5 exemption) that
return closed polygons in (temperature, theta) space, headlessly testable.
``TephigramAxes.shade_cape``/``shade_cin`` draw them through the tephigram
transform as one compound-path ``PathPatch`` per call.

Both curves are sampled onto their merged pressure grid along the drawn
polylines — the straight segments in tephigram (x, y) space that
matplotlib draws between profile levels — with a vertex inserted at each
exact segment intersection, so the fill closes on the plotted lines at
every scale (any other interpolation bows away from the drawn chords
between levels). The regions are bounded as :func:`metpy.calc.cape_cin`
integrates (its ``which_lfc="bottom"``/``which_el="top"`` defaults): CAPE is the
positive-buoyancy region between the LFC — the bottom of the lowest
positive run at or above the LCL — and the EL — the top of the highest
such run, which is the profile top while the parcel is still buoyant
there; CIN is the negative-buoyancy region between the parcel start and
the LFC. With no LFC there is neither region (``cape_cin`` returns
``0 J/kg`` for both). Two documented divergences from the *numbers*:
``cape_cin`` finds its bounds on virtual-temperature profiles and
integrates the net virtual-temperature difference (Doswell & Rasmussen
1994), neither of which the plotted temperature curves can show — the
shading is the drawn-curve region between the same rules' bounds, and the
annotated J/kg number remains the quantitative truth.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
import numpy.typing as npt

from tephpy import transforms
from tephpy._constants import KAPPA, KELVIN_ZERO, MA, P_REF

if TYPE_CHECKING:
    from collections.abc import Iterator

__all__ = ["cape_polygons", "cin_polygons"]


[docs] def cape_polygons( pressure: npt.ArrayLike, temperature: npt.ArrayLike, parcel_pressure: npt.ArrayLike, parcel_temperature: npt.ArrayLike, *, lcl_pressure: float, ) -> list[npt.NDArray[np.float64]]: """Build the CAPE region's closed polygons (spec §3.2). Parameters ---------- pressure : ArrayLike Environment pressures in hPa, strictly decreasing. temperature : ArrayLike Environment temperatures in degrees Celsius; NaN gaps break the region. parcel_pressure : ArrayLike Parcel-path pressures in hPa, strictly decreasing. parcel_temperature : ArrayLike Parcel-path temperatures in degrees Celsius. lcl_pressure : float Pressure of the LCL the parcel uses, in hPa; buoyancy below it never counts towards CAPE. Returns ------- list of numpy.ndarray One ``(N, 2)`` closed polygon in (temperature, theta) space per uninterrupted positive-buoyancy run — plural when the region is interrupted, empty when there is no CAPE (0 is an answer, not an error; spec §6). """ p, env, parcel = _merged_curves( pressure, temperature, parcel_pressure, parcel_temperature ) regions = [ (lo, hi) for lo, hi in _regions(p, parcel - env, positive=True) if p[hi - 1] < lcl_pressure ] polygons = [] for lo, hi in regions: clipped = _clip_pressure_span( p[lo:hi], env[lo:hi], parcel[lo:hi], bottom=lcl_pressure ) if clipped is not None: polygons.append(_polygon(*clipped)) return polygons
[docs] def cin_polygons( pressure: npt.ArrayLike, temperature: npt.ArrayLike, parcel_pressure: npt.ArrayLike, parcel_temperature: npt.ArrayLike, *, lcl_pressure: float, ) -> list[npt.NDArray[np.float64]]: """Build the CIN region's closed polygons (spec §3.2). Parameters ---------- pressure : ArrayLike Environment pressures in hPa, strictly decreasing. temperature : ArrayLike Environment temperatures in degrees Celsius; NaN gaps break the region. parcel_pressure : ArrayLike Parcel-path pressures in hPa, strictly decreasing. parcel_temperature : ArrayLike Parcel-path temperatures in degrees Celsius. lcl_pressure : float Pressure of the LCL the parcel uses, in hPa; it locates the LFC that bounds the region. Returns ------- list of numpy.ndarray One ``(N, 2)`` closed polygon in (temperature, theta) space per uninterrupted negative-buoyancy run between the parcel start and the LFC — empty when there is no LFC (``cape_cin`` reports both CAPE and CIN as zero then) or no inhibition below it. """ p, env, parcel = _merged_curves( pressure, temperature, parcel_pressure, parcel_temperature ) diff = parcel - env lfc_pressure = _lfc(p, diff, lcl_pressure) if lfc_pressure is None: return [] regions = [ (lo, hi) for lo, hi in _regions(p, diff, positive=False) if p[lo] > lfc_pressure ] polygons = [] for lo, hi in regions: clipped = _clip_pressure_span( p[lo:hi], env[lo:hi], parcel[lo:hi], top=lfc_pressure ) if clipped is not None: polygons.append(_polygon(*clipped)) return polygons
def _merged_curves( pressure: npt.ArrayLike, temperature: npt.ArrayLike, parcel_pressure: npt.ArrayLike, parcel_temperature: npt.ArrayLike, ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]]: """Sample both curves onto their merged pressure grid. Both curves are sampled along their drawn polylines (straight segments in tephigram (x, y) space between profile levels) onto the union of their pressure levels over the overlapping span, and a vertex is inserted at each exact intersection of the drawn segments so every run of one buoyancy sign starts and ends on a zero-difference vertex (or a span end; or its own grid vertices for a pathological sign flip whose segments never cross). Parameters ---------- pressure : ArrayLike Environment pressures in hPa, strictly decreasing. temperature : ArrayLike Environment temperatures in degrees Celsius. parcel_pressure : ArrayLike Parcel-path pressures in hPa, strictly decreasing. parcel_temperature : ArrayLike Parcel-path temperatures in degrees Celsius. Returns ------- tuple of numpy.ndarray ``(pressure, temperature, parcel_temperature)`` on the merged, crossing-augmented grid, pressure strictly decreasing. """ env_p = np.asarray(pressure, dtype=np.float64) env_t = np.asarray(temperature, dtype=np.float64) path_p = np.asarray(parcel_pressure, dtype=np.float64) path_t = np.asarray(parcel_temperature, dtype=np.float64) top = max(env_p.min(), path_p.min()) bottom = min(env_p.max(), path_p.max()) grid = np.unique(np.concatenate([env_p, path_p])) grid = grid[(grid >= top) & (grid <= bottom)][::-1] env = _polyline_interpolate(env_p, env_t, grid) parcel = _polyline_interpolate(path_p, path_t, grid) diff = parcel - env crossing = np.flatnonzero(diff[:-1] * diff[1:] < 0.0) if crossing.size: crossing, p_cross, t_cross = _segment_intersections(grid, env, parcel, crossing) if crossing.size: grid = np.insert(grid, crossing + 1, p_cross) env = np.insert(env, crossing + 1, t_cross) parcel = np.insert(parcel, crossing + 1, t_cross) return grid, env, parcel def _polyline_interpolate( pressure: npt.NDArray[np.float64], temperature: npt.NDArray[np.float64], targets: npt.NDArray[np.float64], ) -> npt.NDArray[np.float64]: """Temperature at target pressures on a curve's drawn polyline. The drawn curve is straight in tephigram (x, y) space between profile levels, so temperature and ln theta_K are both linear along a segment while pressure varies nonlinearly; the point at a target pressure is located by bisection on ``ln T_K(s) - ln theta_K(s) = kappa ln (p / P_REF)``, which is concave in the segment parameter and bracketed by the endpoints. Targets that hit a profile level exactly return that level's temperature (NaN levels propagate to their segments only, as ``np.interp`` did). Parameters ---------- pressure : numpy.ndarray The curve's pressures in hPa, strictly decreasing. temperature : numpy.ndarray The curve's temperatures in degrees Celsius. targets : numpy.ndarray Pressures to sample at, in hPa, within the curve's span. Returns ------- numpy.ndarray Temperatures in degrees Celsius on the drawn polyline. """ u = -np.log(pressure) u_targets = -np.log(np.asarray(targets, dtype=np.float64)) theta = transforms.theta_from_pressure_temperature(pressure, temperature) ln_theta_k = np.log(theta + KELVIN_ZERO) segment = np.clip(np.searchsorted(u, u_targets, side="right") - 1, 0, u.size - 2) t0 = temperature[segment] + KELVIN_ZERO t1 = temperature[segment + 1] + KELVIN_ZERO l0 = ln_theta_k[segment] l1 = ln_theta_k[segment + 1] goal = -KAPPA * (u_targets + np.log(P_REF)) low = np.zeros_like(goal) high = np.ones_like(goal) with np.errstate(invalid="ignore", divide="ignore"): for _ in range(60): mid = 0.5 * (low + high) above = np.log(t0 + mid * (t1 - t0)) - (l0 + mid * (l1 - l0)) >= goal low = np.where(above, mid, low) high = np.where(above, high, mid) s = 0.5 * (low + high) result = t0 + s * (t1 - t0) - KELVIN_ZERO # Exact level hits stay exact, whatever their neighbours. index = np.minimum(np.searchsorted(u, u_targets), u.size - 1) hit = u[index] == u_targets return np.asarray(np.where(hit, temperature[index], result), dtype=np.float64) def _segment_intersections( pressure: npt.NDArray[np.float64], temperature: npt.NDArray[np.float64], parcel_temperature: npt.NDArray[np.float64], crossing: npt.NDArray[np.intp], ) -> tuple[npt.NDArray[np.intp], npt.NDArray[np.float64], npt.NDArray[np.float64]]: """Intersect the drawn curves over each sign-change grid cell. Within one merged-grid cell both curves are straight segments in tephigram (x, y) space, and a buoyancy sign change across the cell means they cross inside it whenever pressure is monotone along both chords — the physical case; the intersection point then lies on both drawn polylines. A pathological chord (an extreme temperature swing over a near-isobaric step) can flip the sign at equal pressures while the segments stay disjoint, so a cell is kept only when the intersection sits within both segments — fabricating a vertex there would put it on neither polyline and break the strictly-decreasing grid. Skipped cells leave their run ending on grid vertices instead of a pinch. Parameters ---------- pressure : numpy.ndarray The merged pressure grid, strictly decreasing. temperature : numpy.ndarray Environment temperatures on that grid. parcel_temperature : numpy.ndarray Parcel temperatures on that grid. crossing : numpy.ndarray Indices of the cells whose buoyancy difference changes sign. Returns ------- tuple of numpy.ndarray The kept cell indices and the ``(pressure, temperature)`` of one intersection per kept cell. """ env_xy = np.column_stack( transforms.xy_from_temperature_theta( temperature, transforms.theta_from_pressure_temperature(pressure, temperature), ) ) parcel_xy = np.column_stack( transforms.xy_from_temperature_theta( parcel_temperature, transforms.theta_from_pressure_temperature(pressure, parcel_temperature), ) ) env_span = env_xy[crossing + 1] - env_xy[crossing] parcel_span = parcel_xy[crossing + 1] - parcel_xy[crossing] gap = parcel_xy[crossing] - env_xy[crossing] with np.errstate(invalid="ignore", divide="ignore"): # 2D cross products: the segment parameters of the intersection. determinant = ( env_span[:, 0] * parcel_span[:, 1] - env_span[:, 1] * parcel_span[:, 0] ) s = ( gap[:, 0] * parcel_span[:, 1] - gap[:, 1] * parcel_span[:, 0] ) / determinant u = (gap[:, 0] * env_span[:, 1] - gap[:, 1] * env_span[:, 0]) / determinant inside = np.isfinite(s) & np.isfinite(u) tolerance = 1e-9 for parameter in (s, u): inside &= (parameter >= -tolerance) & (parameter <= 1.0 + tolerance) kept = crossing[inside] s = np.clip(s[inside], 0.0, 1.0) point = env_xy[kept] + s[:, None] * env_span[inside] t_cross = (point[:, 0] - point[:, 1]) / 2.0 ln_theta_k = (point[:, 0] + point[:, 1]) / (2.0 * MA) p_cross = P_REF * np.exp((np.log(t_cross + KELVIN_ZERO) - ln_theta_k) / KAPPA) p_cross = np.clip(p_cross, pressure[kept + 1], pressure[kept]) return kept, p_cross, t_cross def _regions( pressure: npt.NDArray[np.float64], diff: npt.NDArray[np.float64], *, positive: bool, ) -> Iterator[tuple[int, int]]: """Locate the uninterrupted runs of one buoyancy sign. Each run is widened to the adjacent zero-difference crossing vertices so its polygon closes exactly on the drawn curves; runs too short to enclose area are dropped. Parameters ---------- pressure : numpy.ndarray The merged, crossing-augmented pressure grid. diff : numpy.ndarray Parcel minus environment temperature on that grid. positive : bool Select positive-buoyancy runs (CAPE) or negative ones (CIN). Yields ------ tuple of int Half-open ``(start, stop)`` index bounds of one region. """ mask = diff > 0.0 if positive else diff < 0.0 padded = np.concatenate([[False], mask, [False]]) edges = np.flatnonzero(padded[1:] != padded[:-1]) for start, stop in zip(edges[::2], edges[1::2], strict=True): lo = int(start) hi = int(stop) if lo > 0 and diff[lo - 1] == 0.0: lo -= 1 if hi < diff.size and diff[hi] == 0.0: hi += 1 if hi - lo >= 2 and pressure[lo] > pressure[hi - 1]: yield lo, hi def _lfc( pressure: npt.NDArray[np.float64], diff: npt.NDArray[np.float64], lcl_pressure: float, ) -> float | None: """Locate the LFC bound the way ``cape_cin`` does (spec §3.2). The bottom of the lowest positive-buoyancy run reaching above the LCL, clamped to the LCL itself when that run starts below it (``which_lfc="bottom"`` semantics on the drawn curves). Parameters ---------- pressure : numpy.ndarray The merged, crossing-augmented pressure grid. diff : numpy.ndarray Parcel minus environment temperature on that grid. lcl_pressure : float Pressure of the LCL the parcel uses, in hPa. Returns ------- float or None The LFC pressure in hPa, or ``None`` when the parcel never becomes positively buoyant at or above the LCL. """ for lo, hi in _regions(pressure, diff, positive=True): if pressure[hi - 1] < lcl_pressure: return min(float(pressure[lo]), lcl_pressure) return None def _clip_pressure_span( pressure: npt.NDArray[np.float64], temperature: npt.NDArray[np.float64], parcel_temperature: npt.NDArray[np.float64], *, bottom: float | None = None, top: float | None = None, ) -> ( tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]] | None ): """Trim one region to a pressure span, keeping the cut level exact. Parameters ---------- pressure : numpy.ndarray The region's pressures, strictly decreasing. temperature : numpy.ndarray Environment temperatures on those levels. parcel_temperature : numpy.ndarray Parcel temperatures on those levels. bottom : float, optional Keep only ``pressure <= bottom``, inserting the exact cut level (the CAPE clip at the LCL). top : float, optional Keep only ``pressure >= top``, inserting the exact cut level (the CIN clip at the LFC). Returns ------- tuple of numpy.ndarray or None The clipped ``(pressure, temperature, parcel_temperature)``, or ``None`` when fewer than two levels survive. """ p, env, parcel = pressure, temperature, parcel_temperature if bottom is not None and p[0] > bottom: keep = p <= bottom cut = _interpolated_level(p, env, parcel, bottom) p = np.concatenate([[bottom], p[keep]]) env = np.concatenate([[cut[0]], env[keep]]) parcel = np.concatenate([[cut[1]], parcel[keep]]) if top is not None and p[-1] < top: keep = p >= top cut = _interpolated_level(p, env, parcel, top) p = np.concatenate([p[keep], [top]]) env = np.concatenate([env[keep], [cut[0]]]) parcel = np.concatenate([parcel[keep], [cut[1]]]) if p.size < 2 or p[0] <= p[-1]: return None return p, env, parcel def _interpolated_level( pressure: npt.NDArray[np.float64], temperature: npt.NDArray[np.float64], parcel_temperature: npt.NDArray[np.float64], level: float, ) -> tuple[float, float]: """Interpolate both curves at one pressure level, on the drawn chords. The region's grid points already lie on the drawn polylines, and consecutive points share a drawn segment, so sampling between them stays on the plotted curves. Parameters ---------- pressure : numpy.ndarray The region's pressures, strictly decreasing. temperature : numpy.ndarray Environment temperatures on those levels. parcel_temperature : numpy.ndarray Parcel temperatures on those levels. level : float The pressure to interpolate at, in hPa. Returns ------- tuple of float The ``(environment, parcel)`` temperatures at `level`. """ at = np.array([level], dtype=np.float64) return ( float(_polyline_interpolate(pressure, temperature, at)[0]), float(_polyline_interpolate(pressure, parcel_temperature, at)[0]), ) def _polygon( pressure: npt.NDArray[np.float64], temperature: npt.NDArray[np.float64], parcel_temperature: npt.NDArray[np.float64], ) -> npt.NDArray[np.float64]: """Close one region into a (temperature, theta) polygon. Up the parcel curve, then back down the environment curve; the vertices are ready for the tephigram transform (no duplicate closing vertex — the drawing side appends ``CLOSEPOLY``). Parameters ---------- pressure : numpy.ndarray The region's pressures, strictly decreasing. temperature : numpy.ndarray Environment temperatures on those levels. parcel_temperature : numpy.ndarray Parcel temperatures on those levels. Returns ------- numpy.ndarray The closed ``(N, 2)`` polygon in (temperature, theta) space. """ parcel_theta = transforms.theta_from_pressure_temperature( pressure, parcel_temperature ) env_theta = transforms.theta_from_pressure_temperature(pressure, temperature) vertices_t = np.concatenate([parcel_temperature, temperature[::-1]]) vertices_theta = np.concatenate([parcel_theta, env_theta[::-1]]) return np.column_stack([vertices_t, vertices_theta])