# 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.
"""University of Wyoming sounding archive reader (spec §3.4).
:func:`fetch` requests one ascent from the archive's post-2024 wsgi
interface in its machine-readable ``TEXT:CSV`` form — bare, self-describing
CSV (verified 2026-07-27) — over stdlib ``urllib`` behind a function-local
import, and hands the body to a pure, transport-free parser. Network
failures, HTTP errors, and the archive's "no data" replies raise
:class:`~tephpy.exceptions.TephpyIOError` summarising the upstream
response; the parsed sounding passes the ordinary ingest validation
(spec §6).
"""
from __future__ import annotations
import csv
import io
from typing import TYPE_CHECKING
import numpy as np
from tephpy._constants import WYOMING_TIMEOUT, WYOMING_URL
from tephpy.exceptions import TephpyIOError
from tephpy.io._util import coerce_time, strictly_decreasing
from tephpy.sounding import Sounding
if TYPE_CHECKING:
from datetime import datetime
import numpy.typing as npt
__all__ = ["fetch"]
#: Archive CSV column → (sounding field, pint unit) for the carried fields.
_COLUMNS: dict[str, tuple[str, str]] = {
"pressure_hPa": ("pressure", "hPa"),
"temperature_C": ("temperature", "degC"),
"dew point temperature_C": ("dewpoint", "degC"),
"wind direction_degree": ("wind_direction", "degree"),
"wind speed_m/s": ("wind_speed", "m/s"),
}
[docs]
def fetch(
station: str, time: datetime | str, *, timeout: float | None = None
) -> Sounding:
"""Fetch one sounding from the University of Wyoming archive.
Parameters
----------
station : str
The WMO station identifier, e.g. ``"72357"``.
time : datetime.datetime or str
The nominal launch time; a string is read with
:meth:`datetime.datetime.fromisoformat`, and a naive value is
read as UTC (the ``Sounding`` convention).
timeout : float, optional
The request timeout in seconds (default ``WYOMING_TIMEOUT``).
Returns
-------
Sounding
The validated sounding, with `station` and `time` as metadata —
so the legend label derives for free (spec §3.4).
Raises
------
TephpyIOError
For network failures, HTTP errors (including the archive's "no
data at that time" and "unknown station" replies), or a response
the parser does not recognise.
TypeError
If `time` is neither a datetime nor a string.
ValueError
If a `time` string is not ISO 8601.
"""
when = coerce_time(time)
from urllib.parse import quote # noqa: PLC0415 -- spec §3.4 idiom
url = WYOMING_URL.format(
datetime=quote(f"{when:%Y-%m-%d %H:%M:%S}"), station=quote(station)
)
text = _request(url, WYOMING_TIMEOUT if timeout is None else timeout)
return _parse(text, station=station, time=when)
def _request(url: str, timeout: float) -> str:
"""Perform the archive request, mapping failures to `TephpyIOError`.
Parameters
----------
url : str
The request URL (the formatted ``WYOMING_URL``).
timeout : float
The request timeout in seconds.
Returns
-------
str
The decoded response body.
Raises
------
TephpyIOError
For HTTP error statuses (summarising the archive's reply) or
any transport failure.
"""
# Function-local so `import tephpy` stays light (spec §3.4, §10 item 10).
from http.client import HTTPException # noqa: PLC0415
from urllib.error import HTTPError # noqa: PLC0415
from urllib.request import urlopen # noqa: PLC0415
try:
# The URL derives from the https-scheme WYOMING_URL constant.
with urlopen(url, timeout=timeout) as response: # noqa: S310
return str(response.read().decode("utf-8", errors="replace"))
except HTTPError as error:
try:
with error:
body = error.read().decode("utf-8", errors="replace").strip()
except (OSError, HTTPException):
# A truncated or reset error body must not mask the status.
body = ""
summary = body.splitlines()[0] if body else str(error.reason)
msg = f"the Wyoming archive returned HTTP {error.code}: {summary}"
raise TephpyIOError(msg) from error
except (OSError, HTTPException) as error:
# `URLError` and `TimeoutError` are `OSError`s; `HTTPException`
# adds the http.client failures urlopen does not wrap, e.g. a
# truncated body (`IncompleteRead`) or a malformed status line.
msg = f"could not reach the Wyoming archive: {error}"
raise TephpyIOError(msg) from error
def _parse(text: str, *, station: str | None, time: datetime | None) -> Sounding:
"""Parse one ``TEXT:CSV`` archive body into a sounding.
Blank cells read as NaN (NaN gaps are data, spec §3.4); rows whose
pressure does not strictly decrease on the running minimum are
dropped keeping the first occurrence, so the dense BUFR-era ascents
satisfy ``Sounding``'s strict monotonicity; an optional field that
is entirely NaN is treated as absent — and the wind pair as a unit,
so a one-sided wind column passes as absent rather than tripping
``Sounding``'s pairing rule — keeping the missing-data errors
meaningful downstream (spec §6).
The archive's CSV is rectangular, so a row with fewer cells than
the header is a truncated reply rather than a gap: it is rejected
naming the row (the header is row 1). Trailing cells beyond the
header are ignored — every carried column is located by its header
index.
Parameters
----------
text : str
The response body.
station : str or None
The WMO station identifier, carried as metadata; ``None``
derives no legend label.
time : datetime.datetime or None
The nominal launch time, carried as metadata; ``None`` derives
no legend label.
Returns
-------
Sounding
The validated sounding.
Raises
------
TephpyIOError
If the body is not readable as CSV at all, expected columns are
missing, a row is shorter than the header, the header carries no
data rows, or a cell is not numeric.
"""
try:
rows = list(csv.reader(io.StringIO(text)))
except csv.Error as error:
# A 200 body that is not CSV at all — a proxy page, a binary blob.
msg = f"unexpected Wyoming response format: {error}"
raise TephpyIOError(msg) from error
if not rows:
msg = "the Wyoming archive returned an empty response"
raise TephpyIOError(msg)
header = [column.strip() for column in rows[0]]
missing = sorted(set(_COLUMNS) - set(header))
if missing:
msg = (
f"unexpected Wyoming response format: column(s) {missing!r} "
f"not in header {header!r}"
)
raise TephpyIOError(msg)
indices = {column: header.index(column) for column in _COLUMNS}
data: dict[str, list[float]] = {field: [] for field, _ in _COLUMNS.values()}
for number, row in enumerate(rows[1:], start=2):
if not row:
continue
if len(row) < len(header):
msg = (
f"unexpected Wyoming response format: row {number} has "
f"{len(row)} cell(s), expected at least {len(header)}"
)
raise TephpyIOError(msg)
for column, (field, _) in _COLUMNS.items():
cell = row[indices[column]].strip()
try:
data[field].append(float(cell) if cell else np.nan)
except ValueError as error:
msg = (
f"unexpected Wyoming response format: {column!r} "
f"cell {cell!r} is not numeric"
)
raise TephpyIOError(msg) from error
if not data["pressure"]:
# A header carrying no records is a mangled reply, not a sounding
# with too few levels (spec §3.4).
msg = "unexpected Wyoming response format: no data rows after the header"
raise TephpyIOError(msg)
arrays = {
field: np.asarray(values, dtype=np.float64) for field, values in data.items()
}
keep = strictly_decreasing(arrays["pressure"])
arrays = {field: values[keep] for field, values in arrays.items()}
fields: dict[str, npt.NDArray[np.float64] | None] = {}
for field, _ in _COLUMNS.values():
values = arrays[field]
optional = field not in ("pressure", "temperature")
fields[field] = None if optional and bool(np.all(np.isnan(values))) else values
# The wind pair goes together: one wholly-missing component retires
# both, so the failure downstream is the meaningful MissingDataError
# from `plot_barbs`, not a `Sounding` pairing error here (spec §6).
if fields["wind_speed"] is None or fields["wind_direction"] is None:
fields["wind_speed"] = fields["wind_direction"] = None
return Sounding(
fields["pressure"],
fields["temperature"],
dewpoint=fields["dewpoint"],
wind_speed=fields["wind_speed"],
wind_direction=fields["wind_direction"],
units=dict(_COLUMNS.values()),
station=station,
time=time,
)