Source code for tephpy.io.igra

# 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.
"""Integrated Global Radiosonde Archive (IGRA) version 2 reader (spec §3.4).

:func:`read` takes one ascent from an IGRA v2 per-station file — the
as-distributed ``.zip`` or the extracted ``.txt``, sniffed with
``zipfile.is_zipfile`` rather than by suffix — parsing the fixed-width
records per NCEI's ``igra2-data-format.txt``: pressure in Pa, temperature
and dewpoint depression in tenths of °C, wind in degrees and tenths of
m s⁻¹, with the missing-value sentinels reading as NaN and dewpoint
derived as temperature minus depression. Unreadable, malformed, or
ambiguous input raises :class:`~tephpy.exceptions.TephpyIOError`; the
returned sounding passes the ordinary ingest validation (spec §6).
"""

from __future__ import annotations

import dataclasses
from datetime import UTC, datetime
from typing import TYPE_CHECKING

import numpy as np

from tephpy._constants import IGRA_MISSING
from tephpy.exceptions import TephpyIOError
from tephpy.io._util import coerce_time, strictly_decreasing
from tephpy.sounding import Sounding

if TYPE_CHECKING:
    import os

__all__ = ["read"]

#: The nominal-hour sentinel for an ascent with no nominal hour.
_MISSING_HOUR = 99

#: Data-record slices (0-based) per NCEI ``igra2-data-format.txt``, with
#: the value scale to the sounding's unit.
_FIELDS: dict[str, tuple[slice, float, str]] = {
    "pressure": (slice(9, 15), 0.01, "hPa"),
    "temperature": (slice(22, 27), 0.1, "degC"),
    "dewpoint_depression": (slice(34, 39), 0.1, "delta_degC"),
    "wind_direction": (slice(40, 45), 1.0, "degree"),
    "wind_speed": (slice(46, 51), 0.1, "m/s"),
}


@dataclasses.dataclass(frozen=True)
class _Header:
    """One ascent's header record.

    ``line`` is the header's line index, ``levels`` its record count,
    ``station`` the 11-character IGRA identifier, and ``when`` the
    nominal UTC time — ``None`` when the nominal hour is missing.
    """

    line: int
    levels: int
    station: str
    when: datetime | None


