Source code for specmod.tables
"""Fit results as a table, on disk.
The other half of ``REFACTOR_PLAN`` §4.6. Spectra are arrays asked about one
event at a time and live in HDF5 (:mod:`specmod.io`); fit results are a
columnar scan over every event ever fitted — *"give me f_c and Omega for all
635 events and regress them"* — and live here. The published Magna run produced
**11,226 rows** of exactly that, and a multi-event catalogue is larger.
**Parquet as the primary, CSV as an export.** This is not fashion. CSV loses
dtypes, so a column of floats comes back as floats only if pandas guesses
right and as strings if one cell says ``None``; it round-trips every float
through decimal text; and it has to be read in full to read any of it. Parquet
is typed, compressed, and queryable with DuckDB or polars *without loading the
file*. CSV stays because journal supplements want it, and because a human with
a text editor is a legitimate reader.
The format follows from the suffix, so the choice is visible at the call site
rather than buried in a keyword.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pandas as pd
if TYPE_CHECKING: # pragma: no cover
from collections.abc import Mapping
__all__ = ["read_table", "write_table"]
#: Suffix to format. ``.parquet`` is the default because it is what the
#: analysis actually wants; the others exist because other tools do.
_FORMATS = {".parquet": "parquet", ".pq": "parquet", ".csv": "csv"}
def _resolve(path: str | Path) -> tuple[Path, str]:
path = Path(path)
try:
return path, _FORMATS[path.suffix.lower()]
except KeyError:
raise ValueError(
f"cannot tell what format {path.name!r} should be from its suffix. "
f"Use one of {', '.join(sorted(_FORMATS))}."
) from None
def _require_pyarrow() -> None:
try:
import pyarrow # noqa: F401, PLC0415
except ImportError as exc: # pragma: no cover - depends on the environment
raise ImportError(
"writing Parquet needs pyarrow. Install it with "
"`pip install specmod[io]`, or use a .csv path."
) from exc
[docs]
def write_table(
path: str | Path,
table: pd.DataFrame,
*,
meta: Mapping[str, Any] | None = None,
) -> Path:
"""Write a fit table, choosing the format from ``path``'s suffix.
``meta`` is stored in the file's own metadata for Parquet — where it
survives as key-value pairs a reader can get at without parsing the data —
and dropped for CSV, which has nowhere to put it. That asymmetry is stated
rather than hidden: a CSV export is lossy about provenance, and pretending
otherwise is how a run stops being reproducible.
The parent directory is created if absent.
"""
path, format = _resolve(path)
parent = path.parent
if parent != Path():
parent.mkdir(parents=True, exist_ok=True)
if format == "csv":
table.to_csv(path, index=False)
return path
_require_pyarrow()
import pyarrow as pa # noqa: PLC0415
import pyarrow.parquet as pq # noqa: PLC0415
arrow = pa.Table.from_pandas(table, preserve_index=False)
if meta:
# Parquet key-value metadata is bytes-to-bytes, and the existing schema
# metadata carries pandas' own type information — dropping it would
# lose the dtypes this format exists to preserve.
existing = arrow.schema.metadata or {}
arrow = arrow.replace_schema_metadata(
{
**existing,
**{str(k).encode(): str(v).encode() for k, v in meta.items()},
}
)
pq.write_table(arrow, path, compression="zstd")
return path
[docs]
def read_table(path: str | Path) -> pd.DataFrame:
"""Read a fit table back, choosing the format from ``path``'s suffix."""
path, format = _resolve(path)
if format == "csv":
return pd.read_csv(path)
_require_pyarrow()
return pd.read_parquet(path)