Source code for movement.io.save

"""Save ``movement`` datasets to various file formats."""

from collections.abc import Callable
from functools import wraps
from pathlib import Path
from typing import Concatenate, Literal, ParamSpec, Protocol, cast

import xarray as xr
from attrs import field, frozen, validators

from movement.utils.logging import logger
from movement.validators.datasets import DS_TYPE_VALIDATORS, DsType
from movement.validators.files import validate_file_path

type TargetSoftware = Literal[
    "movement",
    "DeepLabCut",
    "SLEAP",
    "LightningPose",
    "NWB",
    "VIA-tracks",
]

P = ParamSpec("P")


[docs] class WriterProtocol(Protocol): """Protocol for writer functions to be registered via ``register_writer``. All writer functions registered via :func:`register_writer` must conform to this protocol. Writers must accept a ``movement`` dataset and a file path, and save the dataset directly to disk. Additional positional and keyword arguments are allowed. See Also -------- register_writer : Decorator for registering writer functions. """
[docs] def __call__( self, ds: xr.Dataset, file: str | Path, *args, **kwargs ) -> None: """Save a ``movement`` dataset to a file. Parameters ---------- ds The ``movement`` dataset to save. file Path to the file to save the dataset to. *args Additional positional arguments for the writer. **kwargs Additional keyword arguments for the writer. """ ...
@frozen class _WriterEntry: """Writer function and dataset-type restriction for a target software.""" writer: WriterProtocol = field(validator=validators.is_callable()) """Writer function for the target software.""" ds_type: DsType | None = field(default=None, kw_only=True) """``movement`` dataset type the writer is restricted to, or None if unrestricted.""" suffixes: set[str] | None = field(default=None, kw_only=True) """Set of valid file suffixes for the writer, or None if unrestricted.""" _WRITER_REGISTRY: dict[TargetSoftware, _WriterEntry] = {}
[docs] def get_supported_target_software() -> dict[TargetSoftware, set[str]]: """Return the supported target software and file suffixes for saving data. Returns a mapping of each registered target software name (i.e. a format ``movement`` can save data *to*) to the set of file suffixes it supports for that format. This is useful for downstream packages that depend on ``movement`` for I/O and want to programmatically discover supported saving formats. Returns ------- dict[TargetSoftware, set[str]] Mapping of target software names to sets of supported file suffixes (e.g. ``{".h5", ".csv"}``). Writers registered without specific suffixes will have an empty set. See Also -------- movement.io.load.get_supported_source_software : Return the source software names and file suffixes supported for *loading* data. """ return { sw: set(entry.suffixes) if entry.suffixes else set() for sw, entry in _WRITER_REGISTRY.items() }
[docs] def register_writer( target_software: TargetSoftware, *, ds_type: DsType | None = None, suffixes: set[str] | None = None, ) -> Callable[ [Callable[Concatenate[xr.Dataset, str | Path, P], None]], Callable[Concatenate[xr.Dataset, str | Path, P], None], ]: """Register a writer function for a given target software. The decorator also handles dataset and file path validation: the dataset is checked against its expected type (``ds_type``, or if unset, the type inferred from ``ds.attrs["ds_type"]``), and the file path is checked for write permission and optional suffixes, before the writer function is called. Parameters ---------- target_software The name of the target software to save to. ds_type The ``movement`` dataset type the writer is restricted to. If None (default), the writer (e.g., ``movement``'s native netCDF format) accepts either type, and the expected type is instead inferred from the dataset's ``ds_type`` attribute at save time. suffixes The set of valid file suffixes (e.g. ``{".h5", ".csv"}``) for this writer. If None (default), the file path is still validated for write permission, but its suffix is not checked. Returns ------- collections.abc.Callable A decorator that registers the writer function. Notes ----- The ``file`` argument passed to the decorated writer function will be a validated :class:`pathlib.Path` object, instead of the original file path. Examples -------- >>> from movement.io.save import register_writer >>> @register_writer("SLEAP", ds_type="poses", suffixes={".h5"}) ... def to_sleap_analysis_file(ds: xr.Dataset, file: str | Path): ... pass """ def decorator( writer_fn: Callable[Concatenate[xr.Dataset, str | Path, P], None], ) -> Callable[Concatenate[xr.Dataset, str | Path, P], None]: @wraps(writer_fn) def wrapper( ds: xr.Dataset, file: str | Path, *args: P.args, **kwargs: P.kwargs ) -> None: _validate_ds_type(ds, target_software) valid_file = validate_file_path( file, permission="w", suffixes=suffixes ) return writer_fn(ds, valid_file, *args, **kwargs) _WRITER_REGISTRY[target_software] = _WriterEntry( cast("WriterProtocol", wrapper), ds_type=ds_type, suffixes=suffixes ) return wrapper return decorator
[docs] def save_dataset( ds: xr.Dataset, file: str | Path, target_software: TargetSoftware = "movement", **kwargs, ) -> None: """Save a ``movement`` dataset to a file in any supported format. Parameters ---------- ds The ``movement`` dataset to save. file Path to the file to save the dataset to. The required file extension depends on the target format. target_software The format to save the dataset in. Defaults to ``"movement"``, which saves the dataset in ``movement``'s native netCDF format (``.nc``). Otherwise, one of the :ref:`supported third-party formats <target-supported-formats>`. **kwargs Additional keyword arguments passed to the format-specific writer (e.g. ``split_individuals`` for DeepLabCut, ``config`` for NWB, or any :meth:`xarray.Dataset.to_netcdf` argument for movement-netCDF). Returns ------- None Raises ------ TypeError If ``ds`` is not an :class:`xarray.Dataset`. ValueError If ``target_software`` is not a supported format, or if it is incompatible with the dataset's ``ds_type`` (e.g. saving a bounding boxes dataset to a poses-only format). See Also -------- movement.io.save_poses movement.io.save_bboxes Notes ----- For NWB, each individual is written to a separate file (as required by the NWB format). For a single-individual dataset, the dataset is written to ``file``. For a multi-individual dataset, each NWBFile's ``identifier`` (by default the individual's name, but configurable via :class:`~movement.io.nwb.NWBFileSaveConfig`) is appended to the file path, just before the extension, e.g. ``"/path/to/file_id_0.nwb"``. Examples -------- Save a dataset to ``movement``'s native netCDF format: >>> from movement.io import save_dataset >>> save_dataset(ds, "/path/to/file.nc") Save a poses dataset to a DeepLabCut .h5 file: >>> save_dataset(ds, "/path/to/file.h5", target_software="DeepLabCut") Save a bounding boxes dataset to a VIA-tracks .csv file: >>> save_dataset(ds, "/path/to/file.csv", target_software="VIA-tracks") Save a poses dataset to NWB file(s), with custom metadata: >>> from movement.io.nwb import NWBFileSaveConfig >>> config = NWBFileSaveConfig( ... nwbfile_kwargs={"session_description": "foraging session"} ... ) >>> save_dataset( ... ds, "/path/to/file.nwb", target_software="NWB", config=config ... ) """ if target_software not in _WRITER_REGISTRY: supported = ", ".join(_WRITER_REGISTRY) raise logger.error( ValueError( f"Unsupported target_software: '{target_software}'. " f"Supported values are: {supported}." ) ) _WRITER_REGISTRY[target_software].writer(ds, file, **kwargs)
def _validate_ds_type(ds: xr.Dataset, target: TargetSoftware) -> None: """Check that ``ds`` is a valid dataset for the given save ``target``. For targets restricted to a specific ``movement`` dataset type (poses or bounding boxes), the corresponding validator's ``validate()`` classmethod is used to check that ``ds`` has the required variables and dimensions for that type. For targets compatible with either type (currently only "movement"), the expected type is inferred from ``ds.attrs["ds_type"]``. """ expected = _WRITER_REGISTRY[target].ds_type if expected is None: # movement: determine the expected type from ds_type try: expected = ds.attrs.get("ds_type") except AttributeError: # let validator raise TypeError for non-xr.Dataset expected = "poses" if expected not in DS_TYPE_VALIDATORS: raise logger.error( ValueError( f"Cannot save to '{target}': expected `ds` to have a " f"'ds_type' attribute of 'poses' or 'bboxes', but got " f"{expected!r}." ) ) DS_TYPE_VALIDATORS[expected].validate(ds) @register_writer("movement", suffixes={".nc"}) def _to_netcdf_file(ds: xr.Dataset, file: str | Path, **kwargs) -> None: """Save a ``movement`` dataset to a netCDF (.nc) file.""" ds.to_netcdf(file, **kwargs) logger.info(f"Saved dataset to {file}.")