Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added examples/example_data/diffusion_data_example.h5
Binary file not shown.
Binary file added examples/example_data/vanadium_data_example.h5
Binary file not shown.
48 changes: 48 additions & 0 deletions examples/experiment_example.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "51b7b8be",
"metadata": {},
"outputs": [],
"source": [
"from easydynamics.experiment import Experiment\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "eb91e49a",
"metadata": {},
"outputs": [],
"source": [
"vanadium_experiment=Experiment(\"Vanadium\")\n",
"vanadium_experiment.load_hdf5(filename=\"example_data/vanadium_data_example.h5\")\n",
"\n",
"vanadium_experiment.plot_data()\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "newdynamics",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.13"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
7 changes: 7 additions & 0 deletions src/easydynamics/experiment/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from .experiment import (
Experiment,
)

__all__ = [
"Experiment",
]
137 changes: 137 additions & 0 deletions src/easydynamics/experiment/experiment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
from typing import Optional, Union

import plopp as pp
import scipp as sc
from easyscience.job.experiment import ExperimentBase
from scipp.io import load_hdf5 as sc_load_hdf5
from scipp.io import save_hdf5 as sc_save_hdf5


class Experiment(ExperimentBase):
"""
Holds data from an experiment as a sc.DataArray along with metadata.
"""

def __init__(
self,
name: str,
data: Optional[Union[sc.DataArray, str]] = None,
*args,
**kwargs,
):
super().__init__(name, *args, **kwargs)

if data is None:
self._data: Optional[sc.DataArray] = None
elif isinstance(data, str):
self.load_hdf5(filename=data)
elif isinstance(data, sc.DataArray):
self._data = data
else:
raise TypeError(
f"Data must be a sc.DataArray or a filename string, not {type(data).__name__}"
)

def load_hdf5(self, filename: str, name: Optional[str] = None):
"""
Load data from an HDF5 file.

Args:
filename (str): Path to the HDF5 file.
"""
if not isinstance(filename, str):
raise TypeError(f"Filename must be a string, not {type(filename).__name__}")

if name is not None:
if not isinstance(name, str):
raise TypeError(f"Name must be a string, not {type(name).__name__}")
self.name = name

# TODO: Add checks of dimensions etc. I'm not yet sure what dimensions I want to allow, so for now I trust that the data is valid.
loaded_data = sc_load_hdf5(filename)
if not isinstance(loaded_data, sc.DataArray):
raise TypeError(
f"Loaded data must be a sc.DataArray, not {type(loaded_data).__name__}"
)
self._data = loaded_data

def save_hdf5(self, filename: Optional[str] = None):
"""Save the dataset to HDF5.

Args:
filename (str): Path to the output HDF5 file.
"""

if filename is None:
filename = f"{self.name}.h5"

if not isinstance(filename, str):
raise TypeError(f"Filename must be a string, not {type(filename).__name__}")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calling experiment.save_hdf5() with no arguments (or any filename without a directory component) raises FileNotFoundError because os.path.dirname('test.h5')returns '', so os.makedirs('', exist_ok=True) fails on Windows. The default filename = f"{self.name}.h5" therefore breaks out of the box.
Guard the directory creation with something like

 dir_name = os.path.dirname(filename);
 if dir_name: 
       os.makedirs(dir_name, exist_ok=True)


if self._data is None:
raise ValueError("No data to save.")

import os

dir_name = os.path.dirname(filename)
if dir_name:
os.makedirs(dir_name, exist_ok=True)

sc_save_hdf5(self._data, filename)

def remove_data(self):
"""Remove the dataset from the experiment."""
self._data = None

@property
def data(self) -> Optional[sc.DataArray]:
"""Get the dataset associated with this experiment."""
return self._data

@data.setter
def data(self, value: sc.DataArray):
"""Set the dataset associated with this experiment."""
if not isinstance(value, sc.DataArray):
raise TypeError(f"Data must be a sc.DataArray, not {type(value).__name__}")
self._data = value

def plot_data(self):
"""Plot the dataset using plopp."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no idea if and how to write unit tests for plotting.

use mocking like there's no tomorrow ;)

e.g.

    def test_plot_data_success(self, experiment):
        "Test plotting data successfully when in notebook environment"
        # GIVEN
        with patch.object(Experiment, '_in_notebook', return_value=True), \
             patch('plopp.plot') as mock_plot, \
             patch('IPython.display.display') as mock_display:
            
            mock_fig = MagicMock()
            mock_plot.return_value = mock_fig
            
            # WHEN
            experiment.plot_data()
            
            # THEN
            mock_plot.assert_called_once()
            args, kwargs = mock_plot.call_args
            assert sc.identical(args[0], experiment._data.transpose())
            assert kwargs['title'] == f"{experiment.name}"
            mock_display.assert_called_once_with(mock_fig)

    def test_plot_data_no_data_raises(self):
        "Test plotting data raises ValueError when no data is present"
        # GIVEN
        experiment = Experiment(name="empty_experiment")
        
        # WHEN / THEN
        with pytest.raises(ValueError, match="No data to plot"):
            experiment.plot_data()

    def test_plot_data_not_in_notebook_raises(self, experiment):
        "Test plotting data raises RuntimeError when not in notebook environment"
        # GIVEN
        with patch.object(Experiment, '_in_notebook', return_value=False):
            
            # WHEN / THEN
            with pytest.raises(RuntimeError, match="plot_data\\(\\) can only be used in a Jupyter notebook environment"):
                experiment.plot_data()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I'll look into it


if self._data is None:
raise ValueError("No data to plot. Please load data first.")

if not self._in_notebook():
raise RuntimeError(
"plot_data() can only be used in a Jupyter notebook environment."
)

from IPython.display import display

fig = pp.plot(self._data.transpose(), title=f"{self.name}")
display(fig)

@staticmethod
def _in_notebook():
try:
from IPython import get_ipython

shell = get_ipython().__class__.__name__
if shell == "ZMQInteractiveShell":
return True # Jupyter notebook or JupyterLab
elif shell == "TerminalInteractiveShell":
return False # Terminal IPython
else:
return False
except (NameError, ImportError):
return False # Standard Python (no IPython)

def __repr__(self) -> str:
return f"Experiment `{self.name}` with data: {self._data}"

def __copy__(self) -> "Experiment":
"""Return a copy of the object."""
temp = self.as_dict(skip=["unique_name"])
new_obj = self.__class__.from_dict(temp)
new_obj.data = self.data.copy() if self.data is not None else None
return new_obj
Loading