# 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 ``Sounding`` data model (spec §3.4).
A :class:`Sounding` is a frozen dataclass holding one ascent's
pressure/temperature/dewpoint/wind arrays as pint quantities on MetPy's
registry, plus optional station/time metadata and a derived legend label.
Inputs are coerced and validated at construction — bad data fails at
ingest, not mid-plot (spec §6) — and pressure is normalized to decreasing
(surface-first) storage with all arrays reversed together, so downstream
``metpy.calc`` sees one orientation.
The pandas/xarray constructors consume the objects handed to them —
neither library is imported at runtime — so ``import tephpy`` stays
light (spec §10 item 10).
"""
from __future__ import annotations
import dataclasses
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Final
import numpy as np
from tephpy._constants import SOUNDING_LABEL_FORMAT
from tephpy._units import as_quantity, check_units_mapping
from tephpy.exceptions import (
DewpointExceedsTemperatureError,
NonMonotonicPressureError,
TephpyUnitsError,
TephpyValidationError,
)
if TYPE_CHECKING:
from collections.abc import Mapping
import pandas as pd
import pint
import xarray as xr
__all__ = ["Sounding"]
#: The data fields with their required dimensionalities (spec §5);
#: ``""`` means dimensionless (wind direction is an angle).
_FIELD_DIMENSIONS: Final[dict[str, str]] = {
"pressure": "[pressure]",
"temperature": "[temperature]",
"dewpoint": "[temperature]",
"wind_speed": "[speed]",
"wind_direction": "",
}
#: Minimum number of levels in a sounding.
_MIN_LEVELS: Final[int] = 2
[docs]
@dataclasses.dataclass(frozen=True, eq=False)
class Sounding:
"""One sounding: quantified profile arrays plus metadata (spec §3.4).
Pressure and temperature are required; dewpoint and wind are optional,
and the two wind fields must arrive together. Bare arrays need the
``units=`` mapping; a constructed Sounding always holds pint
quantities on MetPy's registry, with pressure stored decreasing
(surface-first). NaN gaps are data everywhere except pressure.
Attributes
----------
pressure : pint.Quantity
Level pressures; required, finite, and strictly monotonic (either
direction accepted, normalized to decreasing).
temperature : pint.Quantity
Level temperatures; required.
dewpoint : pint.Quantity or None
Level dewpoints; where dewpoint and temperature are both non-NaN,
dewpoint above temperature is rejected (equality — saturation —
is physical).
wind_speed : pint.Quantity or None
Level wind speeds; requires `wind_direction`.
wind_direction : pint.Quantity or None
Level wind directions (degrees from north); requires `wind_speed`.
station : str or None
Station identifier, e.g. ``"72357"``.
time : datetime.datetime or None
Launch time; ``numpy.datetime64`` input is accepted, naive
datetimes are read as UTC, and aware ones are converted to UTC.
label : str or None
Legend text. When not given it derives as e.g.
``"72357 2013-05-20 12Z"`` if both `station` and `time` are
present, else ``None`` — and ``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, e.g. ``units={"pressure": "hPa",
"temperature": "degC"}`` (spec §5).
"""
pressure: pint.Quantity
temperature: pint.Quantity
dewpoint: pint.Quantity | None = None
wind_speed: pint.Quantity | None = None
wind_direction: pint.Quantity | None = None
station: str | None = None
time: datetime | None = None
label: str | None = None
units: dataclasses.InitVar[Mapping[str, str] | None] = None
def __post_init__(self, units: Mapping[str, str] | None) -> None:
"""Coerce, validate, and normalize the constructed sounding.
Parameters
----------
units : mapping of str to str or None
The ``units=`` mapping for bare-array fields.
"""
for name in ("pressure", "temperature"):
if getattr(self, name) is None:
msg = f"pressure and temperature are required: {name!r} is None"
raise TypeError(msg)
mapping = check_units_mapping(units, allowed=_FIELD_DIMENSIONS)
for name, dimension in _FIELD_DIMENSIONS.items():
value = getattr(self, name)
if value is None:
continue
quantity = as_quantity(
value, name=name, units=mapping.get(name), dimension=dimension
)
object.__setattr__(self, name, quantity)
self._validate_shapes()
self._validate_wind_pairing()
self._validate_dewpoint()
self._normalize_pressure()
self._normalize_time()
self._derive_label()
def _fields_present(self) -> dict[str, pint.Quantity]:
"""Collect the data fields provided to this sounding.
Returns
-------
dict of str to pint.Quantity
Field name to coerced quantity, in field order.
"""
present = {}
for name in _FIELD_DIMENSIONS:
value = getattr(self, name)
if value is not None:
present[name] = value
return present
def _validate_shapes(self) -> None:
"""Require 1-D equal-length arrays of at least two levels."""
lengths = {}
for name, quantity in self._fields_present().items():
if quantity.magnitude.ndim != 1:
msg = f"{name!r} must be 1-D, got {quantity.magnitude.ndim}-D"
raise TephpyValidationError(msg)
lengths[name] = quantity.magnitude.size
if len(set(lengths.values())) > 1:
msg = f"fields must be equal length, got {lengths!r}"
raise TephpyValidationError(msg)
if min(lengths.values()) < _MIN_LEVELS:
msg = f"a sounding needs at least {_MIN_LEVELS} levels, got {lengths!r}"
raise TephpyValidationError(msg)
def _validate_wind_pairing(self) -> None:
"""Require wind speed and direction to arrive together."""
if (self.wind_speed is None) != (self.wind_direction is None):
missing = "wind_direction" if self.wind_direction is None else "wind_speed"
msg = (
"wind_speed and wind_direction must arrive together: "
f"{missing!r} is missing"
)
raise TephpyValidationError(msg)
def _normalize_pressure(self) -> None:
"""Require finite, strictly monotonic pressure; store it decreasing.
Increasing input is accepted and reversed — with every data array
reversed together — so storage is always surface-first.
"""
pressure = self.pressure.magnitude
bad = np.flatnonzero(~np.isfinite(pressure))
if bad.size:
levels = tuple(int(index) for index in bad)
msg = f"pressure must be finite at every level; offending levels {levels}"
raise TephpyValidationError(msg, levels=levels)
diffs = np.diff(pressure)
if np.all(diffs < 0.0):
return
if np.all(diffs > 0.0):
for name, quantity in self._fields_present().items():
object.__setattr__(self, name, quantity[::-1])
return
direction = 1.0 if pressure[-1] > pressure[0] else -1.0
offending = np.flatnonzero(diffs * direction <= 0.0) + 1
levels = tuple(int(index) for index in offending)
msg = (
"pressure must be strictly monotonic; "
f"offending levels {levels} of the {pressure.size}-level profile"
)
raise NonMonotonicPressureError(msg, levels=levels)
def _validate_dewpoint(self) -> None:
"""Reject dewpoint above temperature where both are non-NaN.
Runs before pressure normalization, so ``levels`` index the
caller's input arrays — the same frame as the pressure errors.
"""
if self.dewpoint is None:
return
temperature = self.temperature.m_as("degC")
dewpoint = self.dewpoint.m_as("degC")
both = np.isfinite(temperature) & np.isfinite(dewpoint)
bad = np.flatnonzero(both & (dewpoint > temperature))
if bad.size:
levels = tuple(int(index) for index in bad)
msg = (
"dewpoint exceeds temperature (equality is saturation and "
f"accepted); offending levels {levels}"
)
raise DewpointExceedsTemperatureError(msg, levels=levels)
def _normalize_time(self) -> None:
"""Read naive times as UTC and convert aware ones to UTC."""
# Typed `object`: the field annotation says datetime, but the
# boundary also accepts numpy.datetime64 and rejects the rest.
time: object = self.time
if time is None:
return
if isinstance(time, np.datetime64):
if np.isnat(time):
msg = "time is NaT (not-a-time): pass a real launch time, or None"
raise TypeError(msg)
time = time.astype("datetime64[us]").item()
if not isinstance(time, datetime):
msg = f"time must be a datetime or numpy.datetime64, got {type(time)!r}"
raise TypeError(msg)
time = time.replace(tzinfo=UTC) if time.tzinfo is None else time.astimezone(UTC)
object.__setattr__(self, "time", time)
def _derive_label(self) -> None:
"""Derive the legend label when not explicitly given (spec §3.4)."""
if self.label is None and self.station is not None and self.time is not None:
label = SOUNDING_LABEL_FORMAT.format(station=self.station, time=self.time)
object.__setattr__(self, "label", label)
[docs]
@classmethod
def from_dataframe(
cls,
df: pd.DataFrame,
*,
units: Mapping[str, str] | None = None,
station: str | None = None,
time: datetime | None = None,
label: str | None = None,
**column_map: str,
) -> Sounding:
"""Build a sounding from a pandas DataFrame (spec §3.4).
Column names default to the field names; `column_map` overrides
per field (e.g. ``dewpoint="dwpt"``). Columns are bare arrays, so
the present fields need the ``units=`` mapping.
Parameters
----------
df : pandas.DataFrame
The profile table; must contain pressure and temperature
columns.
units : mapping of str to str, optional
Unit strings keyed by field name (spec §5).
station : str, optional
Station identifier.
time : datetime.datetime, optional
Launch time; ``pandas.Timestamp`` and ``numpy.datetime64``
are accepted.
label : str, optional
Legend text override.
**column_map : str
Field names mapped to their column names in `df`.
Returns
-------
Sounding
The validated sounding.
Raises
------
KeyError
If a required or explicitly mapped column is missing.
TypeError
If `column_map` names an unknown field.
"""
cls._check_field_map(column_map)
data: dict[str, np.ndarray] = {}
for name in _FIELD_DIMENSIONS:
column = column_map.get(name, name)
if column in df.columns:
data[name] = df[column].to_numpy()
elif name in column_map or name in ("pressure", "temperature"):
msg = f"column {column!r} (field {name!r}) is not in the DataFrame"
raise KeyError(msg)
return cls(units=units, station=station, time=time, label=label, **data)
[docs]
@classmethod
def from_dataset(
cls,
ds: xr.Dataset,
*,
units: Mapping[str, str] | None = None,
station: str | None = None,
time: datetime | None = None,
label: str | None = None,
**var_map: str,
) -> Sounding:
"""Build a sounding from an xarray Dataset (spec §3.4).
Variable names default to the field names; `var_map` overrides per
field. Units are read from each variable's ``attrs["units"]`` (the
xarray/CF convention); the ``units=`` mapping is the explicit
override.
Parameters
----------
ds : xarray.Dataset
The profile dataset; must contain pressure and temperature
variables.
units : mapping of str to str, optional
Unit strings keyed by field name, overriding
``attrs["units"]``.
station : str, optional
Station identifier.
time : datetime.datetime, optional
Launch time; ``pandas.Timestamp`` and ``numpy.datetime64``
are accepted.
label : str, optional
Legend text override.
**var_map : str
Field names mapped to their variable names in `ds`.
Returns
-------
Sounding
The validated sounding.
Raises
------
KeyError
If a required or explicitly mapped variable is missing.
TephpyUnitsError
If a field has neither ``attrs["units"]`` nor a ``units=``
entry.
TypeError
If `var_map` names an unknown field.
"""
cls._check_field_map(var_map)
mapping = check_units_mapping(units, allowed=_FIELD_DIMENSIONS)
data: dict[str, np.ndarray] = {}
resolved: dict[str, str] = {}
for name in _FIELD_DIMENSIONS:
variable = var_map.get(name, name)
if variable not in ds.variables:
if name in var_map or name in ("pressure", "temperature"):
msg = f"variable {variable!r} (field {name!r}) not in the Dataset"
raise KeyError(msg)
continue
unit = mapping.get(name) or ds[variable].attrs.get("units")
if not unit:
msg = (
f"{name!r} (variable {variable!r}) has no attrs['units'] "
f'and no override: add units={{"{name}": "<unit>"}}'
)
raise TephpyUnitsError(msg)
data[name] = ds[variable].to_numpy()
resolved[name] = unit
return cls(units=resolved, station=station, time=time, label=label, **data)
@staticmethod
def _check_field_map(field_map: Mapping[str, str]) -> None:
"""Reject unknown field names in a constructor's field mapping.
Parameters
----------
field_map : mapping of str to str
Field names mapped to column or variable names.
Raises
------
TypeError
If the mapping names an unknown field.
"""
unknown = set(field_map) - set(_FIELD_DIMENSIONS)
if unknown:
msg = (
f"unknown field(s) {sorted(unknown)!r}; "
f"expected {sorted(_FIELD_DIMENSIONS)!r}"
)
raise TypeError(msg)