[docs] def read( path: str | os.PathLike[str], *, time: datetime | str | None = None ) -> Sounding: """Read one sounding from an IGRA v2 per-station file. Parameters ---------- path : str or os.PathLike The station file: the as-distributed ``.zip`` or the extracted ``.txt``. time : datetime.datetime or str, optional The nominal launch time selecting the ascent; a string is read with :meth:`datetime.datetime.fromisoformat`, and a naive value is read as UTC. May be omitted only when the file holds exactly one sounding (trimmed research subsets, fixtures). Returns ------- Sounding The validated sounding, with the IGRA station identifier and the nominal time as metadata. Raises ------ TephpyIOError For an unreadable or malformed file, a `time` matching no ascent (the nearest nominal times are reported, when the file records any), or an ambiguous read — several soundings with no ``time=`` selector (the file's count and span are reported). TypeError If `time` is neither a datetime nor a string. ValueError If a `time` string is not ISO 8601. """ lines = _text(path).splitlines() headers = _headers(lines) if not headers: msg = f"{path!s} holds no IGRA v2 header records" raise TephpyIOError(msg) return _sounding(lines, _select(headers, time, path))
def _text(path: str | os.PathLike[str]) -> str: """Return a station file's text, transparently opening the zip form. Parameters ---------- path : str or os.PathLike The station file path. Returns ------- str The decoded file text. Raises ------ TephpyIOError If the file is unreadable or a corrupt zip, or a zip without exactly one member. """ # Function-local so `import tephpy` stays light (spec §3.4, §10 item 10). import zipfile # noqa: PLC0415 import zlib # noqa: PLC0415 -- a corrupt deflate member raises it try: if zipfile.is_zipfile(path): with zipfile.ZipFile(path) as archive: names = archive.namelist() if len(names) != 1: msg = ( f"{path!s} is not an IGRA v2 station file: expected " f"one archive member, found {names!r}" ) raise TephpyIOError(msg) return archive.read(names[0]).decode("ascii", errors="replace") with open(path, encoding="ascii", errors="replace") as handle: # noqa: PTH123 return handle.read() except (OSError, zipfile.BadZipFile, zlib.error) as error: # A corrupt archive raises outside `OSError`: `BadZipFile` for a # mangled directory or CRC, `zlib.error` for a bit-rotted deflate # stream. `TephpyError` is neither, so the multi-member message # raised above passes through unwrapped (spec §3.4). msg = f"could not read {path!s}: {error}" raise TephpyIOError(msg) from error def _headers(lines: list[str]) -> list[_Header]: """Collect the header records from a station file's lines. Parameters ---------- lines : list of str The file's lines. Returns ------- list of _Header One entry per ascent, in file order. Raises ------ TephpyIOError If a header record does not parse. """ headers = [] for index, line in enumerate(lines): if not line.startswith("#"): continue try: year, month, day = int(line[13:17]), int(line[18:20]), int(line[21:23]) hour = int(line[24:26]) when = ( None if hour == _MISSING_HOUR else datetime(year, month, day, hour, tzinfo=UTC) ) headers.append( _Header( line=index, levels=int(line[32:36]), station=line[1:12].strip(), when=when, ) ) except ValueError as error: msg = f"malformed IGRA v2 header record on line {index + 1}: {line!r}" raise TephpyIOError(msg) from error return headers def _select( headers: list[_Header], time: datetime | str | None, path: str | os.PathLike[str], ) -> _Header: """Select the requested ascent from a file's headers. Parameters ---------- headers : list of _Header The file's ascents, in file order. time : datetime.datetime or str or None The nominal launch time, or ``None`` for the sole ascent. path : str or os.PathLike The station file path, for error messages. Returns ------- _Header The selected ascent. Raises ------ TephpyIOError For an ambiguous read (no `time` with several ascents) or a `time` matching no ascent. """ if time is None: if len(headers) == 1: return headers[0] stamped = [header.when for header in headers if header.when is not None] span = ( f" spanning {min(stamped):%Y-%m-%d %H}Z to {max(stamped):%Y-%m-%d %H}Z" if stamped else "" ) msg = f"{path!s} holds {len(headers)} soundings{span}: pass time= to select one" raise TephpyIOError(msg) when = coerce_time(time) for header in headers: if header.when == when: return header nearest = sorted( (header.when for header in headers if header.when is not None), key=lambda stamp: abs(stamp - when), )[:3] detail = ( "nearest: " + ", ".join(f"{stamp:%Y-%m-%d %H}Z" for stamp in nearest) if nearest else "the file records no nominal launch times" ) msg = f"{path!s} has no sounding at {when:%Y-%m-%d %H:%M}Z ({detail})" raise TephpyIOError(msg) def _sounding(lines: list[str], header: _Header) -> Sounding: """Parse one ascent's records into a sounding. Records without a pressure value are dropped (`Sounding` requires finite pressure), the sentinels read as NaN, dewpoint derives as temperature minus dewpoint depression, and rows whose pressure does not strictly undercut the running minimum drop keeping the first occurrence. 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 as barbs that cannot draw — keeping the missing-data errors meaningful downstream (spec §6). Parameters ---------- lines : list of str The file's lines. header : _Header The selected ascent. Returns ------- Sounding The validated sounding. Raises ------ TephpyIOError If a data record does not parse. """ start = header.line + 1 block = lines[start : start + header.levels] if len(block) < header.levels: msg = ( f"IGRA v2 header on line {header.line + 1} declares " f"{header.levels} levels but the file ends after {len(block)}" ) raise TephpyIOError(msg) columns: dict[str, list[float]] = {field: [] for field in _FIELDS} for offset, line in enumerate(block): for field, (chars, scale, _) in _FIELDS.items(): cell = line[chars].strip() try: raw = int(cell) except ValueError as error: msg = ( f"malformed IGRA v2 data record on line " f"{start + offset + 1}: {line!r}" ) raise TephpyIOError(msg) from error columns[field].append(np.nan if raw in IGRA_MISSING else raw * scale) arrays = { field: np.asarray(values, dtype=np.float64) for field, values in columns.items() } keep = strictly_decreasing(arrays["pressure"]) arrays = {field: values[keep] for field, values in arrays.items()} dewpoint = arrays["temperature"] - arrays.pop("dewpoint_depression") wind = ("wind_direction", "wind_speed") # Either component wholly missing retires the pair: `Sounding` takes # the two together, and half a wind draws no barbs (spec §6). wind_absent = any(bool(np.all(np.isnan(arrays[field]))) for field in wind) return Sounding( arrays["pressure"], arrays["temperature"], dewpoint=None if bool(np.all(np.isnan(dewpoint))) else dewpoint, wind_speed=None if wind_absent else arrays["wind_speed"], wind_direction=None if wind_absent else arrays["wind_direction"], units={ "pressure": "hPa", "temperature": "degC", "dewpoint": "degC", "wind_direction": "degree", "wind_speed": "m/s", }, station=header.station, time=header.when, )