Source code for qanary.dataset
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Union
import xarray as xr
# Local imports
from qanary.logger import get_logger
# Public API
__all__ = [
"load",
"convert",
"QanaryDatasetError",
"DatasetNotFoundError",
"ZarrStoreNotFoundError",
"DatasetLoadError",
"DatasetConvertError",
]
logger = get_logger(__name__)
# Custom exceptions
[docs]
class QanaryDatasetError(Exception):
"""Base class for all dataset-related errors in qanary."""
[docs]
class DatasetNotFoundError(QanaryDatasetError):
"""Raised when the requested dataset file or directory does not exist."""
[docs]
class ZarrStoreNotFoundError(QanaryDatasetError):
"""Raised when the expected .zarr store directory cannot be found."""
[docs]
class DatasetLoadError(QanaryDatasetError):
"""Raised when xarray cannot open the dataset."""
[docs]
class DatasetConvertError(QanaryDatasetError):
"""Raised when conversion from .zarr to .nc fails."""
# Public helpers
[docs]
def load(path: Union[str, Path]) -> xr.Dataset:
"""
Load a qanary measurement dataset.
Wraps :func:`xarray.load_dataset` with engine selection appropriate for
Qanary-generated files (automatic selection for ``.nc`` and the Zarr
engine for ``.zarr``) and clear error messages.
Args:
path (Union[str, Path]): Path to a ``.nc`` file or ``.zarr`` directory
created by a Qanary :class:`~qanary.measure.Measurement` run.
Returns:
xr.Dataset: The fully-loaded (in-memory) xarray dataset.
Raises:
DatasetNotFoundError: If *path* does not exist on disk.
DatasetLoadError: If xarray raises any error while opening the file.
"""
path = Path(path)
if not path.exists():
raise DatasetNotFoundError(
f"Dataset not found: '{path}'. "
"Check that the path is correct and the measurement has finished."
)
engine = "zarr" if path.suffix == ".zarr" else None
logger.info(f"Loading dataset from '{path}' (engine={engine or 'auto'})")
try:
ds = xr.load_dataset(path, engine=engine)
except Exception as exc:
raise DatasetLoadError(f"Failed to load dataset from '{path}': {exc}") from exc
logger.info(f"Loaded dataset: {list(ds.data_vars)} | dims={dict(ds.sizes)}")
return ds
[docs]
def convert(nc_path: Union[str, Path], *, overwrite: bool = False) -> Path:
"""
Convert the paired ``.zarr`` store for a measurement to netCDF.
A normal run exports its current in-memory dataset directly to netCDF and
removes its temporary live checkpoint. If recovery instead leaves a
``.zarr`` directory beside the intended ``.nc`` file, this function can
complete that conversion manually.
Expected layout on disk::
<stem>.nc <- target output (may or may not exist yet)
<stem>.zarr/ <- live zarr store (must exist)
Args:
nc_path (Union[str, Path]): Path to the (possibly missing) ``.nc`` file.
The function derives the ``.zarr`` path by replacing the suffix.
overwrite (bool): If *True*, overwrite an existing ``.nc`` file silently.
Defaults to *False*.
Returns:
Path: Resolved absolute path to the written ``.nc`` file.
Raises:
ZarrStoreNotFoundError: If the ``.zarr`` directory does not exist next
to *nc_path*.
DatasetConvertError: If *nc_path* already exists and *overwrite* is
*False*, or if conversion fails for another reason.
"""
nc_path = Path(nc_path).resolve()
zarr_path = nc_path.with_suffix(".zarr")
# Validate zarr store
if not zarr_path.exists():
raise ZarrStoreNotFoundError(
f"No .zarr store found at '{zarr_path}'. "
"Either the measurement never started writing, or the zarr store "
"was already converted and removed."
)
# Guard against accidental overwrites
if nc_path.exists() and not overwrite:
raise DatasetConvertError(
f"Output file '{nc_path}' already exists. "
"Pass overwrite=True to replace it, or remove the file manually."
)
# Load zarr → write netCDF
logger.info(f"Converting '{zarr_path}' -> '{nc_path}'")
try:
ds = xr.load_dataset(zarr_path, engine="zarr")
except Exception as exc:
raise DatasetConvertError(
f"Failed to read zarr store at '{zarr_path}': {exc}"
) from exc
tmp_nc = nc_path.with_suffix(".nc.tmp")
try:
ds.to_netcdf(tmp_nc, mode="w")
tmp_nc.replace(nc_path)
except Exception as exc:
# Clean up incomplete temporary file
if tmp_nc.exists():
try:
tmp_nc.unlink()
except OSError:
pass
raise DatasetConvertError(
f"Failed to write netCDF file to '{nc_path}': {exc}"
) from exc
logger.info(f"Successfully wrote netCDF file: '{nc_path}'")
# Remove zarr store
try:
shutil.rmtree(zarr_path)
logger.info(f"Removed zarr store: '{zarr_path}'")
except Exception as exc:
# Non-fatal: warn but don't fail the whole operation
logger.warning(
f"netCDF export succeeded but failed to remove zarr store "
f"at '{zarr_path}': {exc}. You may delete it manually."
)
return nc_path