# TEXAS/stan/io.py
from pathlib import Path
from typing import Union, Optional, Literal, Sequence, Any, Dict
from ..utils.paths import POSTERIOR_CACHE_DIR, INVT_CACHE_DIR
import json
import re
import numpy as np
import xarray as xr
__all__ = [
"save_posterior",
"load_posterior",
"list_posteriors",
"save_invT_posterior",
]
# By default, write into your repo under TEXAS/posterior_cache
DEFAULT_FORWARD_DIR = POSTERIOR_CACHE_DIR
DEFAULT_INVT_DIR = INVT_CACHE_DIR
DEFAULT_FORWARD_DIR.mkdir(exist_ok=True, parents=True)
DEFAULT_INVT_DIR.mkdir(exist_ok=True, parents=True)
[docs]
def save_posterior(
posterior: xr.Dataset,
cache_dir: Optional[Union[str, Path]] = None,
overwrite: bool = True,
filename_suffix: str = "",
layout: Literal["auto", "case", "legacy"] = "auto",
run: Union[str, int] = "auto",
) -> Path:
"""
Save a forward-model posterior to disk as compressed NetCDF.
The filename is auto-generated from the posterior's metadata attrs:
``{model}_{temptype}[_gdgt23ratio][_no3_{cutoff}][_{proxy_name}]{suffix}.nc``
Parameters
----------
posterior : xr.Dataset
Forward calibration posterior returned by ``get_posterior()``.
Must have ``stan_model_name``, ``temptype``, and ``proxy_name``
attrs set (``proxy_name`` is required — a warning is raised if
missing).
cache_dir : str or Path, optional
Directory to write the file. Defaults to the standard forward
posterior cache (``data/cache/TEXAS_posterior_cache/`` for
source installs, ``~/.texas/cache/TEXAS_posterior_cache/`` for
pip installs).
overwrite : bool
If ``False``, raise ``FileExistsError`` when the output path
already exists. Default ``True``.
filename_suffix : str, optional
Extra tag appended before ``.nc`` under the *legacy* layout, and the
run/member token under the case layout. Leading/trailing underscores
are stripped. **Do not pass a date here.** Filenames no longer carry
date stamps; the run date is recorded in the ``run_timestamp`` attr,
where it survives a rename and does not have to be parsed back out of
a path. Prefer ``run=`` for an explicit member.
run : str or int, optional
Optional run/member token under the case layout. ``"auto"`` (default)
writes **no** member, so a configuration has one canonical path and a
re-run replaces it; pass a value only to keep a run deliberately apart.
An explicit *run* wins over *filename_suffix* for the case path. With
``overwrite=False`` an existing file raises ``FileExistsError``.
layout : {"auto", "case", "legacy"}
Where to write. ``"case"`` uses the CESM-style case directory
(``tx.v026.GHEB.sst.ri3.G23-N1p0/fwd.nc``); ``"legacy"`` uses the
historical long flat filename; ``"auto"`` (default) prefers the case
layout and falls back to legacy with a warning if no case id can be
derived. See :mod:`TEXAS.utils.naming`.
Returns
-------
Path
Absolute path of the saved ``.nc`` file.
"""
if not isinstance(posterior, xr.Dataset):
raise TypeError("posterior must be an xarray.Dataset")
outdir = Path(cache_dir) if cache_dir else DEFAULT_FORWARD_DIR
outdir.mkdir(exist_ok=True, parents=True)
name = posterior.attrs.get("stan_model_name", "unknown_model")
ttype = posterior.attrs.get("temptype", "unknown")
if posterior.attrs.get("use_gdgt23ratio", 0):
ttype += "_gdgt23ratio"
if posterior.attrs.get("use_no3", 0):
cutoff = posterior.attrs.get("no3_cutoff")
if cutoff is None:
raise ValueError("no3_cutoff must be set when use_no3=1")
ttype += f"_no3_{cutoff}"
proxy_name = posterior.attrs.get("proxy_name", "") or ""
proxy_tag = f"_{proxy_name}" if proxy_name and proxy_name != "unknown" else ""
# sanitize suffix
if filename_suffix:
filename_suffix = f"_{filename_suffix.strip('_')}"
legacy_path = outdir / f"{name}_{ttype}{proxy_tag}{filename_suffix}.nc"
# Preferred layout: one directory per calibration case, CESM-style, so the
# forward posterior and every reconstruction derived from it sit together
# and individual filenames stay short. Falls back to the historical flat
# name whenever a case id cannot be derived (e.g. a model this naming
# scheme does not know), so saving can never fail on naming alone.
outpath = legacy_path
if layout in ("case", "auto"):
try:
from ..utils.naming import case_from_attrs, fwd_relpath
# One canonical path per configuration. There is no member counter:
# a re-run overwrites, and callers that do not want to re-run check
# the cache first (load_posterior, or a resume manifest). The
# auto-incrementing member this replaced put ".001" next to "N10",
# where only one of the two is a number, and made overwrite=False
# vacuous because a fresh member never collides.
explicit = filename_suffix.strip("_")
case = case_from_attrs(
posterior.attrs,
run=(str(run) if run != "auto" else explicit) or None)
outpath = outdir / fwd_relpath(case)
outpath.parent.mkdir(exist_ok=True, parents=True)
posterior.attrs["case_id"] = str(case)
except Exception as exc:
if layout == "case":
raise
import warnings
warnings.warn(
f"Could not derive a case id for this posterior ({exc}); "
f"falling back to the legacy flat filename. Pass "
f"layout='legacy' to silence this.",
UserWarning, stacklevel=2,
)
outpath = legacy_path
if outpath.exists() and not overwrite:
raise FileExistsError(f"{outpath} exists and overwrite=False")
posterior.attrs["filename"] = outpath.name
if not posterior.attrs.get("proxy_name"):
import warnings
warnings.warn(
"proxy_name is not set on this posterior. "
"Pass proxy_name= to get_posterior() (e.g. proxy_name='scaledRI'). "
"It is stored in the .nc file and used downstream to validate that "
"the correct proxy type is passed to predict_T_from_proxyObs().",
UserWarning, stacklevel=2,
)
posterior.attrs["proxy_name"] = "unknown"
encoding = {var: {"zlib": True} for var in posterior.data_vars}
sanitized = _sanitize_attrs_for_netcdf(posterior)
sanitized.to_netcdf(outpath, encoding=encoding)
print(f"Saved forward posterior to {outpath} [proxy_name='{posterior.attrs['proxy_name']}']")
return outpath
[docs]
def load_posterior(
model_name: str,
model_type: Literal["forward", "invT"] = "forward",
cache_dir: Optional[Union[str, Path]] = None,
) -> xr.Dataset:
"""
Load a posterior from disk: `{model_name}.nc` in the appropriate cache directory.
Args:
model_name: Name of the model file (without .nc extension)
model_type: Type of posterior ("forward" or "invT")
cache_dir: Custom cache directory (overrides default locations)
Returns:
xarray.Dataset containing the posterior
Raises:
FileNotFoundError: If the posterior file doesn't exist
"""
# Determine cache directory
if cache_dir:
indir = Path(cache_dir)
elif model_type == "forward":
indir = DEFAULT_FORWARD_DIR
elif model_type == "invT":
indir = DEFAULT_INVT_DIR
else:
# This shouldn't happen due to type hints, but just in case
raise ValueError(f"Invalid model_type: {model_type}. Must be 'forward' or 'invT'")
# Ensure directory exists
indir.mkdir(exist_ok=True, parents=True)
# Dual-read: accept either a case id (tx.v026.GHEB.sst.ri3.G23-N1p0) or a
# historical long name, and find the file under either layout. Exact-path
# lookups come first; the attr-matching scan only runs if those miss.
fpath = None
if model_type == "forward":
try:
from ..utils.naming import resolve_posterior_path
fpath = resolve_posterior_path(model_name, indir)
except Exception:
fpath = None
if fpath is None:
candidate = indir / f"{model_name}.nc"
fpath = candidate if candidate.exists() else None
if fpath is None:
# The nitrate token was respelled (N10 -> N1p0) on 2026-08-23, and a
# name and the file it points at can sit on either side of that. This
# covers the inverse cache and any leaf too specific to parse as a case
# id -- an invT leaf carries a site and scenario after the case, so the
# case-id machinery above never sees it.
try:
from ..utils.naming import swap_no3_token
alt = swap_no3_token(model_name)
except Exception:
alt = None
if alt:
candidate = indir / f"{alt}.nc"
fpath = candidate if candidate.exists() else None
if fpath is None and model_type == "forward":
# Last resort before failing: the posteriors that ship inside the wheel.
# This is what lets a bare `pip install texas-psm` reconstruct without a
# download; the cache still wins, so a user's own refit of the same case
# is never shadowed by the bundled copy.
try:
from ..utils.paths import BUNDLED_POSTERIOR_DIR
from ..utils.naming import resolve_posterior_path as _resolve
if BUNDLED_POSTERIOR_DIR.is_dir():
fpath = _resolve(model_name, BUNDLED_POSTERIOR_DIR)
if fpath is None:
candidate = BUNDLED_POSTERIOR_DIR / f"{model_name}.nc"
fpath = candidate if candidate.exists() else None
except Exception:
fpath = None
if fpath is None:
# Case dirs hold "<case>.fwd.nc"; dirs written before 2026-08-11 hold a
# bare "fwd.nc". Either way the case id is the directory name.
cased = sorted(indir.glob("*/*.fwd.nc")) + sorted(indir.glob("*/fwd.nc"))
available = sorted(indir.glob("*.nc")) + cased
available_str = "\n ".join(
dict.fromkeys(
f.parent.name if f.name.endswith("fwd.nc") else f.stem
for f in available
)
) if available else "(none)"
model_name = str(model_name)
raise FileNotFoundError(
f"Posterior file not found: '{model_name}.nc'\n"
f"Searched in: {indir}\n"
f"Files present in that directory:\n {available_str}\n\n"
f"Options:\n"
f" 1. The file is in a different directory — load it yourself and pass the Dataset:\n"
f" import xarray as xr\n"
f" ds = xr.open_dataset('/your/path/{model_name}.nc')\n"
f" predict_T_from_proxyObs(..., fwd_posterior=ds)\n\n"
f" 2. Search a different cache directory:\n"
f" load_posterior('{model_name}', cache_dir='/your/path/here')\n\n"
f" 3. Download from Zenodo:\n"
f" from TEXAS.utils.download import download_posteriors\n"
f" download_posteriors(['{model_name}'])"
)
return xr.load_dataset(fpath)
[docs]
def list_posteriors(
model_type: Literal["forward", "invT", "both"] = "both",
cache_dir: Optional[Union[str, Path]] = None,
) -> Dict[str, list]:
"""
List available posterior files in the cache directory.
Prints a summary and returns a dict of stem names that can be passed
directly to ``predict_T_from_proxyObs(fwd_posterior=...)``.
Parameters
----------
model_type : "forward", "invT", or "both"
Which cache to inspect. Default ``"both"``.
cache_dir : Path or str, optional
Override the default cache root. When given, both forward and invT
subdirectories are looked for under this path.
Returns
-------
dict
``{"forward": [...], "invT": [...]}`` — lists of stem names (no ``.nc``).
"""
if cache_dir:
root = Path(cache_dir)
fwd_dir = root / "TEXAS_posterior_cache"
invt_dir = root / "TEXAS_invT_posterior_cache"
else:
fwd_dir = DEFAULT_FORWARD_DIR
invt_dir = DEFAULT_INVT_DIR
result: Dict[str, list] = {"forward": [], "invT": []}
def _list(directory: Path, label: str) -> list:
files = sorted(directory.glob("*.nc")) if directory.exists() else []
stems = [f.stem for f in files]
print(f"{label} posteriors [{directory}]")
if stems:
for name in stems:
print(f" {name}")
else:
print(" (none)")
return stems
if model_type in ("forward", "both"):
result["forward"] = _list(fwd_dir, "Forward calibration")
# Also report what ships with the package, so a user who has downloaded
# nothing still sees the calibrations they can use right now.
try:
from ..utils.paths import BUNDLED_POSTERIOR_DIR
bundled = sorted(
f.name[: -len(".fwd.nc")]
for f in BUNDLED_POSTERIOR_DIR.glob("*.fwd.nc")
)
except Exception:
bundled = []
if bundled:
print(f"\nBundled with the package [{BUNDLED_POSTERIOR_DIR}]")
for name in bundled:
print(f" {name}")
result["bundled"] = bundled
if model_type in ("invT", "both"):
if model_type == "both":
print()
result["invT"] = _list(invt_dir, "Inverse temperature (invT)")
return result
def save_invT_posterior(
posterior: xr.Dataset,
cache_dir: Optional[Union[str, Path]] = None,
overwrite: bool = True,
filename_tag: Optional[Union[str, Sequence[str]]] = None,
) -> Path:
"""
Save an inverse-T posterior to disk as compressed NetCDF.
Default location: repo/.../TEXAS/invT_posterior_cache/
The filename comes from :func:`_generate_filename_base`, the same builder
the internal save path uses, so a reconstruction lands in one place however
it was produced.
This function used to build ``{site}_{model}_{temptype}.nc`` itself, which
omitted ``proxy_name``: a ``scaledRI`` and a ``TEX86`` reconstruction of one
site collided on a single path, and with the default ``overwrite=True`` the
second silently replaced the first. It was also case-unaware, so it could
not place a reconstruction inside its calibration's case directory. Nothing
on disk used that spelling -- every cached invT posterior came from the
internal path -- so unifying them changes no existing file.
"""
if not isinstance(posterior, xr.Dataset):
raise TypeError("posterior must be an xarray.Dataset")
return _save_invT_posterior(
posterior=posterior,
cache_dir=cache_dir,
overwrite=overwrite,
filename_tag=filename_tag,
)
# ─── Private helpers for invT I/O ──────────────────────────────────────────
def _slug(x: str) -> str:
s = str(x).strip().replace(" ", "-")
return re.sub(r"[^a-zA-Z0-9._-]+", "", s)
def _generate_filename_base(
meta: Dict[str, Any],
filename_tag: Optional[Union[str, Sequence[str]]],
) -> str:
"""
Generate the base filename for saving invT results.
Format: {site}_{model}_{temptype}_{tags}_{model_type}
Model type (direct/ensemble) goes at the end for easy identification.
"""
site_name = _slug(meta.get("SiteName", meta.get("site_name", "unknown_site")))
stan_model = meta.get("stan_model_name", meta.get("stan_model", "unknown_model"))
temptype = meta.get("temptype", "unknown_temptype")
if "marginal" in stan_model:
model_type = "direct"
clean_stan_model = stan_model.replace("_marginal", "")
else:
model_type = "ensemble"
clean_stan_model = stan_model
# Case layout: a reconstruction belongs to the calibration it marginalised
# over, so it is named for that calibration's case. Requires the fwd_case
# attr that build_invT_inputData attaches; posteriors produced before that
# fall through to the legacy name below.
#
# The leaf comes from naming.inv_relpath() rather than being spelled out
# again here. This function used to reimplement it, which is how the two
# drifted: inv_relpath still described the pre-flattening <case>/<leaf>
# layout in its docstring while this branch had already gone flat, and only
# one of them was reachable from production. There is now one builder, so a
# change to the inverse naming scheme cannot land in half of it.
fwd_case = meta.get("fwd_case", "")
if fwd_case:
try:
from ..utils.naming import CONSTRAINT_CODES, inv_relpath, is_case_id
if is_case_id(fwd_case):
constraint = next(
(c for c in CONSTRAINT_CODES if c in stan_model), "unconstrained"
)
leaf = inv_relpath(
fwd_case,
site_name,
constraint=constraint,
kind=model_type,
scenario=filename_tag,
# No run number: a reconstruction is identified by its
# (case, site, scenario), and the case already carries the
# member of the calibration it marginalised over.
run=None,
)
# Callers append the suffix themselves; .stem drops only ".nc".
return leaf.stem
except Exception:
pass # naming is a convenience; never block a save on it
temp_parts = [temptype]
if int(meta.get("use_gdgt23ratio", 0)) == 1:
temp_parts.append("gdgt23ratio")
if int(meta.get("use_no3", 0)) == 1:
no3_cutoff = meta.get("no3_cutoff")
if no3_cutoff is None:
raise ValueError("no3_cutoff missing but use_no3=1.")
temp_parts.append(f"no3_{no3_cutoff}")
temptype_str = "_".join(temp_parts)
proxy_name = meta.get("proxy_name", "") or ""
proxy_segment = f"_{_slug(proxy_name)}" if proxy_name and proxy_name != "unknown" else ""
tag_segment = ""
if filename_tag:
tags = [filename_tag] if isinstance(filename_tag, str) else filename_tag
tag_segment = "_" + "+".join(_slug(t) for t in tags if t)
return f"{site_name}_{clean_stan_model}_{temptype_str}{proxy_segment}{tag_segment}_{model_type}"
def _sanitize_attrs_for_netcdf(ds: xr.Dataset) -> xr.Dataset:
"""Convert posterior attrs to NetCDF-compatible types."""
clean_attrs = {}
for k, v in ds.attrs.items():
if v is None:
continue
elif isinstance(v, bool):
clean_attrs[k] = int(v)
elif isinstance(v, (str, bytes, int, float, np.number)):
clean_attrs[k] = v
elif isinstance(v, (list, tuple, np.ndarray)):
arr = np.asarray(v)
if arr.dtype == bool:
clean_attrs[k] = arr.astype(int).tolist()
else:
clean_attrs[k] = arr.tolist()
else:
try:
clean_attrs[k] = json.dumps(v)
except TypeError:
clean_attrs[k] = str(v)
ds_copy = ds.copy()
ds_copy.attrs = clean_attrs
return ds_copy
def _save_invT_posterior(
posterior: xr.Dataset,
cache_dir: Optional[Union[str, Path]] = None,
overwrite: bool = True,
filename_tag: Optional[Union[str, Sequence[str]]] = None,
) -> Path:
"""Save an invT posterior with detailed auto-generated filename."""
output_dir = Path(cache_dir) if cache_dir else DEFAULT_INVT_DIR
output_dir.mkdir(parents=True, exist_ok=True)
base = _generate_filename_base(posterior.attrs, filename_tag)
filepath = output_dir / f"{base}.nc"
filepath.parent.mkdir(parents=True, exist_ok=True)
if filepath.exists() and not overwrite:
raise FileExistsError(f"{filepath} already exists and overwrite=False.")
posterior.attrs.setdefault("proxy_name", "")
# Record the name we wrote, as save_posterior does for forward posteriors.
# It is what lets a file renamed later still be resolved by its original
# name, and what run_from_attrs() reads to recover a run token.
posterior.attrs["filename"] = filepath.name
encoding = {var: {"zlib": True} for var in posterior.data_vars}
sanitized = _sanitize_attrs_for_netcdf(posterior)
sanitized.to_netcdf(filepath, encoding=encoding)
print(f"✅ Posterior saved to {filepath}")
return filepath
def _save_invT_draws(
draws: xr.Dataset,
cache_dir: Optional[Union[str, Path]] = None,
filename_tag: Optional[Union[str, Sequence[str]]] = None,
overwrite: bool = True,
) -> Path:
"""Save raw invT posterior draws (pre-quantile) as a compressed .nc file.
The filename mirrors the quantile posterior but with a ``_draws`` suffix,
e.g. ``ODP1259_..._040226_direct_draws.nc``.
Draws are automatically organized into a ``draws/`` subdirectory.
"""
base_dir = Path(cache_dir) if cache_dir else DEFAULT_INVT_DIR
output_dir = base_dir / "draws" # Auto-organize into draws subfolder
output_dir.mkdir(parents=True, exist_ok=True)
base = _generate_filename_base(draws.attrs, filename_tag)
filepath = output_dir / f"{base}_draws.nc"
filepath.parent.mkdir(parents=True, exist_ok=True)
if filepath.exists() and not overwrite:
raise FileExistsError(f"{filepath} already exists and overwrite=False.")
encoding = {var: {"zlib": True} for var in draws.data_vars}
sanitized = _sanitize_attrs_for_netcdf(draws)
sanitized.to_netcdf(filepath, encoding=encoding)
print(f"✅ Raw draws saved to {filepath}")
return filepath
def _save_invT_results(
results: Dict[str, Any],
path: Optional[Union[str, Path]] = None,
overwrite: bool = True,
) -> Path:
"""Save invT quantile results dict as a compressed .npz file."""
meta = results.get("metadata", {})
if path is None:
output_dir = DEFAULT_INVT_DIR
output_dir.mkdir(parents=True, exist_ok=True)
filename_tag = meta.get("filename_tag")
base = _generate_filename_base(meta, filename_tag)
path = output_dir / f"{base}.npz"
else:
path = Path(path)
if path.exists() and not overwrite:
raise FileExistsError(f"{path} already exists and overwrite=False.")
savez_dict = {k: np.asarray(v) for k, v in results.items() if k != "metadata"}
savez_dict["__metadata__"] = np.array([json.dumps(meta)])
np.savez(path, **savez_dict)
print(f"✅ invT results saved: {path}")
return path