# 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 = "",
) -> 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``, e.g. ``"032326"`` for a
date-stamped run. Leading/trailing underscores are stripped.
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('_')}"
outpath = outdir / f"{name}_{ttype}{proxy_tag}{filename_suffix}.nc"
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)
# Construct file path
fpath = indir / f"{model_name}.nc"
if not fpath.exists():
available = sorted(indir.glob("*.nc"))
available_str = "\n ".join(f.stem for f in available) if available else "(none)"
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")
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,
) -> Path:
"""
Save an inverse-T posterior to disk as compressed NetCDF.
Default location: repo/.../TEXAS/invT_posterior_cache/
"""
if not isinstance(posterior, xr.Dataset):
raise TypeError("posterior must be an xarray.Dataset")
outdir = Path(cache_dir) if cache_dir else DEFAULT_INVT_DIR
outdir.mkdir(exist_ok=True, parents=True)
site = posterior.attrs.get("SiteName", "unknown_site")
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}"
outpath = outdir / f"{site}_{name}_{ttype}.nc"
if outpath.exists() and not overwrite:
raise FileExistsError(f"{outpath} exists and overwrite=False")
posterior.attrs["filename"] = outpath.name
posterior.attrs.setdefault("proxy_name", "")
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 inverse-T posterior to {outpath}")
return outpath
# ─── 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
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"
if filepath.exists() and not overwrite:
raise FileExistsError(f"{filepath} already exists and overwrite=False.")
posterior.attrs.setdefault("proxy_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"
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