# 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.
"""The tephigram matplotlib projection.
``TephigramAxes`` (registered as the ``"tephigram"`` projection) uses the
native rotated x-y plane as its data space, with the temperature/theta
mapping exposed as an invertible matplotlib transform and the five
background isopleth families drawn by default as zoom-aware artists
(spec §3.2.1).
Side-of-axes layout contract (spec §10 item 7): panels beside the diagram
are appended with ``mpl_toolkits.axes_grid1``'s axes divider, which tracks
the equal-aspect box height — right side, inside-out: the wind-barb
gutter, then the indices panel. One divider is created per axes, cached,
and shared by every side-panel method; ``_relayout_side_panels`` rebuilds
the divider's horizontal stack and reassigns every locator whenever a
panel appears, so the inside-out order holds regardless of the order the
panel methods are called in (spec §3.2.7).
Notes
-----
.. versionadded:: 0.1.0
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any, Final, cast, overload
import warnings
from matplotlib.axes import Axes
import matplotlib.colors as mcolors
from matplotlib.figure import FigureBase
from matplotlib.patches import PathPatch
from matplotlib.path import Path
from matplotlib.projections import register_projection
from matplotlib.ticker import AutoLocator, NullLocator, ScalarFormatter
import matplotlib.transforms as mtransforms
from mpl_toolkits.axes_grid1 import axes_size, make_axes_locatable
import numpy as np
import numpy.typing as npt
from tephpy import transforms
from tephpy._config import config
from tephpy._constants import (
BARB_GUTTER_PAD,
BARB_GUTTER_WIDTH,
BARB_MIN_SEPARATION,
BARB_STAFF_POSITION,
CAPE_COLOR,
CIN_COLOR,
CURSOR_FIELDS,
DEFAULT_EXTENT,
DEFAULT_FIT_MARGIN,
EDGE_AXIS_TITLES,
EDGE_LABEL_GUTTER_PAD,
EDGE_TICK_LENGTH,
EDGE_TICK_PAD,
EDGES,
INDICES_PANEL_FONTSIZE,
INDICES_PANEL_PAD,
INDICES_PANEL_ROWS,
INDICES_PANEL_WIDTH,
LABEL_FONTSIZE,
PROFILE_DEWPOINT_COLOR,
PROFILE_LINEWIDTH,
PROFILE_TEMPERATURE_COLOR,
PROFILE_ZORDER,
SHADING_ALPHA,
SHADING_ZORDER,
Y_MAXIMUM_TEMPERATURE,
)
from tephpy._units import as_quantity, check_units_mapping
from tephpy.exceptions import MissingDataError, TephpyValidationError
from tephpy.plotting import shading
from tephpy.plotting.barbs import BarbStaff
from tephpy.plotting.isopleths import (
_FAMILY_SPECS,
IsoplethFamily,
_EdgeFormatter,
_EdgeLocator,
)
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Mapping
from matplotlib.axes._secondary_axes import SecondaryAxis
from matplotlib.axis import Axis
from matplotlib.lines import Line2D
from mpl_toolkits.axes_grid1.axes_divider import AxesDivider
from tephpy.calc import Profile, SoundingIndices
from tephpy.plotting.isopleths import ResolvedOptions
from tephpy.sounding import Sounding
__all__ = ["TephigramAxes", "TephigramInvertedTransform", "TephigramTransform"]
#: ``Figure.clear``'s frame, recognised so the side-panel teardown can
#: stand down for that caller (spec §3.2.7; see
#: ``TephigramAxes._figure_is_clearing``).
_FIGURE_CLEAR_CODE = FigureBase.clear.__code__
def _cursor_pressure(pressure: float, _temperature: float, _theta: float) -> str:
"""Format the cursor point's pressure (spec §3.2.6).
Parameters
----------
pressure : float
Cursor pressure in hPa.
_temperature : float
Ignored; the uniform registry signature.
_theta : float
Ignored; the uniform registry signature.
Returns
-------
str
The pressure readout, whole hPa.
"""
return f"{pressure:.0f} hPa"
def _cursor_temperature(_pressure: float, temperature: float, _theta: float) -> str:
"""Format the cursor point's temperature (spec §3.2.6).
Parameters
----------
_pressure : float
Ignored; the uniform registry signature.
temperature : float
Cursor temperature in degrees Celsius.
_theta : float
Ignored; the uniform registry signature.
Returns
-------
str
The temperature readout, one decimal.
"""
return f"{temperature:.1f} °C"
def _cursor_theta(_pressure: float, _temperature: float, theta: float) -> str:
"""Format the cursor point's potential temperature (spec §3.2.6).
Parameters
----------
_pressure : float
Ignored; the uniform registry signature.
_temperature : float
Ignored; the uniform registry signature.
theta : float
Cursor potential temperature in degrees Celsius.
Returns
-------
str
The potential-temperature readout, one decimal.
"""
return f"θ {theta:.1f} °C"
def _cursor_mixing_ratio(pressure: float, temperature: float, _theta: float) -> str:
"""Format the saturation mixing ratio through the cursor point (spec §3.2.6).
Parameters
----------
pressure : float
Cursor pressure in hPa.
temperature : float
Cursor temperature in degrees Celsius.
_theta : float
Ignored; the uniform registry signature.
Returns
-------
str
The mixing-ratio readout in g/kg, one decimal, or ``""`` when
undefined (total pressure is less than the saturation vapour
pressure at that temperature).
Notes
-----
MetPy is imported on first use; the first call has a small import cost.
"""
# Function-local so `import tephpy` stays light (spec §10 item 10).
from metpy.calc import saturation_mixing_ratio # noqa: PLC0415
from metpy.units import units as registry # noqa: PLC0415
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
warnings.simplefilter("ignore", RuntimeWarning)
ratio = saturation_mixing_ratio(
registry.Quantity(pressure, "hPa"), registry.Quantity(temperature, "degC")
).m_as("g/kg")
if not math.isfinite(float(ratio)):
return ""
return f"{float(ratio):.1f} g/kg"
def _cursor_theta_w(pressure: float, temperature: float, _theta: float) -> str:
"""Format the moist adiabat (θw) through the cursor point (spec §3.2.6).
The point is treated as saturated (``dewpoint=temperature``), giving
the wet-bulb potential temperature of the pseudoadiabat through it —
the moist-adiabat family's member value (the spec §3.2.6/§3.3
one-source-of-truth idiom).
Parameters
----------
pressure : float
Cursor pressure in hPa.
temperature : float
Cursor temperature in degrees Celsius.
_theta : float
Ignored; the uniform registry signature.
Returns
-------
str
The wet-bulb potential-temperature readout, one decimal, or ``""``
when undefined (total pressure is less than the saturation vapour
pressure at that temperature).
Notes
-----
MetPy is imported on first use; the first call has a small import cost.
``theta_w`` integrates the pseudoadiabat numerically per event.
"""
# Function-local so `import tephpy` stays light (spec §10 item 10).
from metpy.calc import wet_bulb_potential_temperature # noqa: PLC0415
from metpy.units import units as registry # noqa: PLC0415
quantity = registry.Quantity
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
warnings.simplefilter("ignore", RuntimeWarning)
theta_w = wet_bulb_potential_temperature(
quantity(pressure, "hPa"),
quantity(temperature, "degC"),
quantity(temperature, "degC"),
).m_as("degC")
if not math.isfinite(float(theta_w)):
return ""
return f"θw {float(theta_w):.1f} °C"
#: The cursor readout field registry (spec §3.2.6): field name to a
#: ``(pressure, temperature, theta) -> str`` formatter.
_CURSOR_FORMATTERS: Final[dict[str, Callable[[float, float, float], str]]] = {
"pressure": _cursor_pressure,
"temperature": _cursor_temperature,
"theta": _cursor_theta,
"mixing_ratio": _cursor_mixing_ratio,
"theta_w": _cursor_theta_w,
}
def _limits_from_ranges(
pressure: tuple[float, float], temperature: tuple[float, float]
) -> tuple[float, float, float, float]:
"""Map a named (pressure, temperature) region to x/y limits.
All four corners of the region are mapped, not the two a caller
happens to write: the view is an axis-aligned rectangle in a rotated
space, so the bounding box of two *points* need not contain the
region they delimit (framing spec §1, §3.1). The four corners are not
enough either: at fixed pressure the y-coordinate has an interior
maximum at ``Y_MAXIMUM_TEMPERATURE``, so whenever the temperature
range spans it strictly, the two points at that temperature and each
pressure bound join the candidate set (framing spec §3.1).
Parameters
----------
pressure : tuple of float
Pressure bounds in hPa, in either order.
temperature : tuple of float
Temperature bounds in degrees Celsius, in either order.
Returns
-------
tuple of float
``(xlo, xhi, ylo, yhi)`` in data space.
"""
p_lo, p_hi = sorted(pressure)
t_lo, t_hi = sorted(temperature)
pressures = [p_lo, p_lo, p_hi, p_hi]
temperatures = [t_lo, t_hi, t_lo, t_hi]
if t_lo < Y_MAXIMUM_TEMPERATURE < t_hi:
pressures.extend([p_lo, p_hi])
temperatures.extend([Y_MAXIMUM_TEMPERATURE, Y_MAXIMUM_TEMPERATURE])
pressure_arr = np.array(pressures, dtype=np.float64)
temperature_arr = np.array(temperatures, dtype=np.float64)
thetas = transforms.theta_from_pressure_temperature(pressure_arr, temperature_arr)
x, y = transforms.xy_from_temperature_theta(temperature_arr, thetas)
return float(np.min(x)), float(np.max(x)), float(np.min(y)), float(np.max(y))
def _framing_coordinates(
obj: object,
) -> tuple[npt.NDArray[np.float64], list[npt.NDArray[np.float64]]]:
"""Pressure and the temperature-like values that bound a view.
The only place ``fit`` knows what a ``Sounding`` or a ``Profile`` is,
so a third plottable is taught here rather than inside ``fit``
(framing spec §3.2). Wind is absent by design: ``plot_barbs`` draws
into the gutter, so it is not a coordinate of the plane.
Parameters
----------
obj : object
A ``Sounding`` or a ``calc.Profile``.
Returns
-------
tuple
Pressures in hPa, and a list of temperature-like arrays in
degrees Celsius.
Raises
------
TephpyValidationError
If the object is neither.
"""
# ``Sounding`` and ``Profile`` are TYPE_CHECKING-only imports in this
# module, so they are not bound at runtime; import them here rather than
# widening the module's import graph for one isinstance check. Neither
# imports ``plotting``, so this cannot cycle -- verified 2026-08-25.
from tephpy.calc import Profile # noqa: PLC0415
from tephpy.sounding import Sounding # noqa: PLC0415
if isinstance(obj, Sounding):
temperatures = [obj.temperature.to("degC").magnitude]
if obj.dewpoint is not None:
temperatures.append(obj.dewpoint.to("degC").magnitude)
return obj.pressure.to("hPa").magnitude, temperatures
if isinstance(obj, Profile):
return (
obj.pressure.to("hPa").magnitude,
[obj.temperature.to("degC").magnitude],
)
msg = f"fit() takes a Sounding or Profile, not {type(obj).__name__}"
raise TephpyValidationError(msg)
[docs]
class TephigramAxes(Axes):
"""Matplotlib axes for the ``"tephigram"`` projection.
The data space is the native rotated x-y plane (dimensionless), with
equal aspect so the isotherm/dry-adiabat grid stays exactly
perpendicular on screen. The five background isopleth families are
drawn by default as zoom-aware artists and reconfigured through the
accessor methods (:meth:`isotherms`, :meth:`isobars`,
:meth:`dry_adiabats`, :meth:`moist_adiabats`, :meth:`mixing_ratios`).
The temperature/theta mapping is exposed as
:attr:`tephigram_transform`; artists plot in (temperature, theta)
space via ``transform=ax.tephigram_transform + ax.transData``. Native
x/y ticks carry no meteorological meaning and are hidden until
a family claims an edge for its labels — ``labels=("bottom", "left")``
turns them into that family's scale (spec §3.2.2).
Notes
-----
.. versionadded:: 0.1.0
"""
name = "tephigram"
tephigram_transform: TephigramTransform
_families: dict[str, IsoplethFamily]
_indices_panel: Axes | None
_barb_gutter: Axes | None
_side_divider: AxesDivider | None
_edge_owners: dict[str, str]
_secondary_axes: dict[str, SecondaryAxis]
_edge_titles: dict[str, str]
#: The owner and RGBA last applied to each claimed edge's ticks, so a
#: sync re-applies only what the owning family actually changed.
#: Survives release, which is what makes a family visibility toggle a
#: true round trip; the owner is part of the key so a new owner's colour
#: still lands when it matches the last one's. Only :meth:`clear` empties
#: it (spec §3.2.3).
_edge_tick_colors: dict[str, tuple[str, tuple[float, float, float, float]]]
#: Re-entrancy guard for ``_sync_edge_labels``; a class default so it is
#: live before ``Axes.__init__`` reaches :meth:`clear`.
_edge_sync_busy: bool = False
[docs]
def clear(self) -> None:
"""Reset the axes to the tephigram projection defaults.
Matplotlib calls this during ``Axes.__init__`` and on user
``ax.clear()``; both paths recreate the projection-owned state:
the tephigram transform, equal aspect, hidden native axes, the
five background isopleth families, any edges they claim for their
labels, and the default extent
(``tephpy.config`` diagram extent, else ``DEFAULT_EXTENT``).
Side panels — the barb gutter and the indices panel — are
removed with the diagram they annotated, except when the figure
is clearing itself: it removes every axes anyway, and it is
iterating a snapshot this one must not delete from.
Raises
------
TypeError
If ``tephpy.config`` gives one diagram edge to two families, or
names an unknown label placement, or carries a malformed family
``emphasis`` — a non-mapping, a member value that will not convert
to float, a style that is not a mapping, or an unknown style key
(spec §3.2).
ValueError
If a ``tephpy.config`` family ``emphasis`` keys a member value
that is not finite, or gives a ``linewidth`` that is not positive
and finite, or an ``alpha`` outside ``[0, 1]``, or a family
``interval`` is not positive and finite.
Notes
-----
.. versionadded:: 0.1.0
"""
super().clear()
self.tephigram_transform = TephigramTransform()
self.set_aspect(1.0, adjustable="box")
self.xaxis.set_visible(False)
self.yaxis.set_visible(False)
# Presentation is stamped once, here, and never re-asserted, so it is
# the user's from a claim onwards (spec §3.2.3).
self._style_edge_axis(self.xaxis)
self._style_edge_axis(self.yaxis)
# The classic style mirrors ticks onto the opposite edge, which would
# collide with that edge's own family. Pinned on the concrete
# ``XAxis``/``YAxis``, whose ``set_ticks_position`` take different
# values, rather than through the ``Axis``-typed helper above.
self.xaxis.set_ticks_position("bottom")
self.yaxis.set_ticks_position("left")
slots = ("_barb_gutter", "_indices_panel")
panels = [getattr(self, name, None) for name in slots]
if any(panel is not None for panel in panels):
if not self._figure_is_clearing():
for panel in panels:
if panel is not None:
panel.remove()
# The stub demands a callable, but None resets the locator
# (the documented matplotlib behaviour).
self.set_axes_locator(None) # type: ignore[arg-type]
self._indices_panel = None
self._barb_gutter = None
self._side_divider = None
self._edge_owners = {}
self._secondary_axes = {}
self._edge_titles = {}
self._edge_tick_colors = {}
self._families = {}
for name, spec in _FAMILY_SPECS.items():
# The families arm their ``on_change`` only once constructed, so
# this loop builds all five before the first sync sees any.
family = IsoplethFamily(
spec,
getattr(config, name),
validate=self._check_label_edges,
on_change=self._sync_edge_labels,
)
self.add_artist(family)
self._families[name] = family
self._sync_edge_labels()
extent = config.diagram.extent
self.set_extent(**(DEFAULT_EXTENT if extent is None else extent))
def _figure_is_clearing(self) -> bool:
"""Whether the enclosing figure is the caller of :meth:`clear`.
``Figure.clear`` clears and deletes each entry of a snapshot of
``figure.axes``, so an axes that removes a sibling from inside
its own ``clear`` orphans an entry matplotlib is still about to
visit — the panel is then cleared and deleted with no figure,
raising deep in matplotlib. The side panels are the diagram's to
remove on a direct ``ax.clear()``; on a figure clear they are the
figure's, so the teardown stands down (spec §3.2.7). Recognising
the caller by its frame is the only signal: the figure's state is
identical either way.
Returns
-------
bool
Whether a ``Figure.clear`` of this axes' figure is running.
"""
figure = self.get_figure(root=False)
if figure is None:
return False
# Function-local so `import tephpy` stays light (spec §10 item 10).
import inspect # noqa: PLC0415
frame = inspect.currentframe()
while frame is not None:
if (
frame.f_code is _FIGURE_CLEAR_CODE
and frame.f_locals.get("self") is figure
):
return True
frame = frame.f_back
return False
[docs]
def set_extent(
self,
*,
pressure: tuple[float, float],
temperature: tuple[float, float],
) -> None:
"""Fix the view to a pressure range and a temperature range.
For directly comparable figures (spec §3.2.5). Both ranges are
keyword-only and both are required: two positional sequences that
cannot be told apart is the defect this replaces, and fixing one
axis while leaving the other is a different operation
(framing spec §3.1). Order within a range carries no meaning and is
normalised. Autoscaling is disabled, so later overlays never drift
a window the caller fixed.
The view is an axis-aligned rectangle and pressure is not an axis,
so it always reaches further than the ranges name. For the default
extent the view's other two corners are 84.9 hPa / -137.9 degC and
1058.4 hPa / +77.9 degC. Nothing draws there because it is
unphysical, but the region is reachable and the ranges do not say
so (framing spec §1).
Parameters
----------
pressure : tuple of float
Pressure bounds in hPa, in either order, both finite and above
zero.
temperature : tuple of float
Temperature bounds in degrees Celsius, in either order, both
finite.
Raises
------
ValueError
If either range is non-finite, degenerate, or -- for pressure
-- not above zero. The message names the keyword at fault.
Notes
-----
.. versionadded:: 0.1.0
"""
for name, bounds in (("pressure", pressure), ("temperature", temperature)):
lo, hi = sorted(bounds)
if not (math.isfinite(lo) and math.isfinite(hi)):
msg = f"set_extent {name} bounds must be finite: {bounds!r}"
raise ValueError(msg)
if lo == hi:
msg = f"set_extent {name} range must not be degenerate: {bounds!r}"
raise ValueError(msg)
if name == "pressure" and lo <= 0.0:
msg = f"set_extent pressure bounds must be above 0 hPa: {bounds!r}"
raise ValueError(msg)
xlo, xhi, ylo, yhi = _limits_from_ranges(pressure, temperature)
self.set_xlim(xlo, xhi)
self.set_ylim(ylo, yhi)
self.set_autoscale_on(False)
[docs]
def fit(
self,
*objects: Sounding | Profile,
pressure: tuple[float, float] | None = None,
margin: float | None = None,
) -> None:
"""Frame the view around the data given.
Guarantees that nothing you gave it falls outside the frame. It
does not guarantee a neat-looking diagram: a radiosonde ascent
does not stop at the tropopause, and framing all of one gives a
view whose span is dominated by the stratosphere. ``pressure=``
is what makes it neat -- it names the layer you care about, and
the temperature range is then fitted to the data inside it
(framing spec §3.2).
Takes soundings and parcel paths interchangeably, and frames
everything it is given: a parcel is warmer than its environment
through the CAPE region, so fitting a sounding alone can clip the
path the parcel analysis is drawn to show. Autoscaling is
disabled, as for :meth:`set_extent`.
Parameters
----------
*objects : Sounding or Profile
What to frame. At least one is required.
pressure : tuple of float, optional
Pressure bounds in hPa, in either order, naming the layer to
frame. Levels outside it do not bound the view. When omitted
the whole of every object is framed, which is correct and is
usually wide.
margin : float, optional
Fraction of the fitted span added to each side in the drawn
plane. Resolves keyword > ``config.diagram.margin`` >
``DEFAULT_FIT_MARGIN``. Zero fits exactly. Whichever route it
arrives by, the resolved value must be finite and 0 or more.
Raises
------
MissingDataError
If an argument carries no finite data at all, naming which one
-- checked before any clamp is applied -- or if nothing
survives the ``pressure`` clamp across every argument.
TephpyValidationError
If no objects are given, or one is neither a ``Sounding`` nor
a ``Profile``.
ValueError
If the ``pressure`` clamp is non-finite, degenerate, or not
above zero, or if the resolved ``margin`` is negative or not
finite (framing spec §3.3).
Notes
-----
.. versionadded:: 0.1.0
"""
if not objects:
msg = "fit() needs at least one Sounding or Profile to frame"
raise TephpyValidationError(msg)
if pressure is not None:
for name, bounds in (("pressure", pressure),):
lo, hi = sorted(bounds)
if not (math.isfinite(lo) and math.isfinite(hi)):
msg = f"fit {name} clamp bounds must be finite: {bounds!r}"
raise ValueError(msg)
if lo == hi:
msg = f"fit {name} clamp must not be degenerate: {bounds!r}"
raise ValueError(msg)
if lo <= 0.0:
msg = f"fit pressure clamp bounds must be above 0 hPa: {bounds!r}"
raise ValueError(msg)
pressures: list[npt.NDArray[np.float64]] = []
temperatures: list[npt.NDArray[np.float64]] = []
for index, obj in enumerate(objects, start=1):
level, temps = _framing_coordinates(obj)
level = np.asarray(level, dtype=np.float64)
temp_arrays = [np.asarray(t, dtype=np.float64) for t in temps]
# A per-argument check, made before the clamp below is applied:
# data that simply falls outside `pressure=` is a legitimate
# silent no-op (a caller framing a layer may pass a sounding
# that does not reach it), but no finite temperature-like data
# at all is the caller error framing spec §3.2 forbids. Pressure
# itself is never the tell -- `Sounding` and `Profile` both
# require it finite at construction -- so only the
# temperature-like arrays are checked here.
if not any(np.isfinite(t).any() for t in temp_arrays):
msg = (
f"fit() argument {index} ({type(obj).__name__}) has no "
"finite data to frame"
)
raise MissingDataError(msg)
if pressure is None:
inside = np.ones(level.shape, dtype=bool)
else:
lo, hi = sorted(pressure)
inside = (level >= lo) & (level <= hi)
pressures.append(level[inside])
temperatures.extend(t[inside] for t in temp_arrays)
all_p = np.concatenate(pressures) if pressures else np.array([])
all_t = np.concatenate(temperatures) if temperatures else np.array([])
if not (
all_p.size
and all_t.size
and np.isfinite(all_p).any()
and np.isfinite(all_t).any()
):
msg = "fit() found no finite data to frame"
raise MissingDataError(msg)
if pressure is None:
span_p = (float(np.nanmin(all_p)), float(np.nanmax(all_p)))
else:
span_p = (float(min(pressure)), float(max(pressure)))
xlo, xhi, ylo, yhi = _limits_from_ranges(
span_p, (float(np.nanmin(all_t)), float(np.nanmax(all_t)))
)
if margin is None:
configured = config.diagram.margin
margin = DEFAULT_FIT_MARGIN if configured is None else configured
if not (math.isfinite(margin) and margin >= 0.0):
msg = f"fit margin must be finite and 0 or more: {margin!r}"
raise ValueError(msg)
pad_x = (xhi - xlo) * margin
pad_y = (yhi - ylo) * margin
self.set_xlim(xlo - pad_x, xhi + pad_x)
self.set_ylim(ylo - pad_y, yhi + pad_y)
self.set_autoscale_on(False)
@overload
def plot_profile( # numpydoc ignore=GL08
self,
pressure: Profile,
*,
label: str | None = None,
**kwargs: Any, # noqa: ANN401 -- pass-through to matplotlib
) -> Line2D: ...
@overload
def plot_profile( # numpydoc ignore=GL08
self,
pressure: object,
temperature: object,
*,
units: Mapping[str, str] | None = None,
label: str | None = None,
**kwargs: Any, # noqa: ANN401 -- pass-through to matplotlib
) -> Line2D: ...
[docs]
def plot_profile(
self,
pressure: object,
temperature: object | None = None,
*,
units: Mapping[str, str] | None = None,
label: str | None = None,
**kwargs: Any,
) -> Line2D:
"""Plot one profile of temperature against pressure (spec §3.2.5).
Both arrays are pint quantities — or bare arrays with the
``units=`` mapping (spec §5) — converted to diagram-native units
and plotted through the tephigram transform machinery. Matplotlib
keywords pass through untouched, and out-of-domain values
(pressure <= 0 hPa) propagate NaN, breaking the line (spec §3.1).
The same signature also accepts a ``calc.Profile`` (e.g. the
return of ``calc.parcel_path``) as its only positional argument;
dispatch is duck-typed on the ``Profile`` shape — `temperature`
omitted and ``pressure``/``temperature``/``lcl_pressure``
attributes present. Label precedence in that form: `label`
argument > ``profile.label`` > no entry. In both forms no style
defaults are set — this is the low-level primitive (spec §4
styles parcel paths explicitly at the call site).
Parameters
----------
pressure : pint.Quantity, ArrayLike, or Profile
Level pressures, or the profile to plot.
temperature : pint.Quantity or ArrayLike, optional
Level temperatures; omitted in the ``Profile`` form.
units : mapping of str to str, optional
Unit strings for bare arrays, keyed by argument name, e.g.
``units={"pressure": "hPa", "temperature": "degC"}``; not
accepted in the ``Profile`` form.
label : str, optional
Legend label for the line.
**kwargs : Any
Passed through to :meth:`matplotlib.axes.Axes.plot`.
Returns
-------
matplotlib.lines.Line2D
The profile line.
Raises
------
TephpyUnitsError
For unit-less bare arrays, ambiguous or unparsable units, or
the wrong dimensionality.
TephpyValidationError
If an array holds non-numeric elements (e.g. string
missing-markers) that cannot coerce to float64.
TypeError
For wrong argument combinations: a ``Profile`` together with
`temperature` or ``units=``, or `temperature` omitted when
the sole argument is not ``Profile``-shaped (a bare pressure
array, or a ``Sounding`` passed by mistake).
Notes
-----
.. versionadded:: 0.1.0
"""
profile_shaped = all(
hasattr(pressure, attr)
for attr in ("pressure", "temperature", "lcl_pressure")
)
if profile_shaped:
if temperature is not None:
msg = "plot_profile() takes no separate temperature with a Profile"
raise TypeError(msg)
if units is not None:
msg = "plot_profile() takes no units= with a Profile"
raise TypeError(msg)
profile = cast("Profile", pressure)
pressure = profile.pressure
temperature = profile.temperature
if label is None:
label = profile.label
elif temperature is None:
msg = (
"plot_profile() needs pressure and temperature, or a single "
"Profile as its only positional argument"
)
raise TypeError(msg)
mapping = check_units_mapping(units, allowed=("pressure", "temperature"))
p = as_quantity(
pressure,
name="pressure",
units=mapping.get("pressure"),
dimension="[pressure]",
)
t = as_quantity(
temperature,
name="temperature",
units=mapping.get("temperature"),
dimension="[temperature]",
)
pressure_hpa = p.m_as("hPa")
temperature_c = t.m_as("degC")
theta = transforms.theta_from_pressure_temperature(pressure_hpa, temperature_c)
(line,) = self.plot(
temperature_c,
theta,
transform=self.tephigram_transform + self.transData,
label=label,
**kwargs,
)
return line
[docs]
def plot_sounding(
self,
snd: Sounding,
*,
label: str | None = None,
**kwargs: Any, # noqa: ANN401 -- pass-through to matplotlib
) -> tuple[Line2D, Line2D | None]:
"""Plot a sounding's temperature and dewpoint profiles (spec §3.2.5).
Temperature and dewpoint-when-present draw as two profile lines in
the conventional colours (temperature red, dewpoint green), with
one legend entry per sounding attached to the temperature line.
Label precedence: `label` argument > ``snd.label`` > no entry.
Matplotlib keywords pass through to both lines, overriding the
convention defaults; legends stay stock matplotlib — call
``ax.legend()``.
Parameters
----------
snd : Sounding
The sounding to plot.
label : str, optional
Legend label override.
**kwargs : Any
Passed through to :meth:`matplotlib.axes.Axes.plot` for both
lines.
Returns
-------
tuple of matplotlib.lines.Line2D
``(temperature_line, dewpoint_line)``; the dewpoint line is
``None`` when the sounding has no dewpoint.
Notes
-----
.. versionadded:: 0.1.0
"""
resolved = label if label is not None else snd.label
defaults: dict[str, object] = {
"linewidth": PROFILE_LINEWIDTH,
"zorder": PROFILE_ZORDER,
}
temperature_line = self.plot_profile(
snd.pressure,
snd.temperature,
label=resolved,
**{"color": PROFILE_TEMPERATURE_COLOR, **defaults, **kwargs},
)
dewpoint_line = None
if snd.dewpoint is not None:
dewpoint_line = self.plot_profile(
snd.pressure,
snd.dewpoint,
label="_nolegend_",
**{"color": PROFILE_DEWPOINT_COLOR, **defaults, **kwargs},
)
return temperature_line, dewpoint_line
[docs]
def shade_cape(
self,
snd: Sounding,
parcel: Profile,
**kwargs: Any, # noqa: ANN401 -- pass-through to matplotlib
) -> PathPatch | None:
"""Shade the CAPE area between the sounding and a parcel path.
The positive-buoyancy region between the environment temperature
and the parcel path, bounded as :func:`metpy.calc.cape_cin`
integrates — from the LFC to the EL, to the profile top when the
parcel is still buoyant there — so the shading matches the
annotated numbers (spec §3.2.5). Drawn as one compound-path patch;
interrupted regions become multiple polygons in the same patch.
Parameters
----------
snd : Sounding
The environment sounding.
parcel : Profile
The parcel path, e.g. from ``calc.parcel_path``.
**kwargs : Any
Passed through to :class:`matplotlib.patches.PathPatch`,
overriding the ``_constants`` conventions.
Returns
-------
matplotlib.patches.PathPatch or None
The shaded patch, or ``None`` for zero area — 0 is an
answer, not an error (spec §6).
Notes
-----
.. versionadded:: 0.1.0
"""
return self._shade(snd, parcel, shading.cape_polygons, CAPE_COLOR, kwargs)
[docs]
def shade_cin(
self,
snd: Sounding,
parcel: Profile,
**kwargs: Any, # noqa: ANN401 -- pass-through to matplotlib
) -> PathPatch | None:
"""Shade the CIN area between the sounding and a parcel path.
The negative-buoyancy region between the environment temperature
and the parcel path, bounded as :func:`metpy.calc.cape_cin`
integrates — from the parcel start to the LFC — so the shading
matches the annotated numbers (spec §3.2.5). Drawn as one
compound-path patch; with no LFC there is no CIN region.
Parameters
----------
snd : Sounding
The environment sounding.
parcel : Profile
The parcel path, e.g. from ``calc.parcel_path``.
**kwargs : Any
Passed through to :class:`matplotlib.patches.PathPatch`,
overriding the ``_constants`` conventions.
Returns
-------
matplotlib.patches.PathPatch or None
The shaded patch, or ``None`` for zero area — 0 is an
answer, not an error (spec §6).
Notes
-----
.. versionadded:: 0.1.0
"""
return self._shade(snd, parcel, shading.cin_polygons, CIN_COLOR, kwargs)
def _shade(
self,
snd: Sounding,
parcel: Profile,
builder: Callable[..., list[npt.NDArray[np.float64]]],
facecolor: str,
kwargs: dict[str, Any],
) -> PathPatch | None:
"""Build one shading region and draw it as a compound-path patch.
Parameters
----------
snd : Sounding
The environment sounding.
parcel : Profile
The parcel path.
builder : callable
The ``plotting.shading`` polygon builder to delegate to.
facecolor : str
The region's conventional fill colour.
kwargs : dict
User overrides, passed through to the patch.
Returns
-------
matplotlib.patches.PathPatch or None
The shaded patch, or ``None`` for zero area.
"""
polygons = builder(
snd.pressure.m_as("hPa"),
snd.temperature.m_as("degC"),
parcel.pressure.m_as("hPa"),
parcel.temperature.m_as("degC"),
lcl_pressure=float(parcel.lcl_pressure.m_as("hPa")),
)
if not polygons:
return None
vertices = []
codes = []
for polygon in polygons:
count = polygon.shape[0]
vertices.append(np.vstack([polygon, polygon[:1]]))
codes.append(
np.concatenate(
[[Path.MOVETO], np.full(count - 1, Path.LINETO), [Path.CLOSEPOLY]]
)
)
path = Path(np.vstack(vertices), np.concatenate(codes))
patch = PathPatch(
path,
**{
"facecolor": facecolor,
"edgecolor": "none",
"alpha": SHADING_ALPHA,
"zorder": SHADING_ZORDER,
"transform": self.tephigram_transform + self.transData,
**kwargs,
},
)
self.add_patch(patch)
return patch
def _append_side_axes(
self,
*,
width: str,
pad: float,
**kwargs: Any, # noqa: ANN401 -- pass-through to matplotlib
) -> Axes:
"""Append one side panel through the shared, cached divider.
The divider is created on first use and reused by every
side-panel method — a second ``make_axes_locatable`` call would
build a fresh divider and detach the earlier panel (spec §3.2.7).
The caller stores the returned axes on its slot attribute and
must call :meth:`_relayout_side_panels` afterwards.
Parameters
----------
width : str
The panel width, as an ``axes_grid1`` size (e.g. ``"35%"``).
pad : float
The panel padding from the diagram, in inches.
**kwargs : Any
Passed through to the axes constructor (e.g. ``sharey=``).
Returns
-------
matplotlib.axes.Axes
The appended plain axes.
"""
if self._side_divider is None:
self._side_divider = make_axes_locatable(self)
return cast(
"Axes",
self._side_divider.append_axes(
"right", size=width, pad=pad, axes_class=Axes, **kwargs
),
)
def _relayout_side_panels(self) -> None:
"""Rebuild the divider stack in the contracted inside-out order.
``append_axes`` stacks panels in call order; this rebuilds the
divider's horizontal sizes as diagram, barb gutter, indices
panel — skipping absent panels — and reassigns every locator, so
the spec §3.2.7 order holds regardless of the order the panel
methods were called in. The panel nearest the diagram takes
``EDGE_LABEL_GUTTER_PAD`` in place of its own pad while the right
edge carries isopleth ticks, which are wider than the 0.1 in
conventions (spec §3.2.7).
"""
divider = self._side_divider
if divider is None:
return
horizontal = [axes_size.AxesX(self)]
slots: list[tuple[Axes, int]] = []
panels: tuple[tuple[Axes | None, float, str], ...] = (
(self._barb_gutter, BARB_GUTTER_PAD, BARB_GUTTER_WIDTH),
(self._indices_panel, INDICES_PANEL_PAD, INDICES_PANEL_WIDTH),
)
right_labelled = "right" in self._edge_owners
for panel, pad, width in panels:
if panel is None:
continue
# The first panel abuts the diagram, so it is the one the right
# edge's tick labels would land on (spec §3.2.7).
gap = EDGE_LABEL_GUTTER_PAD if right_labelled and not slots else pad
horizontal.append(axes_size.Fixed(gap))
horizontal.append(axes_size.from_any(width, fraction_ref=horizontal[0]))
slots.append((panel, len(horizontal) - 1))
divider.set_horizontal(horizontal)
self.set_axes_locator(divider.new_locator(nx=0, ny=0))
for panel, nx in slots:
panel.set_axes_locator(divider.new_locator(nx=nx, ny=0))
[docs]
def edge_axis(self, edge: str) -> Axis:
"""Return the matplotlib axis drawing one diagram edge's ticks.
The uniform handle on all four edges (spec §3.2.3), keyed by the same
names the ``labels`` option takes. Bottom and left are the axes' own
``xaxis``/``yaxis``; top and right belong to a secondary axes that
has no other public handle. tephpy stamps its tick conventions on an
edge axis once, when that axis is created, so everything stock
matplotlib offers is the caller's from the claim onwards — e.g.
``ax.edge_axis("top").set_tick_params(labelsize=12)``, or
``set_label_text("")`` to keep the ticks and drop the axis title.
The only thing tephpy changes afterwards is the tick colour, and
only when the owning family's own colour or alpha changes, or
another family takes the edge.
Parameters
----------
edge : str
The edge, one of ``EDGES``.
Returns
-------
matplotlib.axis.Axis
The axis drawing that edge's ticks.
Raises
------
TypeError
If `edge` is not one of ``EDGES``.
ValueError
If no family labels that edge. An unclaimed edge renders
nothing to style — bottom and left are hidden, and top and
right have no axis yet — and probing one must not build a
secondary axes nothing is using.
Notes
-----
.. versionadded:: 0.1.0
"""
if edge not in EDGES:
msg = f"unknown edge {edge!r}; expected one of {list(EDGES)!r}"
raise TypeError(msg)
if edge not in self._edge_owners:
msg = (
f"the {edge!r} edge carries no isopleth labels; claim it "
f'first, e.g. ax.isobars(labels="{edge}") (spec §3.2.3)'
)
raise ValueError(msg)
return self._edge_axis(edge)
def _check_label_edges(self, name: str, options: ResolvedOptions) -> None:
"""Reject an edge claim another family already holds.
The axes owns all five families, so it is the only place that can see
a collision; handing this to each family as its validator puts the
rejection inside ``IsoplethFamily.configure``'s rollback, and running
it during family creation surfaces a ``tephpy.config`` conflict at
axes creation rather than at first draw (spec §3.2.2).
Parameters
----------
name : str
The family the candidate options belong to.
options : ResolvedOptions
The candidate options, not yet in force.
Raises
------
TypeError
If another family already claims one of the candidate's edges.
"""
claimed = set(options.label_edges)
if not claimed:
return
for other_name, other in self._families.items():
if other_name == name:
continue
clash = claimed & set(other.options.label_edges)
if clash:
msg = (
f"the {min(clash)!r} edge is already labelled by "
f"{other_name!r}: one family per edge, so release it "
f"before {name!r} can claim it (spec §3.2.2)"
)
raise TypeError(msg)
def _style_edge_axis(self, axis: Axis) -> None:
"""Stamp the tephigram tick conventions on one edge axis.
Applied once, when the axis comes into existence — :meth:`clear` for
the axes' own ``xaxis``/``yaxis``, the lazy build in
:meth:`_edge_axis` for a top or right secondary — and never
re-applied, so a user's ``tick_params`` on a claimed edge survives
every later family resolve (spec §3.2.3). Matplotlib offers no
provenance on ``set_tick_params``, so *when* is the only guard
available. The conventions replay onto the tick artists matplotlib
rebuilds when a claim swaps the locator, because they live in the
axis' ``_major_tick_kw``.
Parameters
----------
axis : matplotlib.axis.Axis
The axis that draws one diagram edge's ticks.
"""
axis.set_tick_params(
labelsize=LABEL_FONTSIZE,
length=EDGE_TICK_LENGTH,
pad=EDGE_TICK_PAD,
)
# Lines of constant data-space x or y mean nothing on a tephigram: the
# ticks are the crossings, not a scale to rule off. Suppressing here
# lands after ``Axes.clear`` has read ``rcParams["axes.grid"]``, which
# several styles set, so a style cannot smuggle them in — while an
# explicit later ``ax.grid(True)`` is the user's call (spec §3.2.3).
axis.grid(visible=False, which="both")
def _edge_axis(self, edge: str) -> Axis:
"""Return the axis that draws one diagram edge's ticks.
Bottom and left reclaim the axes' own hidden ``xaxis``/``yaxis``
(spec §3.1); top and right take a secondary axis, created on first
demand and cached. The identity transform keeps the secondary axis in
the parent's data coordinates, which is what the crossings are in.
Parameters
----------
edge : str
The edge, one of ``EDGES``.
Returns
-------
matplotlib.axis.Axis
The axis to point a locator and formatter at.
"""
if edge == "bottom":
return self.xaxis
if edge == "left":
return self.yaxis
secondary = self._secondary_axes.get(edge)
if secondary is None:
identity = mtransforms.IdentityTransform()
secondary = (
self.secondary_xaxis("top", functions=identity)
if edge == "top"
else self.secondary_yaxis("right", functions=identity)
)
self._secondary_axes[edge] = secondary
self._style_edge_axis(secondary.xaxis if edge == "top" else secondary.yaxis)
return secondary.xaxis if edge == "top" else secondary.yaxis
def _claim_edge(self, edge: str, name: str, *, first: bool) -> None:
"""Point one edge's ticks at a family. Idempotent.
Identity only — locator, formatter, visibility, colour and title.
How the ticks look is stamped once by :meth:`_style_edge_axis` when
the edge axis is created and is the user's thereafter (spec §3.2.3).
Parameters
----------
edge : str
The edge to claim, one of ``EDGES``.
name : str
The claiming family's accessor name, which keys both the axis
titles and ``self._families``.
first : bool
Whether this claim is the edge's first under this owner — the
edge was unowned, or another family held it and has just been
released. Identity is installed only then; a repeat claim
re-applies nothing but a changed colour (spec §3.2.3).
"""
family = self._families[name]
axis = self._edge_axis(edge)
if first:
locator = _EdgeLocator(family, edge)
axis.set_major_locator(locator)
axis.set_major_formatter(_EdgeFormatter(locator))
# Crossings are exact positions; a minor tick between them means
# nothing. NullLocator is also matplotlib's linear-axis default, so
# release restores it.
axis.set_minor_locator(NullLocator())
# Visibility is identity, so a claim restores it on both paths:
# the ``Axis`` on every edge, and for top or right the secondary
# axes that hid with it, spine included. Showing the container
# alone would leave an ``Axis`` the user had hidden drawing no
# ticks on an edge that has just been claimed (spec §3.2.3).
axis.set_visible(True)
secondary = self._secondary_axes.get(edge)
if secondary is not None:
secondary.set_visible(True)
if not axis.get_label_text():
title = EDGE_AXIS_TITLES[name]
axis.set_label_text(title)
self._edge_titles[edge] = title
# ``set_tick_params`` takes no alpha, and per-``Tick`` alpha would not
# survive matplotlib rebuilding the tick artists on a locator change,
# so the family's alpha is baked into the tick RGBA instead.
# The memory is keyed by owner as well as RGBA: it survives release,
# so a bare colour comparison would suppress a new owner's claim
# whenever its colour matched the last owner's, leaving the ticks in
# a colour that now ties them to nothing.
rgba = mcolors.to_rgba(family.options.color, family.options.alpha)
if self._edge_tick_colors.get(edge) != (name, rgba):
axis.set_tick_params(color=rgba, labelcolor=rgba)
self._edge_tick_colors[edge] = (name, rgba)
def _release_edge(self, edge: str) -> None:
"""Return one edge to its unclaimed state.
Teardown only: the locator and formatter go back to matplotlib's
linear-axis defaults, tephpy's own axis title is cleared and
forgotten, and the edge hides. Presentation is left exactly as it
is — it belongs to the user, and the hidden axis renders none of it
(spec §3.2.3).
Parameters
----------
edge : str
The edge to release, one of ``EDGES``.
"""
title = self._edge_titles.pop(edge, None)
secondary = self._secondary_axes.get(edge)
if secondary is None and edge in {"top", "right"}:
# Never claimed, so there is no secondary axes to return; the
# early exit also keeps ``_edge_axis`` below from building one.
return
axis = self._edge_axis(edge)
if title is not None and axis.get_label_text() == title:
axis.set_label_text("")
axis.set_major_locator(AutoLocator())
axis.set_major_formatter(ScalarFormatter())
axis.set_minor_locator(NullLocator())
if secondary is None:
axis.set_visible(False)
else:
# The whole secondary axes hides, not merely its ``Axis``, or the
# spine it owns would keep drawing. It is kept, not removed, so a
# handle held across a release stays live and its ticks and title
# survive the reclaim exactly as bottom and left do (spec §3.2.3).
secondary.set_visible(False)
def _sync_edge_labels(self) -> None:
"""Match the claimed edges to what the five families now ask for.
Every successful family resolve lands here — the accessors, a direct
:meth:`~tephpy.plotting.isopleths.IsoplethFamily.configure`, an
``Artist.set_visible`` — plus the end of :meth:`clear`. Ownership
conflicts were already rejected by :meth:`_check_label_edges`, so this
only applies the outcome. A change on the right edge also relayouts the
side panels, whose pad widens to clear the tick labels (spec §3.2.7).
Nothing on this path resolves a family's options, so a nested call
would have nothing new to apply; the guard makes that structural rather
than a standing assumption about matplotlib's axis internals.
"""
if self._edge_sync_busy:
return
self._edge_sync_busy = True
try:
claims: dict[str, str] = {}
for name, family in self._families.items():
for edge in family.options.label_edges:
claims[edge] = name
had_right = "right" in self._edge_owners
for edge in EDGES:
owner = claims.get(edge)
previous = self._edge_owners.get(edge)
if previous not in (None, owner):
self._release_edge(edge)
if owner is None:
self._edge_owners.pop(edge, None)
else:
self._edge_owners[edge] = owner
self._claim_edge(edge, owner, first=previous != owner)
if had_right != ("right" in self._edge_owners):
self._relayout_side_panels()
finally:
self._edge_sync_busy = False
[docs]
def plot_barbs(
self,
snd: Sounding,
*,
x: float | None = None,
minimum_separation: float | None = None,
**kwargs: Any, # noqa: ANN401 -- pass-through to matplotlib
) -> BarbStaff:
"""Plot the sounding's wind barbs on the gutter staff (spec §3.2.5).
The barbs draw on a right-hand gutter appended with the shared
divider, each level at the y where its isobar meets the
diagram's right edge (the printed-form staff convention),
thinned per draw to a minimum vertical separation — zooming in
reveals more levels. Met Office symbology: flag 50 kt, full barb
10 kt, half barb 5 kt, speeds rounded to 5 kt bins; calm levels
render as matplotlib's small circle. Each call draws one staff:
overlay soundings by calling again with another `x`,
`minimum_separation`, and colour.
Parameters
----------
snd : Sounding
The sounding to plot; must carry wind.
x : float, optional
The staff position as a fraction across the gutter
(default ``BARB_STAFF_POSITION``).
minimum_separation : float, optional
The minimum vertical separation between drawn barbs, in
points (default ``BARB_MIN_SEPARATION``) — a longer
``length=`` glyph wants a wider separation.
**kwargs : Any
Passed through to :class:`matplotlib.quiver.Barbs`, over
the ``_constants`` conventions (increments, rounding,
length).
Returns
-------
BarbStaff
The zoom-aware staff artist; its ``barbs`` property is the
underlying matplotlib collection.
Raises
------
MissingDataError
If the sounding has no wind (spec §6).
Notes
-----
.. versionadded:: 0.1.0
"""
if snd.wind_speed is None or snd.wind_direction is None:
msg = "plot_barbs() needs wind: this sounding has none (spec §3.4)"
raise MissingDataError(msg)
# Function-local so `import tephpy` stays light (spec §10 item 10).
from metpy.calc import wind_components # noqa: PLC0415
u, v = wind_components(snd.wind_speed, snd.wind_direction)
if self._barb_gutter is None:
gutter = self._append_side_axes(
width=BARB_GUTTER_WIDTH, pad=BARB_GUTTER_PAD, sharey=self
)
gutter.set_xlim(0.0, 1.0)
gutter.set_axis_off()
self._barb_gutter = gutter
self._relayout_side_panels()
staff = BarbStaff(
self,
snd.pressure.m_as("hPa"),
np.asarray(u.m_as("knots"), dtype=np.float64),
np.asarray(v.m_as("knots"), dtype=np.float64),
x=BARB_STAFF_POSITION if x is None else float(x),
minimum_separation=(
BARB_MIN_SEPARATION
if minimum_separation is None
else float(minimum_separation)
),
**kwargs,
)
self._barb_gutter.add_artist(staff)
return staff
[docs]
def annotate_indices(self, indices: SoundingIndices) -> Axes:
"""Display derived parameters in a panel beside the diagram.
The first consumer of the side-of-axes contract (spec §3.2.7):
the panel is appended with the ``axes_grid1`` divider, one
formatted line per ``SoundingIndices`` field, NaN rendered as an
em dash. Calling it again updates the panel in place rather than
stacking a second one, and the side-panel layout is rebuilt
inside-out (barb gutter, then this panel) whichever order the
panel methods are called in (spec §3.2.7).
Parameters
----------
indices : SoundingIndices
The derived parameters, e.g. from ``calc.indices``.
Returns
-------
matplotlib.axes.Axes
The panel axes, for restyling.
Notes
-----
.. versionadded:: 0.1.0
"""
if self._indices_panel is None:
self._indices_panel = self._append_side_axes(
width=INDICES_PANEL_WIDTH, pad=INDICES_PANEL_PAD
)
self._relayout_side_panels()
panel = self._indices_panel
panel.clear()
panel.set_axis_off()
rows = len(INDICES_PANEL_ROWS)
for row, (field, label, unit, display, spec) in enumerate(INDICES_PANEL_ROWS):
value = float(getattr(indices, field).m_as(unit))
text = "—" if math.isnan(value) else f"{value:{spec}} {display}"
y = 1.0 - (row + 0.5) / rows
panel.text(
0.04,
y,
label,
fontsize=INDICES_PANEL_FONTSIZE,
ha="left",
va="center",
transform=panel.transAxes,
)
panel.text(
0.96,
y,
text,
fontsize=INDICES_PANEL_FONTSIZE,
ha="right",
va="center",
transform=panel.transAxes,
)
return panel
def _configure_family(self, name: str, kwargs: dict[str, object]) -> IsoplethFamily:
"""Apply non-``None`` accessor kwargs to a family and return it.
The family's own ``on_change`` runs :meth:`_sync_edge_labels`, so a
claim made here reaches the edges by the same route a direct
``family.configure(...)`` takes (spec §3.2.2).
Parameters
----------
name : str
The family key in ``_families``.
kwargs : dict
The accessor's keyword arguments; ``None`` values mean "not
passed" and are dropped.
Returns
-------
IsoplethFamily
The (possibly reconfigured) family artist.
Raises
------
TypeError
If an option name or ``labels`` placement is unknown, if a family
``emphasis`` is malformed — a non-mapping, a member value that will
not convert to float, a style that is not a mapping, or an unknown
style key — or if another family already claims a requested edge.
ValueError
If an option value is invalid — a non-positive ``interval``, or an
``emphasis`` giving a non-finite member value, a ``linewidth`` that
is not positive and finite, or an ``alpha`` outside ``[0, 1]``.
"""
family = self._families[name]
provided = {key: value for key, value in kwargs.items() if value is not None}
if provided:
family.configure(**provided)
return family
# The accessors deliberately mirror their config sections as wide
# keyword-only signatures (spec §3.2/§3.5): PLR0913 is suppressed on
# each rather than restructured away.
[docs]
def isotherms( # noqa: PLR0913
self,
*,
values: Iterable[float] | None = None,
interval: float | None = None,
color: str | None = None,
linewidth: float | None = None,
alpha: float | None = None,
labels: bool | str | tuple[str, ...] | None = None,
emphasis: Mapping[float, Mapping[str, object]] | None = None,
visible: bool | None = None,
) -> IsoplethFamily:
"""Return (and optionally reconfigure) the isotherm family.
With no arguments this returns the family artist unchanged; any
keyword given reconfigures it first (spec §3.2.1). Values are in
degrees Celsius.
Parameters
----------
values : iterable of float, optional
Explicit member temperatures; disables the zoom ladder.
interval : float, optional
Member interval; disables the zoom ladder.
color : str, optional
Line and label colour.
linewidth : float, optional
Line width in points.
alpha : float, optional
Line and label alpha.
labels : bool or str or tuple of str, optional
Where member values are labelled: ``True`` (every member inline —
the default), ``False`` (none), or the diagram edge names
``"bottom"``, ``"top"``, ``"left"`` and ``"right"``, singly or as a
tuple. Listed edges label the members that reach them; every member
left over is labelled inline. One family per edge. An edge crowded
by closely spaced members is thinned with ``interval=``; edge
labelling never drops a member's label itself.
emphasis : mapping of float to mapping, optional
Members to distinguish, keyed by member value in degrees Celsius.
Each value is a mapping of style overrides — ``color``,
``linewidth``, ``linestyle``, ``alpha`` — and an omitted key falls
back to the family's own style, so ``{0.0: {}}`` draws that member
at ``EMPHASIS_LINEWIDTH`` in the family's own colour. An emphasised
member is always drawn, whatever the zoom ladder would select, so a
value the interval never lands on still appears. An empty mapping
emphasises nothing.
visible : bool, optional
Whether the family is drawn.
Returns
-------
IsoplethFamily
The isotherm family artist.
Raises
------
TypeError
If ``labels`` names an unknown placement, ``emphasis`` is malformed,
or an edge another family already claims.
ValueError
If an ``emphasis`` member value is not finite, a ``linewidth`` is
not positive and finite, an ``alpha`` falls outside ``[0, 1]``, or
``interval`` is not positive and finite.
Notes
-----
.. versionadded:: 0.1.0
"""
return self._configure_family(
"isotherms",
{
"values": values,
"interval": interval,
"color": color,
"linewidth": linewidth,
"alpha": alpha,
"labels": labels,
"emphasis": emphasis,
"visible": visible,
},
)
[docs]
def isobars( # noqa: PLR0913
self,
*,
values: Iterable[float] | None = None,
interval: float | None = None,
color: str | None = None,
linewidth: float | None = None,
alpha: float | None = None,
labels: bool | str | tuple[str, ...] | None = None,
emphasis: Mapping[float, Mapping[str, object]] | None = None,
visible: bool | None = None,
) -> IsoplethFamily:
"""Return (and optionally reconfigure) the isobar family.
With no arguments this returns the family artist unchanged; any
keyword given reconfigures it first (spec §3.2.1). Values are in
hPa.
Parameters
----------
values : iterable of float, optional
Explicit member pressures; disables the zoom ladder.
interval : float, optional
Member interval; disables the zoom ladder.
color : str, optional
Line and label colour.
linewidth : float, optional
Line width in points.
alpha : float, optional
Line and label alpha.
labels : bool or str or tuple of str, optional
Where member values are labelled: ``True`` (every member inline —
the default), ``False`` (none), or the diagram edge names
``"bottom"``, ``"top"``, ``"left"`` and ``"right"``, singly or as a
tuple. Listed edges label the members that reach them; every member
left over is labelled inline. One family per edge. An edge crowded
by closely spaced members is thinned with ``interval=``; edge
labelling never drops a member's label itself.
emphasis : mapping of float to mapping, optional
Members to distinguish, keyed by member value in hPa.
Each value is a mapping of style overrides — ``color``,
``linewidth``, ``linestyle``, ``alpha`` — and an omitted key falls
back to the family's own style, so ``{500.0: {}}`` draws that member
at ``EMPHASIS_LINEWIDTH`` in the family's own colour. An emphasised
member is always drawn, whatever the zoom ladder would select, so a
value the interval never lands on still appears. An empty mapping
emphasises nothing.
visible : bool, optional
Whether the family is drawn.
Returns
-------
IsoplethFamily
The isobar family artist.
Raises
------
TypeError
If ``labels`` names an unknown placement, ``emphasis`` is malformed,
or an edge another family already claims.
ValueError
If an ``emphasis`` member value is not finite, a ``linewidth`` is
not positive and finite, an ``alpha`` falls outside ``[0, 1]``, or
``interval`` is not positive and finite.
Notes
-----
.. versionadded:: 0.1.0
"""
return self._configure_family(
"isobars",
{
"values": values,
"interval": interval,
"color": color,
"linewidth": linewidth,
"alpha": alpha,
"labels": labels,
"emphasis": emphasis,
"visible": visible,
},
)
[docs]
def dry_adiabats( # noqa: PLR0913
self,
*,
values: Iterable[float] | None = None,
interval: float | None = None,
color: str | None = None,
linewidth: float | None = None,
alpha: float | None = None,
labels: bool | str | tuple[str, ...] | None = None,
emphasis: Mapping[float, Mapping[str, object]] | None = None,
visible: bool | None = None,
) -> IsoplethFamily:
"""Return (and optionally reconfigure) the dry-adiabat family.
With no arguments this returns the family artist unchanged; any
keyword given reconfigures it first (spec §3.2.1). Values are
potential temperatures in degrees Celsius.
Parameters
----------
values : iterable of float, optional
Explicit member potential temperatures; disables the zoom
ladder.
interval : float, optional
Member interval; disables the zoom ladder.
color : str, optional
Line and label colour.
linewidth : float, optional
Line width in points.
alpha : float, optional
Line and label alpha.
labels : bool or str or tuple of str, optional
Where member values are labelled: ``True`` (every member inline —
the default), ``False`` (none), or the diagram edge names
``"bottom"``, ``"top"``, ``"left"`` and ``"right"``, singly or as a
tuple. Listed edges label the members that reach them; every member
left over is labelled inline. One family per edge. An edge crowded
by closely spaced members is thinned with ``interval=``; edge
labelling never drops a member's label itself.
emphasis : mapping of float to mapping, optional
Members to distinguish, keyed by member value in degrees Celsius.
Each value is a mapping of style overrides — ``color``,
``linewidth``, ``linestyle``, ``alpha`` — and an omitted key falls
back to the family's own style, so ``{0.0: {}}`` draws that member
at ``EMPHASIS_LINEWIDTH`` in the family's own colour. An emphasised
member is always drawn, whatever the zoom ladder would select, so a
value the interval never lands on still appears. An empty mapping
emphasises nothing.
visible : bool, optional
Whether the family is drawn.
Returns
-------
IsoplethFamily
The dry-adiabat family artist.
Raises
------
TypeError
If ``labels`` names an unknown placement, ``emphasis`` is malformed,
or an edge another family already claims.
ValueError
If an ``emphasis`` member value is not finite, a ``linewidth`` is
not positive and finite, an ``alpha`` falls outside ``[0, 1]``, or
``interval`` is not positive and finite.
Notes
-----
.. versionadded:: 0.1.0
"""
return self._configure_family(
"dry_adiabats",
{
"values": values,
"interval": interval,
"color": color,
"linewidth": linewidth,
"alpha": alpha,
"labels": labels,
"emphasis": emphasis,
"visible": visible,
},
)
[docs]
def moist_adiabats( # noqa: PLR0913
self,
*,
values: Iterable[float] | None = None,
interval: float | None = None,
truncation: float | None = None,
color: str | None = None,
linewidth: float | None = None,
alpha: float | None = None,
labels: bool | str | tuple[str, ...] | None = None,
emphasis: Mapping[float, Mapping[str, object]] | None = None,
visible: bool | None = None,
) -> IsoplethFamily:
"""Return (and optionally reconfigure) the moist-adiabat family.
With no arguments this returns the family artist unchanged; any
keyword given reconfigures it first (spec §3.2.1). Values are
wet-bulb potential temperatures in degrees Celsius.
Parameters
----------
values : iterable of float, optional
Explicit member wet-bulb potential temperatures; disables the
zoom ladder.
interval : float, optional
Member interval; disables the zoom ladder.
truncation : float, optional
Temperature (°C) below which the curves are truncated.
color : str, optional
Line and label colour.
linewidth : float, optional
Line width in points.
alpha : float, optional
Line and label alpha.
labels : bool or str or tuple of str, optional
Where member values are labelled: ``True`` (every member inline —
the default), ``False`` (none), or the diagram edge names
``"bottom"``, ``"top"``, ``"left"`` and ``"right"``, singly or as a
tuple. Listed edges label the members that reach them; every member
left over is labelled inline. One family per edge. An edge crowded
by closely spaced members is thinned with ``interval=``; edge
labelling never drops a member's label itself.
emphasis : mapping of float to mapping, optional
Members to distinguish, keyed by member value in degrees Celsius.
Each value is a mapping of style overrides — ``color``,
``linewidth``, ``linestyle``, ``alpha`` — and an omitted key falls
back to the family's own style, so ``{0.0: {}}`` draws that member
at ``EMPHASIS_LINEWIDTH`` in the family's own colour. An emphasised
member is always drawn, whatever the zoom ladder would select, so a
value the interval never lands on still appears. An empty mapping
emphasises nothing.
visible : bool, optional
Whether the family is drawn.
Returns
-------
IsoplethFamily
The moist-adiabat family artist.
Raises
------
TypeError
If ``labels`` names an unknown placement, ``emphasis`` is malformed,
or an edge another family already claims.
ValueError
If an ``emphasis`` member value is not finite, a ``linewidth`` is
not positive and finite, an ``alpha`` falls outside ``[0, 1]``, or
``interval`` is not positive and finite.
Notes
-----
.. versionadded:: 0.1.0
"""
return self._configure_family(
"moist_adiabats",
{
"values": values,
"interval": interval,
"truncation": truncation,
"color": color,
"linewidth": linewidth,
"alpha": alpha,
"labels": labels,
"emphasis": emphasis,
"visible": visible,
},
)
[docs]
def mixing_ratios( # noqa: PLR0913
self,
*,
values: Iterable[float] | None = None,
color: str | None = None,
linewidth: float | None = None,
alpha: float | None = None,
labels: bool | str | tuple[str, ...] | None = None,
emphasis: Mapping[float, Mapping[str, object]] | None = None,
visible: bool | None = None,
) -> IsoplethFamily:
"""Return (and optionally reconfigure) the mixing-ratio family.
With no arguments this returns the family artist unchanged; any
keyword given reconfigures it first (spec §3.2.1). Values are
humidity mixing ratios in g/kg; this family has no ``interval``
(its members come from the ``MIXING_RATIO_VALUES`` ladder).
Parameters
----------
values : iterable of float, optional
Explicit member mixing ratios; disables the zoom ladder.
color : str, optional
Line and label colour.
linewidth : float, optional
Line width in points.
alpha : float, optional
Line and label alpha.
labels : bool or str or tuple of str, optional
Where member values are labelled: ``True`` (every member inline —
the default), ``False`` (none), or the diagram edge names
``"bottom"``, ``"top"``, ``"left"`` and ``"right"``, singly or as a
tuple. Listed edges label the members that reach them; every member
left over is labelled inline. One family per edge. An edge crowded
by a large member set is thinned with ``values=``; edge labelling
never drops a member's label itself.
emphasis : mapping of float to mapping, optional
Members to distinguish, keyed by member value in g/kg.
Each value is a mapping of style overrides — ``color``,
``linewidth``, ``linestyle``, ``alpha`` — and an omitted key falls
back to the family's own style, so ``{5.0: {}}`` draws that member
at ``EMPHASIS_LINEWIDTH`` in the family's own colour. An emphasised
member is always drawn, whatever the zoom ladder would select, so a
value the ladder never selects still appears. An empty mapping
emphasises nothing.
visible : bool, optional
Whether the family is drawn.
Returns
-------
IsoplethFamily
The mixing-ratio family artist.
Raises
------
TypeError
If ``labels`` names an unknown placement, ``emphasis`` is malformed,
or an edge another family already claims.
ValueError
If an ``emphasis`` member value is not finite, a ``linewidth`` is
not positive and finite, or an ``alpha`` falls outside ``[0, 1]``.
Notes
-----
.. versionadded:: 0.1.0
"""
return self._configure_family(
"mixing_ratios",
{
"values": values,
"color": color,
"linewidth": linewidth,
"alpha": alpha,
"labels": labels,
"emphasis": emphasis,
"visible": visible,
},
)
register_projection(TephigramAxes)