From 276965fc76863188cb4ba17c829ce149b6b59826 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Mon, 9 Mar 2026 20:16:34 +0100 Subject: [PATCH 1/5] move extract_x_y_weights to experiment --- src/easydynamics/analysis/analysis.py | 82 +++---- src/easydynamics/analysis/analysis1d.py | 74 ++++--- src/easydynamics/analysis/analysis_base.py | 64 +++--- src/easydynamics/experiment/experiment.py | 90 +++++--- .../easydynamics/analysis/test_analysis1d.py | 204 ++++++++++-------- .../analysis/test_analysis_base.py | 163 +++++++------- .../experiment/test_experiment.py | 196 ++++++++++------- 7 files changed, 479 insertions(+), 394 deletions(-) diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index da6decfd..9e6b91e2 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -57,7 +57,7 @@ class Analysis(AnalysisBase): def __init__( self, - display_name: str = 'MyAnalysis', + display_name: str = "MyAnalysis", unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -100,8 +100,8 @@ def __init__( if self.Q is not None: for Q_index in range(len(self.Q)): analysis = Analysis1d( - display_name=f'{self.display_name}_Q{Q_index}', - unique_name=(f'{self.unique_name}_Q{Q_index}'), + display_name=f"{self.display_name}_Q{Q_index}", + unique_name=(f"{self.unique_name}_Q{Q_index}"), experiment=self.experiment, sample_model=self.sample_model, instrument_model=self.instrument_model, @@ -139,9 +139,9 @@ def analysis_list(self, value: list[Analysis1d]) -> None: """ raise AttributeError( - 'analysis_list is read-only. ' - 'To change the analysis list, modify the experiment, sample model, ' - 'or instrument model.' + "analysis_list is read-only. " + "To change the analysis list, modify the experiment, sample model, " + "or instrument model." ) ############# @@ -176,7 +176,7 @@ def calculate( def fit( self, - fit_method: str = 'independent', + fit_method: str = "independent", Q_index: int | None = None, ) -> FitResults | list[FitResults]: """Fit the model to the experimental data. @@ -204,20 +204,22 @@ def fit( if self.Q is None: raise ValueError( - 'No Q values available for fitting. Please check the experiment data.' + "No Q values available for fitting. Please check the experiment data." ) Q_index = self._verify_Q_index(Q_index) - if fit_method == 'independent': + if fit_method == "independent": if Q_index is not None: return self._fit_single_Q(Q_index) else: return self._fit_all_Q_independently() - elif fit_method == 'simultaneous': + elif fit_method == "simultaneous": return self._fit_all_Q_simultaneously() else: - raise ValueError("Invalid fit method. Choose 'independent' or 'simultaneous'.") + raise ValueError( + "Invalid fit method. Choose 'independent' or 'simultaneous'." + ) def plot_data_and_model( self, @@ -264,42 +266,44 @@ def plot_data_and_model( ) if self.experiment.binned_data is None: - raise ValueError('No data to plot. Please load data first.') + raise ValueError("No data to plot. Please load data first.") if not _in_notebook(): - raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.') + raise RuntimeError( + "plot_data() can only be used in a Jupyter notebook environment." + ) if self.Q is None: raise ValueError( - 'No Q values available for plotting. Please check the experiment data.' + "No Q values available for plotting. Please check the experiment data." ) if not isinstance(plot_components, bool): - raise TypeError('plot_components must be True or False.') + raise TypeError("plot_components must be True or False.") if not isinstance(add_background, bool): - raise TypeError('add_background must be True or False.') + raise TypeError("add_background must be True or False.") import plopp as pp plot_kwargs_defaults = { - 'title': self.display_name, - 'linestyle': {'Data': 'none', 'Model': '-'}, - 'marker': {'Data': 'o', 'Model': None}, - 'color': {'Data': 'black', 'Model': 'red'}, - 'markerfacecolor': {'Data': 'none', 'Model': 'none'}, + "title": self.display_name, + "linestyle": {"Data": "none", "Model": "-"}, + "marker": {"Data": "o", "Model": None}, + "color": {"Data": "black", "Model": "red"}, + "markerfacecolor": {"Data": "none", "Model": "none"}, } data_and_model = { - 'Data': self.experiment.binned_data, - 'Model': self._create_model_array(), + "Data": self.experiment.binned_data, + "Model": self._create_model_array(), } if plot_components: components = self._create_components_dataset(add_background=add_background) for key in components.keys(): data_and_model[key] = components[key] - plot_kwargs_defaults['linestyle'][key] = '--' - plot_kwargs_defaults['marker'][key] = None + plot_kwargs_defaults["linestyle"][key] = "--" + plot_kwargs_defaults["marker"][key] = None # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -326,7 +330,7 @@ def parameters_to_dataset(self) -> sc.Dataset: parameter across different Q values. """ - ds = sc.Dataset(coords={'Q': self.Q}) + ds = sc.Dataset(coords={"Q": self.Q}) # Collect all parameter names all_names = { @@ -356,7 +360,7 @@ def parameters_to_dataset(self) -> sc.Dataset: except Exception as e: raise UnitError( f"Inconsistent units for parameter '{name}': " - f'{units[name]} vs {p.unit}' + f"{units[name]} vs {p.unit}" ) from e values[name].append(p.value) @@ -368,7 +372,7 @@ def parameters_to_dataset(self) -> sc.Dataset: # Build dataset variables for name in all_names: ds[name] = sc.Variable( - dims=['Q'], + dims=["Q"], values=np.asarray(values[name], dtype=float), variances=np.asarray(variances[name], dtype=float), unit=units.get(name, None), @@ -403,8 +407,10 @@ def plot_parameters( if isinstance(names, str): names = [names] - if not isinstance(names, list) or not all(isinstance(name, str) for name in names): - raise TypeError('names must be a string or a list of strings.') + if not isinstance(names, list) or not all( + isinstance(name, str) for name in names + ): + raise TypeError("names must be a string or a list of strings.") for name in names: if name not in ds: @@ -412,9 +418,9 @@ def plot_parameters( data_to_plot = {name: ds[name] for name in names} plot_kwargs_defaults = { - 'linestyle': {name: 'none' for name in names}, - 'marker': {name: 'o' for name in names}, - 'markerfacecolor': {name: 'none' for name in names}, + "linestyle": {name: "none" for name in names}, + "marker": {name: "o" for name in names}, + "markerfacecolor": {name: "none" for name in names}, } plot_kwargs_defaults.update(kwargs) @@ -506,7 +512,7 @@ def _fit_all_Q_simultaneously(self) -> FitResults: ws = [] for analysis in self.analysis_list: - x, y, weight = self._extract_x_y_weights_from_experiment(analysis.Q_index) + x, y, weight = self.experiment._extract_x_y_weights(analysis.Q_index) xs.append(x) ys.append(y) ws.append(weight) @@ -544,10 +550,10 @@ def _create_model_array(self) -> sc.DataArray: dimensions "Q" and "energy". """ - model = sc.array(dims=['Q', 'energy'], values=self.calculate()) + model = sc.array(dims=["Q", "energy"], values=self.calculate()) model_data_array = sc.DataArray( data=model, - coords={'Q': self.Q, 'energy': self.experiment.energy}, + coords={"Q": self.Q, "energy": self.experiment.energy}, ) return model_data_array @@ -568,14 +574,14 @@ def _create_components_dataset(self, add_background: bool = True) -> sc.Dataset: of the model, with dimensions "Q". """ if not isinstance(add_background, bool): - raise TypeError('add_background must be True or False.') + raise TypeError("add_background must be True or False.") datasets = [ analysis._create_components_dataset_single_Q(add_background=add_background) for analysis in self.analysis_list ] - return sc.concat(datasets, dim='Q') + return sc.concat(datasets, dim="Q") ############# # Dunder methods diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index d267c546..4b106cb5 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -62,7 +62,7 @@ class Analysis1d(AnalysisBase): def __init__( self, - display_name: str = 'MyAnalysis', + display_name: str = "MyAnalysis", unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -185,7 +185,7 @@ def fit(self) -> FitResults: FitResults: The result of the fit. """ if self._experiment is None: - raise ValueError('No experiment is associated with this Analysis.') + raise ValueError("No experiment is associated with this Analysis.") # Create convolver once to reuse during fitting self._convolver = self._create_convolver() @@ -195,7 +195,9 @@ def fit(self) -> FitResults: fit_function=self.as_fit_function(), ) - x, y, weights = self._extract_x_y_weights_from_experiment(Q_index=self._require_Q_index()) + x, y, weights = self.experiment._extract_x_y_weights( + Q_index=self._require_Q_index() + ) fit_result = fitter.fit(x=x, y=y, weights=weights) self._fit_result = fit_result @@ -263,33 +265,37 @@ def plot_data_and_model( import plopp as pp if self.experiment.data is None: - raise ValueError('No data to plot. Please load data first.') + raise ValueError("No data to plot. Please load data first.") - data = self.experiment.data['Q', self.Q_index] + data = self.experiment.data["Q", self.Q_index] model_array = self._create_sample_scipp_array() - component_dataset = self._create_components_dataset_single_Q(add_background=add_background) + component_dataset = self._create_components_dataset_single_Q( + add_background=add_background + ) # Create a dataset containing the data, model, and individual # components for plotting. - data_and_model = sc.Dataset({ - 'Data': data, - 'Model': model_array, - }) + data_and_model = sc.Dataset( + { + "Data": data, + "Model": model_array, + } + ) data_and_model = sc.merge(data_and_model, component_dataset) plot_kwargs_defaults = { - 'title': self.display_name, - 'linestyle': {'Data': 'none', 'Model': '-'}, - 'marker': {'Data': 'o', 'Model': 'none'}, - 'color': {'Data': 'black', 'Model': 'red'}, - 'markerfacecolor': {'Data': 'none', 'Model': 'none'}, + "title": self.display_name, + "linestyle": {"Data": "none", "Model": "-"}, + "marker": {"Data": "o", "Model": "none"}, + "color": {"Data": "black", "Model": "red"}, + "markerfacecolor": {"Data": "none", "Model": "none"}, } if plot_components: for comp_name in component_dataset.keys(): - plot_kwargs_defaults['linestyle'][comp_name] = '--' - plot_kwargs_defaults['marker'][comp_name] = None + plot_kwargs_defaults["linestyle"][comp_name] = "--" + plot_kwargs_defaults["marker"][comp_name] = None # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -315,7 +321,7 @@ def _require_Q_index(self) -> int: ValueError: If the Q index is not set. """ if self._Q_index is None: - raise ValueError('Q_index must be set.') + raise ValueError("Q_index must be set.") return self._Q_index def _on_Q_index_changed(self) -> None: @@ -382,7 +388,9 @@ def _evaluate_components( # performance is not important. # We don't create a convolver if the resolution is empty. - resolution = self.instrument_model.resolution_model.get_component_collection(Q_index) + resolution = self.instrument_model.resolution_model.get_component_collection( + Q_index + ) if resolution.is_empty: return components.evaluate(energy - energy_offset.value) @@ -437,8 +445,10 @@ def _evaluate_background(self) -> np.ndarray: np.ndarray: The evaluated background contribution. """ Q_index = self._require_Q_index() - background_components = self.instrument_model.background_model.get_component_collection( - Q_index=Q_index + background_components = ( + self.instrument_model.background_model.get_component_collection( + Q_index=Q_index + ) ) return self._evaluate_components( components=background_components, @@ -482,8 +492,8 @@ def _create_convolver(self) -> Convolution | None: if sample_components.is_empty: return None - resolution_components = self.instrument_model.resolution_model.get_component_collection( - Q_index + resolution_components = ( + self.instrument_model.resolution_model.get_component_collection(Q_index) ) if resolution_components.is_empty: return None @@ -571,17 +581,19 @@ def _create_components_dataset_single_Q( Q_index=self.Q_index ).components - background_components = self.instrument_model.background_model.get_component_collection( - Q_index=self.Q_index - ).components + background_components = ( + self.instrument_model.background_model.get_component_collection( + Q_index=self.Q_index + ).components + ) background = self._evaluate_background() if add_background else None for component in sample_components: scipp_arrays[component.display_name] = self._create_component_scipp_array( component=component, background=background ) for component in background_components: - scipp_arrays[component.display_name] = self._create_background_component_scipp_array( - component=component + scipp_arrays[component.display_name] = ( + self._create_background_component_scipp_array(component=component) ) return sc.Dataset(scipp_arrays) @@ -597,9 +609,9 @@ def _to_scipp_array(self, values: np.ndarray) -> sc.DataArray: """ return sc.DataArray( - data=sc.array(dims=['energy'], values=values), + data=sc.array(dims=["energy"], values=values), coords={ - 'energy': self.energy, - 'Q': self.Q[self.Q_index], + "energy": self.energy, + "Q": self.Q[self.Q_index], }, ) diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index e4c19d8d..1e6a92bb 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -import numpy as np import scipp as sc from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase from easyscience.variable import Parameter @@ -56,7 +55,7 @@ class AnalysisBase(EasyScienceModelBase): def __init__( self, - display_name: str = 'MyAnalysis', + display_name: str = "MyAnalysis", unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -98,21 +97,23 @@ def __init__( elif isinstance(experiment, Experiment): self._experiment = experiment else: - raise TypeError('experiment must be an instance of Experiment or None.') + raise TypeError("experiment must be an instance of Experiment or None.") if sample_model is None: self._sample_model = SampleModel() elif isinstance(sample_model, SampleModel): self._sample_model = sample_model else: - raise TypeError('sample_model must be an instance of SampleModel or None.') + raise TypeError("sample_model must be an instance of SampleModel or None.") if instrument_model is None: self._instrument_model = InstrumentModel() elif isinstance(instrument_model, InstrumentModel): self._instrument_model = instrument_model else: - raise TypeError('instrument_model must be an instance of InstrumentModel or None.') + raise TypeError( + "instrument_model must be an instance of InstrumentModel or None." + ) if extra_parameters is not None: if isinstance(extra_parameters, Parameter): @@ -122,7 +123,9 @@ def __init__( ): self._extra_parameters = extra_parameters else: - raise TypeError('extra_parameters must be a Parameter or a list of Parameters.') + raise TypeError( + "extra_parameters must be a Parameter or a list of Parameters." + ) else: self._extra_parameters = [] @@ -151,7 +154,7 @@ def experiment(self, value: Experiment) -> None: """ if not isinstance(value, Experiment): - raise TypeError('experiment must be an instance of Experiment') + raise TypeError("experiment must be an instance of Experiment") self._experiment = value self._on_experiment_changed() @@ -173,7 +176,7 @@ def sample_model(self, value: SampleModel) -> None: TypeError: if value is not a SampleModel. """ if not isinstance(value, SampleModel): - raise TypeError('sample_model must be an instance of SampleModel') + raise TypeError("sample_model must be an instance of SampleModel") self._sample_model = value self._on_sample_model_changed() @@ -195,7 +198,7 @@ def instrument_model(self, value: InstrumentModel) -> None: TypeError: if value is not an InstrumentModel. """ if not isinstance(value, InstrumentModel): - raise TypeError('instrument_model must be an instance of InstrumentModel') + raise TypeError("instrument_model must be an instance of InstrumentModel") self._instrument_model = value self._on_instrument_model_changed() @@ -219,7 +222,7 @@ def Q(self, value) -> None: Raises: AttributeError: If trying to set Q. """ - raise AttributeError('Q is a read-only property derived from the Experiment.') + raise AttributeError("Q is a read-only property derived from the Experiment.") @property def energy(self) -> sc.Variable | None: @@ -243,7 +246,9 @@ def energy(self, value) -> None: AttributeError: If trying to set energy. """ - raise AttributeError('energy is a read-only property derived from the Experiment.') + raise AttributeError( + "energy is a read-only property derived from the Experiment." + ) @property def temperature(self) -> Parameter | None: @@ -267,7 +272,9 @@ def temperature(self, value) -> None: AttributeError: If trying to set temperature. """ - raise AttributeError('temperature is a read-only property derived from the SampleModel.') + raise AttributeError( + "temperature is a read-only property derived from the SampleModel." + ) @property def extra_parameters(self) -> list[Parameter]: @@ -298,7 +305,9 @@ def extra_parameters(self, value: Parameter | list[Parameter]) -> None: elif value is None: self._extra_parameters = [] else: - raise TypeError('extra_parameters must be a Parameter, a list of Parameters, or None.') + raise TypeError( + "extra_parameters must be a Parameter, a list of Parameters, or None." + ) ############# # Other methods @@ -345,32 +354,9 @@ def _verify_Q_index(self, Q_index: int | None) -> int | None: or Q_index < 0 or (self.Q is not None and Q_index >= len(self.Q)) ): - raise IndexError('Q_index must be a valid index for the Q values.') + raise IndexError("Q_index must be a valid index for the Q values.") return Q_index - def _extract_x_y_weights_from_experiment( - self, Q_index: int - ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Extract the x, y, and weights arrays from the experiment for - the given Q index. - - Args: - Q_index (int): The Q index to extract the data for. - - Returns: - tuple[np.ndarray, np.ndarray, np.ndarray]: The x, y, and - weights arrays extracted from the experiment for the - given Q index. - """ - data = self.experiment.data['Q', Q_index] - x = data.coords['energy'].values - y = data.values - e = data.variances**0.5 - if np.any(e == 0): - raise ValueError('Cannot compute weights: some variances are zero.') - weights = 1.0 / e - return x, y, weights - ############# # Dunder methods ############# @@ -381,5 +367,5 @@ def __repr__(self) -> str: Returns: str: A string representation of the Analysis. """ - return f' {self.__class__.__name__} (display_name={self.display_name}, \ - unique_name={self.unique_name})' + return f" {self.__class__.__name__} (display_name={self.display_name}, \ + unique_name={self.unique_name})" diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index 5bd6728c..29d1084a 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -3,6 +3,7 @@ import os +import numpy as np import plopp as pp import scipp as sc from easyscience.base_classes.new_base import NewBase @@ -37,7 +38,7 @@ class Experiment(NewBase): def __init__( self, - display_name: str = 'MyExperiment', + display_name: str = "MyExperiment", unique_name: str | None = None, data: sc.DataArray | str | None = None, ): @@ -70,7 +71,7 @@ def __init__( self._data = data else: raise TypeError( - f'Data must be a sc.DataArray or a filename string, not {type(data).__name__}' + f"Data must be a sc.DataArray or a filename string, not {type(data).__name__}" ) self._binned_data = ( @@ -104,7 +105,7 @@ def data(self, value: sc.DataArray) -> None: ValueError: If the dataset is missing required coordinates. """ if not isinstance(value, sc.DataArray): - raise TypeError(f'Data must be a sc.DataArray, not {type(value).__name__}') + raise TypeError(f"Data must be a sc.DataArray, not {type(value).__name__}") self._validate_coordinates(value) self._data = value self._binned_data = ( @@ -133,7 +134,9 @@ def binned_data(self, value: sc.DataArray) -> None: Raises: AttributeError: Always, since binned_data is read-only. """ - raise AttributeError('binned_data is a read-only property. Use rebin() to rebin the data') + raise AttributeError( + "binned_data is a read-only property. Use rebin() to rebin the data" + ) @property def Q(self) -> sc.Variable | None: @@ -145,7 +148,7 @@ def Q(self) -> sc.Variable | None: """ if self._data is None: return None - return self._binned_data.coords['Q'] + return self._binned_data.coords["Q"] @Q.setter def Q(self, value: sc.Variable) -> None: @@ -158,7 +161,7 @@ def Q(self, value: sc.Variable) -> None: Raises: AttributeError: Always, since Q is read-only. """ - raise AttributeError('Q is a read-only property derived from the data.') + raise AttributeError("Q is a read-only property derived from the data.") @property def energy(self) -> sc.Variable | None: @@ -170,7 +173,7 @@ def energy(self) -> sc.Variable | None: """ if self._data is None: return None - return self._binned_data.coords['energy'] + return self._binned_data.coords["energy"] @energy.setter def energy(self, value: sc.Variable) -> None: @@ -183,7 +186,7 @@ def energy(self, value: sc.Variable) -> None: Raises: AttributeError: Always, since energy is read-only. """ - raise AttributeError('energy is a read-only property derived from the data.') + raise AttributeError("energy is a read-only property derived from the data.") ########### # Handle data @@ -204,19 +207,19 @@ def load_hdf5(self, filename: str, display_name: str | None = None): coordinates. """ if not isinstance(filename, str): - raise TypeError(f'Filename must be a string, not {type(filename).__name__}') + raise TypeError(f"Filename must be a string, not {type(filename).__name__}") if display_name is not None: if not isinstance(display_name, str): raise TypeError( - f'Display name must be a string, not {type(display_name).__name__}' + f"Display name must be a string, not {type(display_name).__name__}" ) self.display_name = display_name 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__}' + f"Loaded data must be a sc.DataArray, not {type(loaded_data).__name__}" ) self._validate_coordinates(loaded_data) self.data = loaded_data @@ -235,13 +238,13 @@ def save_hdf5(self, filename: str | None = None): """ if filename is None: - filename = f'{self.unique_name}.h5' + filename = f"{self.unique_name}.h5" if not isinstance(filename, str): - raise TypeError(f'Filename must be a string, not {type(filename).__name__}') + raise TypeError(f"Filename must be a string, not {type(filename).__name__}") if self._data is None: - raise ValueError('No data to save.') + raise ValueError("No data to save.") dir_name = os.path.dirname(filename) if dir_name: @@ -270,31 +273,33 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: if not isinstance(dimensions, dict): raise TypeError( - 'dimensions must be a dictionary mapping dimension names ' - 'to number of bins or bin values as sc.Variable.' + "dimensions must be a dictionary mapping dimension names " + "to number of bins or bin values as sc.Variable." ) if self._data is None: - raise ValueError('No data to rebin. Please load data first.') + raise ValueError("No data to rebin. Please load data first.") binned_data = self._data.copy() dim_copy = dimensions.copy() for dim, value in dim_copy.items(): if not isinstance(dim, str): raise TypeError( - f'Dimension keys must be strings. Got {type(dim)} for {dim} instead.' + f"Dimension keys must be strings. Got {type(dim)} for {dim} instead." ) if dim not in self._data.dims: raise KeyError( f"Dimension '{dim}' not a valid dimension for rebinning. " - f'Should be one of {self._data.dims}.' + f"Should be one of {self._data.dims}." ) - if isinstance(value, float) and value.is_integer(): # I allow eg. 2.0 as well as 2 + if ( + isinstance(value, float) and value.is_integer() + ): # I allow eg. 2.0 as well as 2 value = int(value) # This line can be removed when scipp resize support # resizing with coordinates dimensions[dim] = value if not (isinstance(value, int) or isinstance(value, sc.Variable)): raise TypeError( - f'Dimension values must be integers or sc.Variable. ' + f"Dimension values must be integers or sc.Variable. " f"Got {type(value)} for dimension '{dim}' instead." ) binned_data = binned_data.bin({dim: value}) @@ -320,13 +325,15 @@ def plot_data(self, slicer=False, **kwargs) -> None: """ if self._binned_data is None: - raise ValueError('No data to plot. Please load data first.') + raise ValueError("No data to plot. Please load data first.") if not _in_notebook(): - raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.') + raise RuntimeError( + "plot_data() can only be used in a Jupyter notebook environment." + ) plot_kwargs_defaults = { - 'title': self.display_name, + "title": self.display_name, } # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -337,7 +344,7 @@ def plot_data(self, slicer=False, **kwargs) -> None: ) else: fig = pp.plot( - self._binned_data.transpose(dims=['energy', 'Q']), + self._binned_data.transpose(dims=["energy", "Q"]), **plot_kwargs_defaults, ) return fig @@ -357,9 +364,9 @@ def _validate_coordinates(data: sc.DataArray) -> None: ValueError: If required coordinates are missing. """ if not isinstance(data, sc.DataArray): - raise TypeError('Data must be a sc.DataArray.') + raise TypeError("Data must be a sc.DataArray.") - required_coords = ['Q', 'energy'] + required_coords = ["Q", "energy"] for coord in required_coords: if coord not in data.coords: raise ValueError(f"Data is missing required coordinate: '{coord}'") @@ -380,6 +387,29 @@ def _convert_to_bin_centers(self, data: sc.DataArray) -> sc.DataArray: data = data.assign_coords({dim: sc.midpoints(coord)}) return data + def _extract_x_y_weights( + self, Q_index: int + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Extract the x, y, and weights arrays from the experiment for + the given Q index. + + Args: + Q_index (int): The Q index to extract the data for. + + Returns: + tuple[np.ndarray, np.ndarray, np.ndarray]: The x, y, and + weights arrays extracted from the experiment for the + given Q index. + """ + data = self.data["Q", Q_index] + x = data.coords["energy"].values + y = data.values + e = data.variances**0.5 + if np.any(e == 0): + raise ValueError("Cannot compute weights: some variances are zero.") + weights = 1.0 / e + return x, y, weights + ######## # dunder methods ########### @@ -391,15 +421,15 @@ def __repr__(self) -> str: str: A string representation of the Experiment object. """ - return f'Experiment `{self.unique_name}` with data: {self._data}' + return f"Experiment `{self.unique_name}` with data: {self._data}" - def __copy__(self) -> 'Experiment': + def __copy__(self) -> "Experiment": """Return a copy of the object. Returns: Experiment: A copy of the Experiment object. """ - temp = self.to_dict(skip=['unique_name']) + temp = self.to_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 diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 5a48a857..404a5c1f 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -22,22 +22,22 @@ class TestAnalysis1d: @pytest.fixture def analysis1d(self): - Q = sc.array(dims=['Q'], values=[1, 2, 3], unit='1/Angstrom') - energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') + Q = sc.array(dims=["Q"], values=[1, 2, 3], unit="1/Angstrom") + energy = sc.array(dims=["energy"], values=[10.0, 20.0, 30.0], unit="meV") data = sc.array( - dims=['Q', 'energy'], + dims=["Q", "energy"], values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], ) - data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) + data_array = sc.DataArray(data=data, coords={"Q": Q, "energy": energy}) experiment = Experiment(data=data_array) sample_model = SampleModel(components=Gaussian()) instrument_model = InstrumentModel() analysis1d = Analysis1d( - display_name='TestAnalysis', + display_name="TestAnalysis", experiment=experiment, sample_model=sample_model, instrument_model=instrument_model, @@ -51,7 +51,7 @@ def test_init(self, analysis1d): # WHEN THEN # EXPECT - assert analysis1d.display_name == 'TestAnalysis' + assert analysis1d.display_name == "TestAnalysis" assert isinstance(analysis1d._experiment, Experiment) assert isinstance(analysis1d._sample_model, SampleModel) assert isinstance(analysis1d._instrument_model, InstrumentModel) @@ -61,7 +61,7 @@ def test_init(self, analysis1d): def test_init_no_experiment(self): # WHEN - analysis1d = Analysis1d(display_name='TestAnalysisNoExperiment') + analysis1d = Analysis1d(display_name="TestAnalysisNoExperiment") # THEN EXPECT assert isinstance(analysis1d._experiment, Experiment) @@ -75,20 +75,20 @@ def test_Q_index_setter(self, analysis1d): assert analysis1d.Q_index == 1 @pytest.mark.parametrize( - 'invalid_Q_index, expected_exception, expected_message', + "invalid_Q_index, expected_exception, expected_message", [ - (-1, IndexError, 'Q_index must be'), - (10, IndexError, 'Q_index must be'), - ('invalid', IndexError, 'Q_index must be '), - (np.nan, IndexError, 'Q_index must be '), - ([1, 2], IndexError, 'Q_index must be '), + (-1, IndexError, "Q_index must be"), + (10, IndexError, "Q_index must be"), + ("invalid", IndexError, "Q_index must be "), + (np.nan, IndexError, "Q_index must be "), + ([1, 2], IndexError, "Q_index must be "), ], ids=[ - 'Negative index', - 'Index out of range', - 'Non-integer string', - 'NaN value', - 'List instead of integer', + "Negative index", + "Index out of range", + "Non-integer string", + "NaN value", + "List instead of integer", ], ) def test_Q_index_setter_incorrect_Q( @@ -138,7 +138,7 @@ def test_fit_raises_if_no_experiment(self, analysis1d): analysis1d._experiment = None # EXPECT - with pytest.raises(ValueError, match='No experiment'): + with pytest.raises(ValueError, match="No experiment"): analysis1d.fit() def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): @@ -151,21 +151,21 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): fake_y = np.array([10, 20, 30]) fake_weights = np.array([0.1, 0.2, 0.3]) - analysis1d._extract_x_y_weights_from_experiment = MagicMock( + analysis1d.experiment._extract_x_y_weights = MagicMock( return_value=(fake_x, fake_y, fake_weights) ) - analysis1d._create_convolver = MagicMock(return_value='fake_convolver') + analysis1d._create_convolver = MagicMock(return_value="fake_convolver") fake_fit_result = object() fake_fitter_instance = MagicMock() fake_fitter_instance.fit.return_value = fake_fit_result with patch( - 'easydynamics.analysis.analysis1d.EasyScienceFitter', + "easydynamics.analysis.analysis1d.EasyScienceFitter", return_value=fake_fitter_instance, ) as mock_fitter: - analysis1d.as_fit_function = MagicMock(return_value='fit_func') + analysis1d.as_fit_function = MagicMock(return_value="fit_func") # THEN result = analysis1d.fit() @@ -178,10 +178,10 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): mock_fitter.assert_called_once_with( fit_object=analysis1d, - fit_function='fit_func', + fit_function="fit_func", ) - analysis1d._extract_x_y_weights_from_experiment.assert_called_once() + analysis1d.experiment._extract_x_y_weights.assert_called_once() fake_fitter_instance.fit.assert_called_once_with( x=fake_x, @@ -215,8 +215,8 @@ def test_as_fit_function_calls_calculate(self, analysis1d): def test_get_all_variables(self, analysis1d): # WHEN - extra_par1 = Parameter(name='extra_par1', value=1.0) - extra_par2 = Parameter(name='extra_par2', value=2.0) + extra_par1 = Parameter(name="extra_par1", value=1.0) + extra_par2 = Parameter(name="extra_par2", value=2.0) analysis1d._extra_parameters = [extra_par1, extra_par2] # THEN @@ -224,8 +224,12 @@ def test_get_all_variables(self, analysis1d): # EXPECT assert isinstance(variables, list) - sample_vars = analysis1d.sample_model.get_all_variables(Q_index=analysis1d.Q_index) - instrument_vars = analysis1d.instrument_model.get_all_variables(Q_index=analysis1d.Q_index) + sample_vars = analysis1d.sample_model.get_all_variables( + Q_index=analysis1d.Q_index + ) + instrument_vars = analysis1d.instrument_model.get_all_variables( + Q_index=analysis1d.Q_index + ) extra_vars = [extra_par1, extra_par2] expected_vars = sample_vars + instrument_vars + extra_vars assert Counter(variables) == Counter(expected_vars) @@ -233,24 +237,30 @@ def test_get_all_variables(self, analysis1d): def test_plot_raises_if_no_data(self, analysis1d): analysis1d.experiment._data = None - with pytest.raises(ValueError, match='No data'): + with pytest.raises(ValueError, match="No data"): analysis1d.plot_data_and_model() def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): # WHEN # Mock the data and model components to be plotted - fake_model = sc.DataArray(data=sc.array(dims=['energy'], values=[1, 2, 3])) + fake_model = sc.DataArray(data=sc.array(dims=["energy"], values=[1, 2, 3])) analysis1d._create_sample_scipp_array = MagicMock(return_value=fake_model) - fake_components = sc.Dataset({ - 'Component1': sc.DataArray(data=sc.array(dims=['energy'], values=[0.1, 0.2, 0.3])) - }) - analysis1d._create_components_dataset_single_Q = MagicMock(return_value=fake_components) + fake_components = sc.Dataset( + { + "Component1": sc.DataArray( + data=sc.array(dims=["energy"], values=[0.1, 0.2, 0.3]) + ) + } + ) + analysis1d._create_components_dataset_single_Q = MagicMock( + return_value=fake_components + ) fake_fig = object() - with patch('plopp.plot', return_value=fake_fig) as mock_plot: + with patch("plopp.plot", return_value=fake_fig) as mock_plot: # THEN result = analysis1d.plot_data_and_model() @@ -267,9 +277,9 @@ def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): dataset_passed = args[0] - assert 'Data' in dataset_passed - assert 'Model' in dataset_passed - assert 'Component1' in dataset_passed + assert "Data" in dataset_passed + assert "Model" in dataset_passed + assert "Component1" in dataset_passed assert result is fake_fig @@ -289,7 +299,7 @@ def test_require_Q_index_raises_if_no_Q_index(self, analysis1d): analysis1d._Q_index = None # EXPECT - with pytest.raises(ValueError, match='Q_index must be set'): + with pytest.raises(ValueError, match="Q_index must be set"): analysis1d._require_Q_index() def test_on_Q_index_changed(self, analysis1d): @@ -366,7 +376,7 @@ def test_evaluate_with_resolution(self, analysis1d): analysis1d.instrument_model.resolution_model.components = Gaussian() components = Gaussian() - with patch('easydynamics.analysis.analysis1d.Convolution') as MockConvolution: + with patch("easydynamics.analysis.analysis1d.Convolution") as MockConvolution: # THEN analysis1d._evaluate_components( components=components, @@ -385,20 +395,22 @@ def test_evaluate_with_resolution(self, analysis1d): ) ) - energy_offset = analysis1d.instrument_model.get_energy_offset_at_Q(analysis1d.Q_index) + energy_offset = analysis1d.instrument_model.get_energy_offset_at_Q( + analysis1d.Q_index + ) # Extract call arguments _, kwargs = MockConvolution.call_args - assert kwargs['sample_components'] == components - assert kwargs['resolution_components'] == resolution_components - assert kwargs['temperature'] == analysis1d.temperature - assert kwargs['energy_offset'] == energy_offset + assert kwargs["sample_components"] == components + assert kwargs["resolution_components"] == resolution_components + assert kwargs["temperature"] == analysis1d.temperature + assert kwargs["energy_offset"] == energy_offset # check that the energy array passed to the convolver is the # same as the analysis1d energy array np.testing.assert_array_equal( - kwargs['energy'], + kwargs["energy"], analysis1d.energy.values, ) @@ -449,7 +461,9 @@ def test_evaluate_sample_component(self, analysis1d): def test_evaluate_background(self, analysis1d): # WHEN - analysis1d.instrument_model.background_model.get_component_collection = MagicMock() + analysis1d.instrument_model.background_model.get_component_collection = ( + MagicMock() + ) analysis1d._evaluate_components = MagicMock() # THEN @@ -504,13 +518,15 @@ def test_create_convolver(self, analysis1d): return_value=sample_components ) - analysis1d.instrument_model.resolution_model.get_component_collection = MagicMock( - return_value=resolution_components + analysis1d.instrument_model.resolution_model.get_component_collection = ( + MagicMock(return_value=resolution_components) ) - analysis1d.instrument_model.get_energy_offset_at_Q = MagicMock(return_value=123.0) + analysis1d.instrument_model.get_energy_offset_at_Q = MagicMock( + return_value=123.0 + ) - with patch('easydynamics.analysis.analysis1d.Convolution') as MockConvolution: + with patch("easydynamics.analysis.analysis1d.Convolution") as MockConvolution: # THEN result = analysis1d._create_convolver() @@ -520,15 +536,17 @@ def test_create_convolver(self, analysis1d): _, kwargs = MockConvolution.call_args - assert kwargs['sample_components'] is sample_components - assert kwargs['resolution_components'] is resolution_components - assert sc.identical(kwargs['energy'], analysis1d.energy) - assert kwargs['temperature'] is analysis1d.temperature - assert kwargs['energy_offset'] == 123.0 + assert kwargs["sample_components"] is sample_components + assert kwargs["resolution_components"] is resolution_components + assert sc.identical(kwargs["energy"], analysis1d.energy) + assert kwargs["temperature"] is analysis1d.temperature + assert kwargs["energy_offset"] == 123.0 assert result == MockConvolution.return_value - def test_create_convolver_returns_none_if_no_resolution_components(self, analysis1d): + def test_create_convolver_returns_none_if_no_resolution_components( + self, analysis1d + ): # WHEN analysis1d.instrument_model.resolution_model.clear_components() @@ -553,14 +571,14 @@ def test_create_convolver_returns_none_if_no_sample_components(self, analysis1d) ############# @pytest.mark.parametrize( - 'background', + "background", [ None, np.array([0.5, 0.5, 0.5]), ], ids=[ - 'No background', - 'With background', + "No background", + "With background", ], ) def test_create_component_scipp_array(self, analysis1d, background): @@ -572,17 +590,23 @@ def test_create_component_scipp_array(self, analysis1d, background): # WHEN # Mock the functions that will be called. - analysis1d._evaluate_sample_component = MagicMock(return_value=np.array([1.0, 2.0, 3.0])) + analysis1d._evaluate_sample_component = MagicMock( + return_value=np.array([1.0, 2.0, 3.0]) + ) analysis1d._to_scipp_array = MagicMock() component = object() # THEN - analysis1d._create_component_scipp_array(component=component, background=background) + analysis1d._create_component_scipp_array( + component=component, background=background + ) # EXPECT - analysis1d._evaluate_sample_component.assert_called_once_with(component=component) + analysis1d._evaluate_sample_component.assert_called_once_with( + component=component + ) expected_values = np.array([1.0, 2.0, 3.0]) if background is not None: @@ -594,7 +618,7 @@ def test_create_component_scipp_array(self, analysis1d, background): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs['values'], + kwargs["values"], expected_values, ) @@ -617,7 +641,9 @@ def test_create_background_component_scipp_array(self, analysis1d): analysis1d._create_background_component_scipp_array(component=component) # EXPECT - analysis1d._evaluate_background_component.assert_called_once_with(component=component) + analysis1d._evaluate_background_component.assert_called_once_with( + component=component + ) analysis1d._to_scipp_array.assert_called_once() @@ -625,7 +651,7 @@ def test_create_background_component_scipp_array(self, analysis1d): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs['values'], + kwargs["values"], np.array([1.0, 2.0, 3.0]), ) @@ -652,14 +678,14 @@ def test_create_sample_scipp_array(self, analysis1d): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs['values'], + kwargs["values"], np.array([1.0, 2.0, 3.0]), ) @pytest.mark.parametrize( - 'add_background', + "add_background", [True, False], - ids=['With background', 'Without background'], + ids=["With background", "Without background"], ) def test_create_components_dataset_single_Q( self, @@ -678,7 +704,7 @@ def test_create_components_dataset_single_Q( # ---- Sample component ---- sample_component = MagicMock() - sample_component.display_name = 'sample_comp' + sample_component.display_name = "sample_comp" sample_collection = MagicMock() sample_collection.components = [sample_component] @@ -689,13 +715,13 @@ def test_create_components_dataset_single_Q( # ---- Background component ---- background_component = MagicMock() - background_component.display_name = 'background_comp' + background_component.display_name = "background_comp" background_collection = MagicMock() background_collection.components = [background_component] - analysis1d.instrument_model.background_model.get_component_collection = MagicMock( - return_value=background_collection + analysis1d.instrument_model.background_model.get_component_collection = ( + MagicMock(return_value=background_collection) ) # ---- Background evaluation ---- @@ -703,18 +729,26 @@ def test_create_components_dataset_single_Q( analysis1d._evaluate_background = MagicMock(return_value=background_value) # ---- Return scipp DataArrays ---- - fake_sample_da = sc.DataArray(data=sc.array(dims=['energy'], values=[1.0, 2.0, 3.0])) + fake_sample_da = sc.DataArray( + data=sc.array(dims=["energy"], values=[1.0, 2.0, 3.0]) + ) - analysis1d._create_component_scipp_array = MagicMock(return_value=fake_sample_da) + analysis1d._create_component_scipp_array = MagicMock( + return_value=fake_sample_da + ) - fake_background_da = sc.DataArray(data=sc.array(dims=['energy'], values=[4.0, 5.0, 6.0])) + fake_background_da = sc.DataArray( + data=sc.array(dims=["energy"], values=[4.0, 5.0, 6.0]) + ) analysis1d._create_background_component_scipp_array = MagicMock( return_value=fake_background_da ) # THEN - dataset = analysis1d._create_components_dataset_single_Q(add_background=add_background) + dataset = analysis1d._create_components_dataset_single_Q( + add_background=add_background + ) # EXPECT @@ -742,13 +776,13 @@ def test_create_components_dataset_single_Q( analysis1d._create_component_scipp_array.assert_called_once() _, kwargs = analysis1d._create_component_scipp_array.call_args - assert kwargs['component'] is sample_component + assert kwargs["component"] is sample_component if expected_background is None: - assert kwargs['background'] is None + assert kwargs["background"] is None else: np.testing.assert_array_equal( - kwargs['background'], + kwargs["background"], expected_background, ) @@ -759,8 +793,8 @@ def test_create_components_dataset_single_Q( # Dataset content assert isinstance(dataset, sc.Dataset) - assert 'sample_comp' in dataset - assert 'background_comp' in dataset + assert "sample_comp" in dataset + assert "background_comp" in dataset def test_to_scipp_array(self, analysis1d): # WHEN @@ -774,10 +808,10 @@ def test_to_scipp_array(self, analysis1d): np.testing.assert_array_equal(scipp_array.values, numpy_array) np.testing.assert_array_equal( - scipp_array.coords['energy'].values, analysis1d.experiment.energy.values + scipp_array.coords["energy"].values, analysis1d.experiment.energy.values ) np.testing.assert_array_equal( - scipp_array.coords['Q'].values, + scipp_array.coords["Q"].values, analysis1d.experiment.Q[analysis1d.Q_index].values, ) diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py index e91e7612..13b667cf 100644 --- a/tests/unit/easydynamics/analysis/test_analysis_base.py +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -6,7 +6,6 @@ import numpy as np import pytest -import scipp as sc from easyscience.variable import Parameter from easydynamics.analysis.analysis_base import AnalysisBase @@ -22,7 +21,7 @@ def analysis_base(self): sample_model = SampleModel() instrument_model = InstrumentModel() analysis_base = AnalysisBase( - display_name='TestAnalysis', + display_name="TestAnalysis", experiment=experiment, sample_model=sample_model, instrument_model=instrument_model, @@ -33,65 +32,67 @@ def test_init(self, analysis_base): # WHEN THEN # EXPECT - assert analysis_base.display_name == 'TestAnalysis' + assert analysis_base.display_name == "TestAnalysis" assert isinstance(analysis_base._experiment, Experiment) assert isinstance(analysis_base._sample_model, SampleModel) assert isinstance(analysis_base._instrument_model, InstrumentModel) assert analysis_base._extra_parameters == [] def test_init_extra_parameter(self): - extra_parameter = Parameter(name='param1', value=1.0) + extra_parameter = Parameter(name="param1", value=1.0) analysis = AnalysisBase(extra_parameters=extra_parameter) assert analysis._extra_parameters == [extra_parameter] def test_init_extra_parameters(self): extra_parameters = [ - Parameter(name='param1', value=1.0), - Parameter(name='param2', value=2.0), + Parameter(name="param1", value=1.0), + Parameter(name="param2", value=2.0), ] analysis = AnalysisBase(extra_parameters=extra_parameters) assert analysis._extra_parameters == extra_parameters def test_init_calls_on_experiment_changed(self): - with patch.object(AnalysisBase, '_on_experiment_changed') as mock_on_experiment_changed: + with patch.object( + AnalysisBase, "_on_experiment_changed" + ) as mock_on_experiment_changed: AnalysisBase() mock_on_experiment_changed.assert_called_once() @pytest.mark.parametrize( - 'kwargs, expected_exception, expected_message', + "kwargs, expected_exception, expected_message", [ ( - {'experiment': 123}, + {"experiment": 123}, TypeError, - 'experiment must be an instance of Experiment', + "experiment must be an instance of Experiment", ), ( - {'sample_model': 'not a model'}, + {"sample_model": "not a model"}, TypeError, - 'sample_model must be an instance of SampleModel', + "sample_model must be an instance of SampleModel", ), ( - {'instrument_model': 'not a model'}, + {"instrument_model": "not a model"}, TypeError, - 'instrument_model must be an instance of InstrumentModel', + "instrument_model must be an instance of InstrumentModel", ), ( - {'extra_parameters': 123}, + {"extra_parameters": 123}, TypeError, - 'extra_parameters must be a Parameter or a list of Parameters.', + "extra_parameters must be a Parameter or a list of Parameters.", ), ( - {'extra_parameters': [123]}, + {"extra_parameters": [123]}, TypeError, - 'extra_parameters must be a Parameter or a list of Parameters.', + "extra_parameters must be a Parameter or a list of Parameters.", ), ], ids=[ - 'invalid experiment', - 'invalid sample_model', - 'invalid instrument_model', - 'invalid extra_parameters', - 'invalid extra_parameters list', + "invalid experiment", + "invalid sample_model", + "invalid instrument_model", + "invalid extra_parameters", + "invalid extra_parameters list", ], ) def test_init_invalid_inputs(self, kwargs, expected_exception, expected_message): @@ -99,14 +100,18 @@ def test_init_invalid_inputs(self, kwargs, expected_exception, expected_message) AnalysisBase(**kwargs) def test_experiment_setter_calls_on_experiment_changed(self, analysis_base): - with patch.object(analysis_base, '_on_experiment_changed') as mock_on_experiment_changed: + with patch.object( + analysis_base, "_on_experiment_changed" + ) as mock_on_experiment_changed: new_experiment = Experiment() analysis_base.experiment = new_experiment mock_on_experiment_changed.assert_called_once() def test_experiment_setter_invalid_type(self, analysis_base): - with pytest.raises(TypeError, match='experiment must be an instance of Experiment'): - analysis_base.experiment = 'not an experiment' + with pytest.raises( + TypeError, match="experiment must be an instance of Experiment" + ): + analysis_base.experiment = "not an experiment" def test_experiment_setter_valid(self, analysis_base): new_experiment = Experiment() @@ -114,8 +119,10 @@ def test_experiment_setter_valid(self, analysis_base): assert analysis_base.experiment == new_experiment def test_sample_model_setter_invalid_type(self, analysis_base): - with pytest.raises(TypeError, match='sample_model must be an instance of SampleModel'): - analysis_base.sample_model = 'not a sample model' + with pytest.raises( + TypeError, match="sample_model must be an instance of SampleModel" + ): + analysis_base.sample_model = "not a sample model" def test_sample_model_setter_valid(self, analysis_base): new_sample_model = SampleModel() @@ -124,7 +131,7 @@ def test_sample_model_setter_valid(self, analysis_base): def test_sample_model_setter_calls_on_sample_model_changed(self, analysis_base): with patch.object( - analysis_base, '_on_sample_model_changed' + analysis_base, "_on_sample_model_changed" ) as mock_on_sample_model_changed: new_sample_model = SampleModel() analysis_base.sample_model = new_sample_model @@ -132,18 +139,20 @@ def test_sample_model_setter_calls_on_sample_model_changed(self, analysis_base): def test_instrument_model_setter_invalid_type(self, analysis_base): with pytest.raises( - TypeError, match='instrument_model must be an instance of InstrumentModel' + TypeError, match="instrument_model must be an instance of InstrumentModel" ): - analysis_base.instrument_model = 'not an instrument model' + analysis_base.instrument_model = "not an instrument model" def test_instrument_model_setter_valid(self, analysis_base): new_instrument_model = InstrumentModel() analysis_base.instrument_model = new_instrument_model assert analysis_base.instrument_model == new_instrument_model - def test_instrument_model_setter_calls_on_instrument_model_changed(self, analysis_base): + def test_instrument_model_setter_calls_on_instrument_model_changed( + self, analysis_base + ): with patch.object( - analysis_base, '_on_instrument_model_changed' + analysis_base, "_on_instrument_model_changed" ) as mock_on_instrument_model_changed: new_instrument_model = InstrumentModel() analysis_base.instrument_model = new_instrument_model @@ -155,7 +164,7 @@ def test_Q_property(self, analysis_base): # Patch the 'experiment' attribute's Q property with patch.object( - type(analysis_base.experiment), 'Q', new_callable=PropertyMock + type(analysis_base.experiment), "Q", new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q result = analysis_base.Q # Access the property @@ -165,7 +174,7 @@ def test_Q_property(self, analysis_base): def test_Q_setter_raises(self, analysis_base): with pytest.raises( AttributeError, - match='Q is a read-only property derived from the Experiment.', + match="Q is a read-only property derived from the Experiment.", ): analysis_base.Q = [1, 2, 3] @@ -175,7 +184,7 @@ def test_energy_property(self, analysis_base): # Patch the 'experiment' attribute's energy property with patch.object( - type(analysis_base.experiment), 'energy', new_callable=PropertyMock + type(analysis_base.experiment), "energy", new_callable=PropertyMock ) as mock_energy: mock_energy.return_value = fake_energy result = analysis_base.energy # Access the property @@ -185,7 +194,7 @@ def test_energy_property(self, analysis_base): def test_energy_setter_raises(self, analysis_base): with pytest.raises( AttributeError, - match='energy is a read-only property derived from the Experiment.', + match="energy is a read-only property derived from the Experiment.", ): analysis_base.energy = [10, 20, 30] @@ -193,7 +202,7 @@ def test_temperature_property_no_temperature(self, analysis_base): # Patch the 'experiment' attribute's temperature property to # return None with patch.object( - type(analysis_base.sample_model), 'temperature', new_callable=PropertyMock + type(analysis_base.sample_model), "temperature", new_callable=PropertyMock ) as mock_temperature: mock_temperature.return_value = None result = analysis_base.temperature # Access the property @@ -206,7 +215,7 @@ def test_temperature_property(self, analysis_base): # Patch the 'sample_model' attribute's temperature property with patch.object( - type(analysis_base.sample_model), 'temperature', new_callable=PropertyMock + type(analysis_base.sample_model), "temperature", new_callable=PropertyMock ) as mock_temperature: mock_temperature.return_value = fake_temperature result = analysis_base.temperature # Access the property @@ -216,22 +225,22 @@ def test_temperature_property(self, analysis_base): def test_temperature_setter_raises(self, analysis_base): with pytest.raises( AttributeError, - match='temperature is a read-only property', + match="temperature is a read-only property", ): analysis_base.temperature = 300 @pytest.mark.parametrize( - 'extra_parameters', + "extra_parameters", [ - Parameter(name='param1', value=1.0), + Parameter(name="param1", value=1.0), [ - Parameter(name='param1', value=1.0), - Parameter(name='param2', value=2.0), + Parameter(name="param1", value=1.0), + Parameter(name="param2", value=2.0), ], ], ids=[ - 'single parameter', - 'list of parameters', + "single parameter", + "list of parameters", ], ) def test_extra_parameters_property(self, analysis_base, extra_parameters): @@ -243,26 +252,30 @@ def test_extra_parameters_property(self, analysis_base, extra_parameters): # EXPECT expected = ( - [extra_parameters] if isinstance(extra_parameters, Parameter) else extra_parameters + [extra_parameters] + if isinstance(extra_parameters, Parameter) + else extra_parameters ) assert analysis_base.extra_parameters == expected @pytest.mark.parametrize( - 'invalid_extra_parameters', + "invalid_extra_parameters", [ - 'not a parameter', - [Parameter(name='param1', value=1.0), 'not a parameter'], + "not a parameter", + [Parameter(name="param1", value=1.0), "not a parameter"], ], ids=[ - 'single invalid parameter', - 'list with invalid parameter', + "single invalid parameter", + "list with invalid parameter", ], ) - def test_extra_parameters_setter_invalid_type(self, analysis_base, invalid_extra_parameters): + def test_extra_parameters_setter_invalid_type( + self, analysis_base, invalid_extra_parameters + ): with pytest.raises( TypeError, - match='extra_parameters must be', + match="extra_parameters must be", ): analysis_base.extra_parameters = invalid_extra_parameters @@ -272,7 +285,7 @@ def test_on_experiment_changed_updates_Q(self, analysis_base): # Patch the Q property of analysis_base with patch.object( - type(analysis_base.experiment), 'Q', new_callable=PropertyMock + type(analysis_base.experiment), "Q", new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q @@ -291,7 +304,7 @@ def test_on_sample_model_changed_updates_Q(self, analysis_base): # Patch the Q property of analysis_base with patch.object( - type(analysis_base.experiment), 'Q', new_callable=PropertyMock + type(analysis_base.experiment), "Q", new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q @@ -306,7 +319,7 @@ def test_on_instrument_model_changed_updates_Q(self, analysis_base): # Patch the Q property of analysis_base with patch.object( - type(analysis_base.experiment), 'Q', new_callable=PropertyMock + type(analysis_base.experiment), "Q", new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q @@ -328,42 +341,14 @@ def test_verify_Q_index_invalid(self, analysis_base): invalid_Q_index = -1 # THEN / EXPECT - with pytest.raises(IndexError, match='Q_index must be a valid index'): + with pytest.raises(IndexError, match="Q_index must be a valid index"): analysis_base._verify_Q_index(invalid_Q_index) - def test_extract_x_y_weights_from_experiment(self, analysis_base): - # WHEN - Q = sc.array(dims=['Q'], values=[1, 2, 3], unit='1/Angstrom') - energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') - data = sc.array( - dims=['Q', 'energy'], - values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], - variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], - ) - - data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) - - experiment = Experiment(data=data_array) - analysis_base.experiment = experiment - - Q_index = 0 - - # THEN - x, y, weights = analysis_base._extract_x_y_weights_from_experiment(Q_index=Q_index) - - # EXPECT - assert np.array_equal(x, analysis_base.experiment.energy.values) - assert np.array_equal(y, analysis_base.experiment.data.values[Q_index]) - assert np.array_equal( - weights, - 1 / analysis_base.experiment.data.variances[Q_index] ** 0.5, - ) - def test_repr(self, analysis_base): # WHEN repr_str = repr(analysis_base) # THEN EXPECT - assert 'AnalysisBase' in repr_str - assert 'display_name=TestAnalysis' in repr_str - assert 'unique_name=' in repr_str + assert "AnalysisBase" in repr_str + assert "display_name=TestAnalysis" in repr_str + assert "unique_name=" in repr_str diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index 6601c16c..e2837233 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -15,12 +15,12 @@ class TestExperiment: @pytest.fixture def experiment(self): - Q = sc.linspace('Q', 0.5, 1.5, num=10, unit='1/Angstrom') - energy = sc.linspace('energy', -5, 5, num=11, unit='meV') - values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))) - data = sc.DataArray(data=values, coords={'Q': Q, 'energy': energy}) + Q = sc.linspace("Q", 0.5, 1.5, num=10, unit="1/Angstrom") + energy = sc.linspace("energy", -5, 5, num=11, unit="meV") + values = sc.array(dims=["Q", "energy"], values=np.ones((10, 11))) + data = sc.DataArray(data=values, coords={"Q": Q, "energy": energy}) - experiment = Experiment(display_name='test_experiment', data=data) + experiment = Experiment(display_name="test_experiment", data=data) return experiment ############## @@ -30,51 +30,51 @@ def experiment(self): def test_init_array(self, experiment): "Test initialization with a Scipp DataArray" # WHEN THEN EXPECT - assert experiment.display_name == 'test_experiment' + assert experiment.display_name == "test_experiment" assert isinstance(experiment._data, sc.DataArray) - assert 'Q' in experiment._data.dims - assert 'energy' in experiment._data.dims - assert experiment._data.sizes['Q'] == 10 - assert experiment._data.sizes['energy'] == 11 + assert "Q" in experiment._data.dims + assert "energy" in experiment._data.dims + assert experiment._data.sizes["Q"] == 10 + assert experiment._data.sizes["energy"] == 11 assert sc.identical( experiment._data.data, - sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))), + sc.array(dims=["Q", "energy"], values=np.ones((10, 11))), ) def test_init_string(self, tmp_path): "Test initialization with a filename string," - 'should load the file' + "should load the file" # WHEN - Q = sc.linspace('Q', 0.5, 1.5, num=10, unit='1/Angstrom') - energy = sc.linspace('energy', -5, 5, num=11, unit='meV') - values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))) - data = sc.DataArray(data=values, coords={'Q': Q, 'energy': energy}) + Q = sc.linspace("Q", 0.5, 1.5, num=10, unit="1/Angstrom") + energy = sc.linspace("energy", -5, 5, num=11, unit="meV") + values = sc.array(dims=["Q", "energy"], values=np.ones((10, 11))) + data = sc.DataArray(data=values, coords={"Q": Q, "energy": energy}) - filename = tmp_path / 'test_experiment.h5' + filename = tmp_path / "test_experiment.h5" sc.io.save_hdf5(data, filename) # THEN - experiment = Experiment(display_name='loaded_experiment', data=str(filename)) + experiment = Experiment(display_name="loaded_experiment", data=str(filename)) # EXPECT - assert experiment.display_name == 'loaded_experiment' + assert experiment.display_name == "loaded_experiment" assert isinstance(experiment._data, sc.DataArray) - assert 'Q' in experiment._data.dims - assert 'energy' in experiment._data.dims - assert experiment._data.sizes['Q'] == 10 - assert experiment._data.sizes['energy'] == 11 + assert "Q" in experiment._data.dims + assert "energy" in experiment._data.dims + assert experiment._data.sizes["Q"] == 10 + assert experiment._data.sizes["energy"] == 11 assert sc.identical( experiment._data.data, - sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))), + sc.array(dims=["Q", "energy"], values=np.ones((10, 11))), ) def test_init_no_data(self): "Test initialization with no data" # WHEN - experiment = Experiment(display_name='empty_experiment') + experiment = Experiment(display_name="empty_experiment") # THEN EXPECT - assert experiment.display_name == 'empty_experiment' + assert experiment.display_name == "empty_experiment" assert experiment._data is None assert experiment.energy is None assert experiment.Q is None @@ -91,34 +91,34 @@ def test_init_invalid_data(self): def test_load_hdf5(self, tmp_path, experiment): "Test loading data from an HDF5 file." - 'First use scipp to save data to a file, ' - 'then load it using the method.' + "First use scipp to save data to a file, " + "then load it using the method." # WHEN # First create a file to load from - filename = tmp_path / 'test.h5' + filename = tmp_path / "test.h5" data_to_save = experiment.data sc.io.save_hdf5(data_to_save, filename) # THEN - new_experiment = Experiment(display_name='new_experiment') - new_experiment.load_hdf5(str(filename), display_name='loaded_data') + new_experiment = Experiment(display_name="new_experiment") + new_experiment.load_hdf5(str(filename), display_name="loaded_data") loaded_data = new_experiment.data # EXPECT assert sc.identical(data_to_save, loaded_data) - assert new_experiment.display_name == 'loaded_data' + assert new_experiment.display_name == "loaded_data" def test_load_hdf5_invalid_name_raises(self, experiment): "Test loading data from an HDF5 file," - 'giving the Experiment an invalid name' + "giving the Experiment an invalid name" # WHEN / THEN EXPECT with pytest.raises(TypeError): - experiment.load_hdf5('some_file.h5', display_name=123) + experiment.load_hdf5("some_file.h5", display_name=123) def test_load_hdf5_invalid_filename_raises(self, experiment): "Test loading data from an HDF5 file with an invalid filename" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match='must be a string'): + with pytest.raises(TypeError, match="must be a string"): experiment.load_hdf5(123) def test_load_hdf5_invalid_file_raises(self, experiment): @@ -126,13 +126,13 @@ def test_load_hdf5_invalid_file_raises(self, experiment): # WHEN / THEN EXPECT with pytest.raises(OSError): - experiment.load_hdf5('non_existent_file.h5') + experiment.load_hdf5("non_existent_file.h5") def test_save_hdf5(self, tmp_path, experiment): "Test saving data to an HDF5 file. Load the saved file" - 'using scipp and compare to the original data.' + "using scipp and compare to the original data." # WHEN THEN - filename = tmp_path / 'saved_data.h5' + filename = tmp_path / "saved_data.h5" experiment.save_hdf5(str(filename)) # EXPECT @@ -149,25 +149,25 @@ def test_save_hdf5_default_filename(self, tmp_path, experiment, monkeypatch): experiment.save_hdf5() # EXPECT - expected_filename = tmp_path / f'{experiment.unique_name}.h5' + expected_filename = tmp_path / f"{experiment.unique_name}.h5" loaded_data = sc.io.load_hdf5(str(expected_filename)) original_data = experiment.data assert sc.identical(original_data, loaded_data) def test_save_hdf5_no_data_raises(self): "Test saving data to an HDF5 file when no data is present" - 'in the experiment' + "in the experiment" # WHEN experiment = Experiment() # THEN EXPECT with pytest.raises(ValueError): - experiment.save_hdf5('should_fail.h5') + experiment.save_hdf5("should_fail.h5") def test_save_hdf5_invalid_filename_raises(self, experiment): "Test saving data to an HDF5 file with an invalid filename" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match='must be a string'): + with pytest.raises(TypeError, match="must be a string"): experiment.save_hdf5(123) def test_remove_data(self, experiment): @@ -179,11 +179,11 @@ def test_remove_data(self, experiment): assert experiment._data is None @pytest.mark.parametrize( - 'new_Q_bins, new_energy_bins', + "new_Q_bins, new_energy_bins", [ ( - sc.linspace('Q', 0.5, 1.5, num=7, unit='1/Angstrom'), - sc.linspace('energy', -5, 5, num=8, unit='meV'), + sc.linspace("Q", 0.5, 1.5, num=7, unit="1/Angstrom"), + sc.linspace("energy", -5, 5, num=8, unit="meV"), ), ( 6, @@ -194,23 +194,23 @@ def test_remove_data(self, experiment): 7.0, ), ( - sc.linspace('Q', 0.5, 1.5, num=7, unit='1/Angstrom'), + sc.linspace("Q", 0.5, 1.5, num=7, unit="1/Angstrom"), 7, ), ], - ids=['sc_bins', 'integers_bins', 'float_bins', 'mixed_bins'], + ids=["sc_bins", "integers_bins", "float_bins", "mixed_bins"], ) def test_rebin(self, experiment, new_Q_bins, new_energy_bins): "Test rebinning data in the experiment" # WHEN # THEN - experiment.rebin({'Q': new_Q_bins, 'energy': new_energy_bins}) + experiment.rebin({"Q": new_Q_bins, "energy": new_energy_bins}) # EXPECT rebinned_data = experiment.binned_data - assert rebinned_data.sizes['Q'] == 6 - assert rebinned_data.sizes['energy'] == 7 + assert rebinned_data.sizes["Q"] == 6 + assert rebinned_data.sizes["energy"] == 7 def test_rebin_no_data_raises(self): "Test rebinning data when no data is present" @@ -219,34 +219,34 @@ def test_rebin_no_data_raises(self): # THEN EXPECT with pytest.raises(ValueError): - experiment.rebin({'Q': 6, 'energy': 7}) + experiment.rebin({"Q": 6, "energy": 7}) def test_rebin_invalid_dimensions_raises(self, experiment): "Test rebinning data with invalid dimensions" # WHEN / THEN EXPECT with pytest.raises(TypeError): - experiment.rebin('invalid_dimensions') + experiment.rebin("invalid_dimensions") def test_rebin_invalid_dimension_name_raises(self, experiment): "Test rebinning data with invalid dimension name" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match='Dimension keys must be strings'): - experiment.rebin({123: 6, 'energy': 7}) + with pytest.raises(TypeError, match="Dimension keys must be strings"): + experiment.rebin({123: 6, "energy": 7}) def test_rebin_dimension_not_in_data_raises(self, experiment): "Test rebinning data with a dimension not in the data" # WHEN / THEN EXPECT with pytest.raises(KeyError, match="Dimension 'time' not a valid"): - experiment.rebin({'time': 6, 'energy': 7}) + experiment.rebin({"time": 6, "energy": 7}) def test_rebin_invalid_bin_values_raises(self, experiment): "Test rebinning data with invalid bin values" # WHEN / THEN EXPECT with pytest.raises( TypeError, - match='Dimension values must be integers or', + match="Dimension values must be integers or", ): - experiment.rebin({'Q': [0.5, 1.0, 1.5], 'energy': 7}) + experiment.rebin({"Q": [0.5, 1.0, 1.5], "energy": 7}) ############## # test setters and getters @@ -284,8 +284,8 @@ def test_plot_data_success(self, experiment): "Test plotting data successfully when in notebook environment" # WHEN with ( - patch(f'{Experiment.__module__}._in_notebook', return_value=True), - patch('plopp.plot') as mock_plot, + patch(f"{Experiment.__module__}._in_notebook", return_value=True), + patch("plopp.plot") as mock_plot, ): mock_fig = MagicMock() mock_plot.return_value = mock_fig @@ -297,7 +297,7 @@ def test_plot_data_success(self, experiment): mock_plot.assert_called_once() args, kwargs = mock_plot.call_args assert sc.identical(args[0], experiment._data.transpose()) - assert kwargs['title'] == f'{experiment.display_name}' + assert kwargs["title"] == f"{experiment.display_name}" assert result == mock_fig def test_plot_data_no_data_raises(self): @@ -306,18 +306,18 @@ def test_plot_data_no_data_raises(self): experiment = Experiment() # THEN EXPECT - with pytest.raises(ValueError, match='No data to plot'): + 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' + "when not in notebook environment" # WHEN - with patch(f'{Experiment.__module__}._in_notebook', return_value=False): + with patch(f"{Experiment.__module__}._in_notebook", return_value=False): # THEN EXPECT with pytest.raises( RuntimeError, - match='plot_data\\(\\) can only be used in a Jupyter notebook environment', + match="plot_data\\(\\) can only be used in a Jupyter notebook environment", ): experiment.plot_data() @@ -332,40 +332,42 @@ def test_validate_coordinates(self, experiment): def test_validate_coordinates_raises_missing_Q(self, experiment): "Test that _validate_coordinates raises ValueError when Q coord" - 'is missing' + "is missing" # WHEN invalid_data = experiment._data.copy() - invalid_data.coords.pop('Q') + invalid_data.coords.pop("Q") # THEN EXPECT - with pytest.raises(ValueError, match='missing required coordinate'): + with pytest.raises(ValueError, match="missing required coordinate"): experiment._validate_coordinates(invalid_data) def test_validate_coordinates_raises_missing_energy(self, experiment): "Test that _validate_coordinates raises ValueError when energy" - 'coord is missing' + "coord is missing" # WHEN invalid_data = experiment._data.copy() - invalid_data.coords.pop('energy') + invalid_data.coords.pop("energy") # THEN EXPECT - with pytest.raises(ValueError, match='missing required coordinate'): + with pytest.raises(ValueError, match="missing required coordinate"): experiment._validate_coordinates(invalid_data) def test_validate_coordinates_raises_not_DataArray(self): "Test that _validate_coordinates raises TypeError when data is" - 'not a Scipp DataArray' + "not a Scipp DataArray" # WHEN THEN EXPECT - with pytest.raises(TypeError, match='must be a'): - Experiment()._validate_coordinates('not_a_data_array') + with pytest.raises(TypeError, match="must be a"): + Experiment()._validate_coordinates("not_a_data_array") def test_convert_to_bin_centers(self, experiment): "Test that _convert_to_bin_centers converts edges to centers" # WHEN - Q_edges = sc.linspace('Q', 0.0, 2.0, num=11, unit='1/Angstrom') - energy_edges = sc.linspace('energy', -6, 6, num=13, unit='meV') - values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 12))) - binned_data = sc.DataArray(data=values, coords={'Q': Q_edges, 'energy': energy_edges}) + Q_edges = sc.linspace("Q", 0.0, 2.0, num=11, unit="1/Angstrom") + energy_edges = sc.linspace("energy", -6, 6, num=13, unit="meV") + values = sc.array(dims=["Q", "energy"], values=np.ones((10, 12))) + binned_data = sc.DataArray( + data=values, coords={"Q": Q_edges, "energy": energy_edges} + ) # THEN experiment._data = binned_data # Set data to avoid warnings @@ -375,10 +377,37 @@ def test_convert_to_bin_centers(self, experiment): expected_Q = 0.5 * (Q_edges[:-1] + Q_edges[1:]) expected_energy = 0.5 * (energy_edges[:-1] + energy_edges[1:]) - assert sc.identical(converted_data.coords['Q'], expected_Q) - assert sc.identical(converted_data.coords['energy'], expected_energy) + assert sc.identical(converted_data.coords["Q"], expected_Q) + assert sc.identical(converted_data.coords["energy"], expected_energy) assert sc.identical(converted_data.data, binned_data.data) + def test_extract_x_y_weights(self): + # WHEN + Q = sc.array(dims=["Q"], values=[1, 2, 3], unit="1/Angstrom") + energy = sc.array(dims=["energy"], values=[10.0, 20.0, 30.0], unit="meV") + data = sc.array( + dims=["Q", "energy"], + values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], + variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], + ) + + data_array = sc.DataArray(data=data, coords={"Q": Q, "energy": energy}) + + experiment = Experiment(data=data_array) + + Q_index = 0 + + # THEN + x, y, weights = experiment._extract_x_y_weights(Q_index=Q_index) + + # EXPECT + assert np.array_equal(x, experiment.energy.values) + assert np.array_equal(y, experiment.data.values[Q_index]) + assert np.array_equal( + weights, + 1 / experiment.data.variances[Q_index] ** 0.5, + ) + ############## # test dunder methods ############## @@ -388,12 +417,15 @@ def test_repr(self, experiment): repr_str = repr(experiment) # THEN EXPECT - assert repr_str == f'Experiment `{experiment.unique_name}` with data: {experiment._data}' + assert ( + repr_str + == f"Experiment `{experiment.unique_name}` with data: {experiment._data}" + ) def test_copy_experiment(self, experiment): "Test copying an Experiment object." - 'The copied object should have the same attributes ' - 'but be a different object in memory.' + "The copied object should have the same attributes " + "but be a different object in memory." # WHEN copied_experiment = copy(experiment) From 39ae3d1661ff79a1714f36c8ec4609e170a0696e Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Mon, 9 Mar 2026 20:32:32 +0100 Subject: [PATCH 2/5] Handle NaN's in data --- src/easydynamics/analysis/analysis.py | 82 +++--- src/easydynamics/analysis/analysis1d.py | 72 +++--- src/easydynamics/analysis/analysis_base.py | 40 ++- src/easydynamics/experiment/experiment.py | 104 ++++---- .../easydynamics/analysis/test_analysis1d.py | 204 +++++++-------- .../analysis/test_analysis_base.py | 134 +++++----- .../experiment/test_experiment.py | 238 ++++++++++-------- 7 files changed, 426 insertions(+), 448 deletions(-) diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 9e6b91e2..33a5d948 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -57,7 +57,7 @@ class Analysis(AnalysisBase): def __init__( self, - display_name: str = "MyAnalysis", + display_name: str = 'MyAnalysis', unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -100,8 +100,8 @@ def __init__( if self.Q is not None: for Q_index in range(len(self.Q)): analysis = Analysis1d( - display_name=f"{self.display_name}_Q{Q_index}", - unique_name=(f"{self.unique_name}_Q{Q_index}"), + display_name=f'{self.display_name}_Q{Q_index}', + unique_name=(f'{self.unique_name}_Q{Q_index}'), experiment=self.experiment, sample_model=self.sample_model, instrument_model=self.instrument_model, @@ -139,9 +139,9 @@ def analysis_list(self, value: list[Analysis1d]) -> None: """ raise AttributeError( - "analysis_list is read-only. " - "To change the analysis list, modify the experiment, sample model, " - "or instrument model." + 'analysis_list is read-only. ' + 'To change the analysis list, modify the experiment, sample model, ' + 'or instrument model.' ) ############# @@ -176,7 +176,7 @@ def calculate( def fit( self, - fit_method: str = "independent", + fit_method: str = 'independent', Q_index: int | None = None, ) -> FitResults | list[FitResults]: """Fit the model to the experimental data. @@ -204,22 +204,20 @@ def fit( if self.Q is None: raise ValueError( - "No Q values available for fitting. Please check the experiment data." + 'No Q values available for fitting. Please check the experiment data.' ) Q_index = self._verify_Q_index(Q_index) - if fit_method == "independent": + if fit_method == 'independent': if Q_index is not None: return self._fit_single_Q(Q_index) else: return self._fit_all_Q_independently() - elif fit_method == "simultaneous": + elif fit_method == 'simultaneous': return self._fit_all_Q_simultaneously() else: - raise ValueError( - "Invalid fit method. Choose 'independent' or 'simultaneous'." - ) + raise ValueError("Invalid fit method. Choose 'independent' or 'simultaneous'.") def plot_data_and_model( self, @@ -266,44 +264,42 @@ def plot_data_and_model( ) if self.experiment.binned_data is None: - raise ValueError("No data to plot. Please load data first.") + raise ValueError('No data to plot. Please load data first.') if not _in_notebook(): - raise RuntimeError( - "plot_data() can only be used in a Jupyter notebook environment." - ) + raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.') if self.Q is None: raise ValueError( - "No Q values available for plotting. Please check the experiment data." + 'No Q values available for plotting. Please check the experiment data.' ) if not isinstance(plot_components, bool): - raise TypeError("plot_components must be True or False.") + raise TypeError('plot_components must be True or False.') if not isinstance(add_background, bool): - raise TypeError("add_background must be True or False.") + raise TypeError('add_background must be True or False.') import plopp as pp plot_kwargs_defaults = { - "title": self.display_name, - "linestyle": {"Data": "none", "Model": "-"}, - "marker": {"Data": "o", "Model": None}, - "color": {"Data": "black", "Model": "red"}, - "markerfacecolor": {"Data": "none", "Model": "none"}, + 'title': self.display_name, + 'linestyle': {'Data': 'none', 'Model': '-'}, + 'marker': {'Data': 'o', 'Model': None}, + 'color': {'Data': 'black', 'Model': 'red'}, + 'markerfacecolor': {'Data': 'none', 'Model': 'none'}, } data_and_model = { - "Data": self.experiment.binned_data, - "Model": self._create_model_array(), + 'Data': self.experiment.binned_data, + 'Model': self._create_model_array(), } if plot_components: components = self._create_components_dataset(add_background=add_background) for key in components.keys(): data_and_model[key] = components[key] - plot_kwargs_defaults["linestyle"][key] = "--" - plot_kwargs_defaults["marker"][key] = None + plot_kwargs_defaults['linestyle'][key] = '--' + plot_kwargs_defaults['marker'][key] = None # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -330,7 +326,7 @@ def parameters_to_dataset(self) -> sc.Dataset: parameter across different Q values. """ - ds = sc.Dataset(coords={"Q": self.Q}) + ds = sc.Dataset(coords={'Q': self.Q}) # Collect all parameter names all_names = { @@ -360,7 +356,7 @@ def parameters_to_dataset(self) -> sc.Dataset: except Exception as e: raise UnitError( f"Inconsistent units for parameter '{name}': " - f"{units[name]} vs {p.unit}" + f'{units[name]} vs {p.unit}' ) from e values[name].append(p.value) @@ -372,7 +368,7 @@ def parameters_to_dataset(self) -> sc.Dataset: # Build dataset variables for name in all_names: ds[name] = sc.Variable( - dims=["Q"], + dims=['Q'], values=np.asarray(values[name], dtype=float), variances=np.asarray(variances[name], dtype=float), unit=units.get(name, None), @@ -407,10 +403,8 @@ def plot_parameters( if isinstance(names, str): names = [names] - if not isinstance(names, list) or not all( - isinstance(name, str) for name in names - ): - raise TypeError("names must be a string or a list of strings.") + if not isinstance(names, list) or not all(isinstance(name, str) for name in names): + raise TypeError('names must be a string or a list of strings.') for name in names: if name not in ds: @@ -418,9 +412,9 @@ def plot_parameters( data_to_plot = {name: ds[name] for name in names} plot_kwargs_defaults = { - "linestyle": {name: "none" for name in names}, - "marker": {name: "o" for name in names}, - "markerfacecolor": {name: "none" for name in names}, + 'linestyle': {name: 'none' for name in names}, + 'marker': {name: 'o' for name in names}, + 'markerfacecolor': {name: 'none' for name in names}, } plot_kwargs_defaults.update(kwargs) @@ -512,7 +506,7 @@ def _fit_all_Q_simultaneously(self) -> FitResults: ws = [] for analysis in self.analysis_list: - x, y, weight = self.experiment._extract_x_y_weights(analysis.Q_index) + x, y, weight = self.experiment._extract_x_y_weights_only_finite(analysis.Q_index) xs.append(x) ys.append(y) ws.append(weight) @@ -550,10 +544,10 @@ def _create_model_array(self) -> sc.DataArray: dimensions "Q" and "energy". """ - model = sc.array(dims=["Q", "energy"], values=self.calculate()) + model = sc.array(dims=['Q', 'energy'], values=self.calculate()) model_data_array = sc.DataArray( data=model, - coords={"Q": self.Q, "energy": self.experiment.energy}, + coords={'Q': self.Q, 'energy': self.experiment.energy}, ) return model_data_array @@ -574,14 +568,14 @@ def _create_components_dataset(self, add_background: bool = True) -> sc.Dataset: of the model, with dimensions "Q". """ if not isinstance(add_background, bool): - raise TypeError("add_background must be True or False.") + raise TypeError('add_background must be True or False.') datasets = [ analysis._create_components_dataset_single_Q(add_background=add_background) for analysis in self.analysis_list ] - return sc.concat(datasets, dim="Q") + return sc.concat(datasets, dim='Q') ############# # Dunder methods diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 4b106cb5..521ac1f8 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -62,7 +62,7 @@ class Analysis1d(AnalysisBase): def __init__( self, - display_name: str = "MyAnalysis", + display_name: str = 'MyAnalysis', unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -185,7 +185,7 @@ def fit(self) -> FitResults: FitResults: The result of the fit. """ if self._experiment is None: - raise ValueError("No experiment is associated with this Analysis.") + raise ValueError('No experiment is associated with this Analysis.') # Create convolver once to reuse during fitting self._convolver = self._create_convolver() @@ -195,7 +195,7 @@ def fit(self) -> FitResults: fit_function=self.as_fit_function(), ) - x, y, weights = self.experiment._extract_x_y_weights( + x, y, weights = self.experiment._extract_x_y_weights_only_finite( Q_index=self._require_Q_index() ) fit_result = fitter.fit(x=x, y=y, weights=weights) @@ -265,37 +265,33 @@ def plot_data_and_model( import plopp as pp if self.experiment.data is None: - raise ValueError("No data to plot. Please load data first.") + raise ValueError('No data to plot. Please load data first.') - data = self.experiment.data["Q", self.Q_index] + data = self.experiment.data['Q', self.Q_index] model_array = self._create_sample_scipp_array() - component_dataset = self._create_components_dataset_single_Q( - add_background=add_background - ) + component_dataset = self._create_components_dataset_single_Q(add_background=add_background) # Create a dataset containing the data, model, and individual # components for plotting. - data_and_model = sc.Dataset( - { - "Data": data, - "Model": model_array, - } - ) + data_and_model = sc.Dataset({ + 'Data': data, + 'Model': model_array, + }) data_and_model = sc.merge(data_and_model, component_dataset) plot_kwargs_defaults = { - "title": self.display_name, - "linestyle": {"Data": "none", "Model": "-"}, - "marker": {"Data": "o", "Model": "none"}, - "color": {"Data": "black", "Model": "red"}, - "markerfacecolor": {"Data": "none", "Model": "none"}, + 'title': self.display_name, + 'linestyle': {'Data': 'none', 'Model': '-'}, + 'marker': {'Data': 'o', 'Model': 'none'}, + 'color': {'Data': 'black', 'Model': 'red'}, + 'markerfacecolor': {'Data': 'none', 'Model': 'none'}, } if plot_components: for comp_name in component_dataset.keys(): - plot_kwargs_defaults["linestyle"][comp_name] = "--" - plot_kwargs_defaults["marker"][comp_name] = None + plot_kwargs_defaults['linestyle'][comp_name] = '--' + plot_kwargs_defaults['marker'][comp_name] = None # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -321,7 +317,7 @@ def _require_Q_index(self) -> int: ValueError: If the Q index is not set. """ if self._Q_index is None: - raise ValueError("Q_index must be set.") + raise ValueError('Q_index must be set.') return self._Q_index def _on_Q_index_changed(self) -> None: @@ -388,9 +384,7 @@ def _evaluate_components( # performance is not important. # We don't create a convolver if the resolution is empty. - resolution = self.instrument_model.resolution_model.get_component_collection( - Q_index - ) + resolution = self.instrument_model.resolution_model.get_component_collection(Q_index) if resolution.is_empty: return components.evaluate(energy - energy_offset.value) @@ -445,10 +439,8 @@ def _evaluate_background(self) -> np.ndarray: np.ndarray: The evaluated background contribution. """ Q_index = self._require_Q_index() - background_components = ( - self.instrument_model.background_model.get_component_collection( - Q_index=Q_index - ) + background_components = self.instrument_model.background_model.get_component_collection( + Q_index=Q_index ) return self._evaluate_components( components=background_components, @@ -492,8 +484,8 @@ def _create_convolver(self) -> Convolution | None: if sample_components.is_empty: return None - resolution_components = ( - self.instrument_model.resolution_model.get_component_collection(Q_index) + resolution_components = self.instrument_model.resolution_model.get_component_collection( + Q_index ) if resolution_components.is_empty: return None @@ -581,19 +573,17 @@ def _create_components_dataset_single_Q( Q_index=self.Q_index ).components - background_components = ( - self.instrument_model.background_model.get_component_collection( - Q_index=self.Q_index - ).components - ) + background_components = self.instrument_model.background_model.get_component_collection( + Q_index=self.Q_index + ).components background = self._evaluate_background() if add_background else None for component in sample_components: scipp_arrays[component.display_name] = self._create_component_scipp_array( component=component, background=background ) for component in background_components: - scipp_arrays[component.display_name] = ( - self._create_background_component_scipp_array(component=component) + scipp_arrays[component.display_name] = self._create_background_component_scipp_array( + component=component ) return sc.Dataset(scipp_arrays) @@ -609,9 +599,9 @@ def _to_scipp_array(self, values: np.ndarray) -> sc.DataArray: """ return sc.DataArray( - data=sc.array(dims=["energy"], values=values), + data=sc.array(dims=['energy'], values=values), coords={ - "energy": self.energy, - "Q": self.Q[self.Q_index], + 'energy': self.energy, + 'Q': self.Q[self.Q_index], }, ) diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index 1e6a92bb..0c1ab171 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -55,7 +55,7 @@ class AnalysisBase(EasyScienceModelBase): def __init__( self, - display_name: str = "MyAnalysis", + display_name: str = 'MyAnalysis', unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -97,23 +97,21 @@ def __init__( elif isinstance(experiment, Experiment): self._experiment = experiment else: - raise TypeError("experiment must be an instance of Experiment or None.") + raise TypeError('experiment must be an instance of Experiment or None.') if sample_model is None: self._sample_model = SampleModel() elif isinstance(sample_model, SampleModel): self._sample_model = sample_model else: - raise TypeError("sample_model must be an instance of SampleModel or None.") + raise TypeError('sample_model must be an instance of SampleModel or None.') if instrument_model is None: self._instrument_model = InstrumentModel() elif isinstance(instrument_model, InstrumentModel): self._instrument_model = instrument_model else: - raise TypeError( - "instrument_model must be an instance of InstrumentModel or None." - ) + raise TypeError('instrument_model must be an instance of InstrumentModel or None.') if extra_parameters is not None: if isinstance(extra_parameters, Parameter): @@ -123,9 +121,7 @@ def __init__( ): self._extra_parameters = extra_parameters else: - raise TypeError( - "extra_parameters must be a Parameter or a list of Parameters." - ) + raise TypeError('extra_parameters must be a Parameter or a list of Parameters.') else: self._extra_parameters = [] @@ -154,7 +150,7 @@ def experiment(self, value: Experiment) -> None: """ if not isinstance(value, Experiment): - raise TypeError("experiment must be an instance of Experiment") + raise TypeError('experiment must be an instance of Experiment') self._experiment = value self._on_experiment_changed() @@ -176,7 +172,7 @@ def sample_model(self, value: SampleModel) -> None: TypeError: if value is not a SampleModel. """ if not isinstance(value, SampleModel): - raise TypeError("sample_model must be an instance of SampleModel") + raise TypeError('sample_model must be an instance of SampleModel') self._sample_model = value self._on_sample_model_changed() @@ -198,7 +194,7 @@ def instrument_model(self, value: InstrumentModel) -> None: TypeError: if value is not an InstrumentModel. """ if not isinstance(value, InstrumentModel): - raise TypeError("instrument_model must be an instance of InstrumentModel") + raise TypeError('instrument_model must be an instance of InstrumentModel') self._instrument_model = value self._on_instrument_model_changed() @@ -222,7 +218,7 @@ def Q(self, value) -> None: Raises: AttributeError: If trying to set Q. """ - raise AttributeError("Q is a read-only property derived from the Experiment.") + raise AttributeError('Q is a read-only property derived from the Experiment.') @property def energy(self) -> sc.Variable | None: @@ -246,9 +242,7 @@ def energy(self, value) -> None: AttributeError: If trying to set energy. """ - raise AttributeError( - "energy is a read-only property derived from the Experiment." - ) + raise AttributeError('energy is a read-only property derived from the Experiment.') @property def temperature(self) -> Parameter | None: @@ -272,9 +266,7 @@ def temperature(self, value) -> None: AttributeError: If trying to set temperature. """ - raise AttributeError( - "temperature is a read-only property derived from the SampleModel." - ) + raise AttributeError('temperature is a read-only property derived from the SampleModel.') @property def extra_parameters(self) -> list[Parameter]: @@ -305,9 +297,7 @@ def extra_parameters(self, value: Parameter | list[Parameter]) -> None: elif value is None: self._extra_parameters = [] else: - raise TypeError( - "extra_parameters must be a Parameter, a list of Parameters, or None." - ) + raise TypeError('extra_parameters must be a Parameter, a list of Parameters, or None.') ############# # Other methods @@ -354,7 +344,7 @@ def _verify_Q_index(self, Q_index: int | None) -> int | None: or Q_index < 0 or (self.Q is not None and Q_index >= len(self.Q)) ): - raise IndexError("Q_index must be a valid index for the Q values.") + raise IndexError('Q_index must be a valid index for the Q values.') return Q_index ############# @@ -367,5 +357,5 @@ def __repr__(self) -> str: Returns: str: A string representation of the Analysis. """ - return f" {self.__class__.__name__} (display_name={self.display_name}, \ - unique_name={self.unique_name})" + return f' {self.__class__.__name__} (display_name={self.display_name}, \ + unique_name={self.unique_name})' diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index 29d1084a..99644a8a 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -38,7 +38,7 @@ class Experiment(NewBase): def __init__( self, - display_name: str = "MyExperiment", + display_name: str = 'MyExperiment', unique_name: str | None = None, data: sc.DataArray | str | None = None, ): @@ -71,7 +71,7 @@ def __init__( self._data = data else: raise TypeError( - f"Data must be a sc.DataArray or a filename string, not {type(data).__name__}" + f'Data must be a sc.DataArray or a filename string, not {type(data).__name__}' ) self._binned_data = ( @@ -105,7 +105,7 @@ def data(self, value: sc.DataArray) -> None: ValueError: If the dataset is missing required coordinates. """ if not isinstance(value, sc.DataArray): - raise TypeError(f"Data must be a sc.DataArray, not {type(value).__name__}") + raise TypeError(f'Data must be a sc.DataArray, not {type(value).__name__}') self._validate_coordinates(value) self._data = value self._binned_data = ( @@ -134,9 +134,7 @@ def binned_data(self, value: sc.DataArray) -> None: Raises: AttributeError: Always, since binned_data is read-only. """ - raise AttributeError( - "binned_data is a read-only property. Use rebin() to rebin the data" - ) + raise AttributeError('binned_data is a read-only property. Use rebin() to rebin the data') @property def Q(self) -> sc.Variable | None: @@ -148,7 +146,7 @@ def Q(self) -> sc.Variable | None: """ if self._data is None: return None - return self._binned_data.coords["Q"] + return self._binned_data.coords['Q'] @Q.setter def Q(self, value: sc.Variable) -> None: @@ -161,7 +159,7 @@ def Q(self, value: sc.Variable) -> None: Raises: AttributeError: Always, since Q is read-only. """ - raise AttributeError("Q is a read-only property derived from the data.") + raise AttributeError('Q is a read-only property derived from the data.') @property def energy(self) -> sc.Variable | None: @@ -173,7 +171,7 @@ def energy(self) -> sc.Variable | None: """ if self._data is None: return None - return self._binned_data.coords["energy"] + return self._binned_data.coords['energy'] @energy.setter def energy(self, value: sc.Variable) -> None: @@ -186,7 +184,7 @@ def energy(self, value: sc.Variable) -> None: Raises: AttributeError: Always, since energy is read-only. """ - raise AttributeError("energy is a read-only property derived from the data.") + raise AttributeError('energy is a read-only property derived from the data.') ########### # Handle data @@ -207,19 +205,19 @@ def load_hdf5(self, filename: str, display_name: str | None = None): coordinates. """ if not isinstance(filename, str): - raise TypeError(f"Filename must be a string, not {type(filename).__name__}") + raise TypeError(f'Filename must be a string, not {type(filename).__name__}') if display_name is not None: if not isinstance(display_name, str): raise TypeError( - f"Display name must be a string, not {type(display_name).__name__}" + f'Display name must be a string, not {type(display_name).__name__}' ) self.display_name = display_name 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__}" + f'Loaded data must be a sc.DataArray, not {type(loaded_data).__name__}' ) self._validate_coordinates(loaded_data) self.data = loaded_data @@ -238,13 +236,13 @@ def save_hdf5(self, filename: str | None = None): """ if filename is None: - filename = f"{self.unique_name}.h5" + filename = f'{self.unique_name}.h5' if not isinstance(filename, str): - raise TypeError(f"Filename must be a string, not {type(filename).__name__}") + raise TypeError(f'Filename must be a string, not {type(filename).__name__}') if self._data is None: - raise ValueError("No data to save.") + raise ValueError('No data to save.') dir_name = os.path.dirname(filename) if dir_name: @@ -273,33 +271,31 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: if not isinstance(dimensions, dict): raise TypeError( - "dimensions must be a dictionary mapping dimension names " - "to number of bins or bin values as sc.Variable." + 'dimensions must be a dictionary mapping dimension names ' + 'to number of bins or bin values as sc.Variable.' ) if self._data is None: - raise ValueError("No data to rebin. Please load data first.") + raise ValueError('No data to rebin. Please load data first.') binned_data = self._data.copy() dim_copy = dimensions.copy() for dim, value in dim_copy.items(): if not isinstance(dim, str): raise TypeError( - f"Dimension keys must be strings. Got {type(dim)} for {dim} instead." + f'Dimension keys must be strings. Got {type(dim)} for {dim} instead.' ) if dim not in self._data.dims: raise KeyError( f"Dimension '{dim}' not a valid dimension for rebinning. " - f"Should be one of {self._data.dims}." + f'Should be one of {self._data.dims}.' ) - if ( - isinstance(value, float) and value.is_integer() - ): # I allow eg. 2.0 as well as 2 + if isinstance(value, float) and value.is_integer(): # I allow eg. 2.0 as well as 2 value = int(value) # This line can be removed when scipp resize support # resizing with coordinates dimensions[dim] = value if not (isinstance(value, int) or isinstance(value, sc.Variable)): raise TypeError( - f"Dimension values must be integers or sc.Variable. " + f'Dimension values must be integers or sc.Variable. ' f"Got {type(value)} for dimension '{dim}' instead." ) binned_data = binned_data.bin({dim: value}) @@ -325,15 +321,13 @@ def plot_data(self, slicer=False, **kwargs) -> None: """ if self._binned_data is None: - raise ValueError("No data to plot. Please load data first.") + raise ValueError('No data to plot. Please load data first.') if not _in_notebook(): - raise RuntimeError( - "plot_data() can only be used in a Jupyter notebook environment." - ) + raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.') plot_kwargs_defaults = { - "title": self.display_name, + 'title': self.display_name, } # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -344,7 +338,7 @@ def plot_data(self, slicer=False, **kwargs) -> None: ) else: fig = pp.plot( - self._binned_data.transpose(dims=["energy", "Q"]), + self._binned_data.transpose(dims=['energy', 'Q']), **plot_kwargs_defaults, ) return fig @@ -364,9 +358,9 @@ def _validate_coordinates(data: sc.DataArray) -> None: ValueError: If required coordinates are missing. """ if not isinstance(data, sc.DataArray): - raise TypeError("Data must be a sc.DataArray.") + raise TypeError('Data must be a sc.DataArray.') - required_coords = ["Q", "energy"] + required_coords = ['Q', 'energy'] for coord in required_coords: if coord not in data.coords: raise ValueError(f"Data is missing required coordinate: '{coord}'") @@ -387,9 +381,7 @@ def _convert_to_bin_centers(self, data: sc.DataArray) -> sc.DataArray: data = data.assign_coords({dim: sc.midpoints(coord)}) return data - def _extract_x_y_weights( - self, Q_index: int - ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + def _extract_x_y_e(self, Q_index: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Extract the x, y, and weights arrays from the experiment for the given Q index. @@ -401,13 +393,41 @@ def _extract_x_y_weights( weights arrays extracted from the experiment for the given Q index. """ - data = self.data["Q", Q_index] - x = data.coords["energy"].values + data = self.data['Q', Q_index] + x = data.coords['energy'].values y = data.values e = data.variances**0.5 + return x, y, e + + def _extract_x_y_weights_only_finite( + self, Q_index: int + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Extract the x, y, and weights arrays from the experiment for + the given Q index, removing any NaN and Inf values. + + Args: + Q_index (int): The Q index to extract the data for. + + Returns: + tuple[np.ndarray, np.ndarray, np.ndarray]: The x, y, and + weights arrays extracted from the experiment for the + given Q index, with NaNs and Infs removed. + + Raises: + ValueError: If any variances are zero after removing NaNs + and Infs, since this would lead to infinite weights. + """ + x, y, e = self._extract_x_y_e(Q_index) + mask = np.isfinite(y) & np.isfinite(e) & np.isfinite(x) + + x = x[mask] + y = y[mask] + e = e[mask] + if np.any(e == 0): - raise ValueError("Cannot compute weights: some variances are zero.") + raise ValueError('Cannot compute weights: some variances are zero.') weights = 1.0 / e + return x, y, weights ######## @@ -421,15 +441,15 @@ def __repr__(self) -> str: str: A string representation of the Experiment object. """ - return f"Experiment `{self.unique_name}` with data: {self._data}" + return f'Experiment `{self.unique_name}` with data: {self._data}' - def __copy__(self) -> "Experiment": + def __copy__(self) -> 'Experiment': """Return a copy of the object. Returns: Experiment: A copy of the Experiment object. """ - temp = self.to_dict(skip=["unique_name"]) + temp = self.to_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 diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 404a5c1f..9b1a1894 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -22,22 +22,22 @@ class TestAnalysis1d: @pytest.fixture def analysis1d(self): - Q = sc.array(dims=["Q"], values=[1, 2, 3], unit="1/Angstrom") - energy = sc.array(dims=["energy"], values=[10.0, 20.0, 30.0], unit="meV") + Q = sc.array(dims=['Q'], values=[1, 2, 3], unit='1/Angstrom') + energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') data = sc.array( - dims=["Q", "energy"], + dims=['Q', 'energy'], values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], ) - data_array = sc.DataArray(data=data, coords={"Q": Q, "energy": energy}) + data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) experiment = Experiment(data=data_array) sample_model = SampleModel(components=Gaussian()) instrument_model = InstrumentModel() analysis1d = Analysis1d( - display_name="TestAnalysis", + display_name='TestAnalysis', experiment=experiment, sample_model=sample_model, instrument_model=instrument_model, @@ -51,7 +51,7 @@ def test_init(self, analysis1d): # WHEN THEN # EXPECT - assert analysis1d.display_name == "TestAnalysis" + assert analysis1d.display_name == 'TestAnalysis' assert isinstance(analysis1d._experiment, Experiment) assert isinstance(analysis1d._sample_model, SampleModel) assert isinstance(analysis1d._instrument_model, InstrumentModel) @@ -61,7 +61,7 @@ def test_init(self, analysis1d): def test_init_no_experiment(self): # WHEN - analysis1d = Analysis1d(display_name="TestAnalysisNoExperiment") + analysis1d = Analysis1d(display_name='TestAnalysisNoExperiment') # THEN EXPECT assert isinstance(analysis1d._experiment, Experiment) @@ -75,20 +75,20 @@ def test_Q_index_setter(self, analysis1d): assert analysis1d.Q_index == 1 @pytest.mark.parametrize( - "invalid_Q_index, expected_exception, expected_message", + 'invalid_Q_index, expected_exception, expected_message', [ - (-1, IndexError, "Q_index must be"), - (10, IndexError, "Q_index must be"), - ("invalid", IndexError, "Q_index must be "), - (np.nan, IndexError, "Q_index must be "), - ([1, 2], IndexError, "Q_index must be "), + (-1, IndexError, 'Q_index must be'), + (10, IndexError, 'Q_index must be'), + ('invalid', IndexError, 'Q_index must be '), + (np.nan, IndexError, 'Q_index must be '), + ([1, 2], IndexError, 'Q_index must be '), ], ids=[ - "Negative index", - "Index out of range", - "Non-integer string", - "NaN value", - "List instead of integer", + 'Negative index', + 'Index out of range', + 'Non-integer string', + 'NaN value', + 'List instead of integer', ], ) def test_Q_index_setter_incorrect_Q( @@ -138,7 +138,7 @@ def test_fit_raises_if_no_experiment(self, analysis1d): analysis1d._experiment = None # EXPECT - with pytest.raises(ValueError, match="No experiment"): + with pytest.raises(ValueError, match='No experiment'): analysis1d.fit() def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): @@ -151,21 +151,21 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): fake_y = np.array([10, 20, 30]) fake_weights = np.array([0.1, 0.2, 0.3]) - analysis1d.experiment._extract_x_y_weights = MagicMock( + analysis1d.experiment._extract_x_y_weights_only_finite = MagicMock( return_value=(fake_x, fake_y, fake_weights) ) - analysis1d._create_convolver = MagicMock(return_value="fake_convolver") + analysis1d._create_convolver = MagicMock(return_value='fake_convolver') fake_fit_result = object() fake_fitter_instance = MagicMock() fake_fitter_instance.fit.return_value = fake_fit_result with patch( - "easydynamics.analysis.analysis1d.EasyScienceFitter", + 'easydynamics.analysis.analysis1d.EasyScienceFitter', return_value=fake_fitter_instance, ) as mock_fitter: - analysis1d.as_fit_function = MagicMock(return_value="fit_func") + analysis1d.as_fit_function = MagicMock(return_value='fit_func') # THEN result = analysis1d.fit() @@ -178,10 +178,10 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): mock_fitter.assert_called_once_with( fit_object=analysis1d, - fit_function="fit_func", + fit_function='fit_func', ) - analysis1d.experiment._extract_x_y_weights.assert_called_once() + analysis1d.experiment._extract_x_y_weights_only_finite.assert_called_once() fake_fitter_instance.fit.assert_called_once_with( x=fake_x, @@ -215,8 +215,8 @@ def test_as_fit_function_calls_calculate(self, analysis1d): def test_get_all_variables(self, analysis1d): # WHEN - extra_par1 = Parameter(name="extra_par1", value=1.0) - extra_par2 = Parameter(name="extra_par2", value=2.0) + extra_par1 = Parameter(name='extra_par1', value=1.0) + extra_par2 = Parameter(name='extra_par2', value=2.0) analysis1d._extra_parameters = [extra_par1, extra_par2] # THEN @@ -224,12 +224,8 @@ def test_get_all_variables(self, analysis1d): # EXPECT assert isinstance(variables, list) - sample_vars = analysis1d.sample_model.get_all_variables( - Q_index=analysis1d.Q_index - ) - instrument_vars = analysis1d.instrument_model.get_all_variables( - Q_index=analysis1d.Q_index - ) + sample_vars = analysis1d.sample_model.get_all_variables(Q_index=analysis1d.Q_index) + instrument_vars = analysis1d.instrument_model.get_all_variables(Q_index=analysis1d.Q_index) extra_vars = [extra_par1, extra_par2] expected_vars = sample_vars + instrument_vars + extra_vars assert Counter(variables) == Counter(expected_vars) @@ -237,30 +233,24 @@ def test_get_all_variables(self, analysis1d): def test_plot_raises_if_no_data(self, analysis1d): analysis1d.experiment._data = None - with pytest.raises(ValueError, match="No data"): + with pytest.raises(ValueError, match='No data'): analysis1d.plot_data_and_model() def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): # WHEN # Mock the data and model components to be plotted - fake_model = sc.DataArray(data=sc.array(dims=["energy"], values=[1, 2, 3])) + fake_model = sc.DataArray(data=sc.array(dims=['energy'], values=[1, 2, 3])) analysis1d._create_sample_scipp_array = MagicMock(return_value=fake_model) - fake_components = sc.Dataset( - { - "Component1": sc.DataArray( - data=sc.array(dims=["energy"], values=[0.1, 0.2, 0.3]) - ) - } - ) - analysis1d._create_components_dataset_single_Q = MagicMock( - return_value=fake_components - ) + fake_components = sc.Dataset({ + 'Component1': sc.DataArray(data=sc.array(dims=['energy'], values=[0.1, 0.2, 0.3])) + }) + analysis1d._create_components_dataset_single_Q = MagicMock(return_value=fake_components) fake_fig = object() - with patch("plopp.plot", return_value=fake_fig) as mock_plot: + with patch('plopp.plot', return_value=fake_fig) as mock_plot: # THEN result = analysis1d.plot_data_and_model() @@ -277,9 +267,9 @@ def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): dataset_passed = args[0] - assert "Data" in dataset_passed - assert "Model" in dataset_passed - assert "Component1" in dataset_passed + assert 'Data' in dataset_passed + assert 'Model' in dataset_passed + assert 'Component1' in dataset_passed assert result is fake_fig @@ -299,7 +289,7 @@ def test_require_Q_index_raises_if_no_Q_index(self, analysis1d): analysis1d._Q_index = None # EXPECT - with pytest.raises(ValueError, match="Q_index must be set"): + with pytest.raises(ValueError, match='Q_index must be set'): analysis1d._require_Q_index() def test_on_Q_index_changed(self, analysis1d): @@ -376,7 +366,7 @@ def test_evaluate_with_resolution(self, analysis1d): analysis1d.instrument_model.resolution_model.components = Gaussian() components = Gaussian() - with patch("easydynamics.analysis.analysis1d.Convolution") as MockConvolution: + with patch('easydynamics.analysis.analysis1d.Convolution') as MockConvolution: # THEN analysis1d._evaluate_components( components=components, @@ -395,22 +385,20 @@ def test_evaluate_with_resolution(self, analysis1d): ) ) - energy_offset = analysis1d.instrument_model.get_energy_offset_at_Q( - analysis1d.Q_index - ) + energy_offset = analysis1d.instrument_model.get_energy_offset_at_Q(analysis1d.Q_index) # Extract call arguments _, kwargs = MockConvolution.call_args - assert kwargs["sample_components"] == components - assert kwargs["resolution_components"] == resolution_components - assert kwargs["temperature"] == analysis1d.temperature - assert kwargs["energy_offset"] == energy_offset + assert kwargs['sample_components'] == components + assert kwargs['resolution_components'] == resolution_components + assert kwargs['temperature'] == analysis1d.temperature + assert kwargs['energy_offset'] == energy_offset # check that the energy array passed to the convolver is the # same as the analysis1d energy array np.testing.assert_array_equal( - kwargs["energy"], + kwargs['energy'], analysis1d.energy.values, ) @@ -461,9 +449,7 @@ def test_evaluate_sample_component(self, analysis1d): def test_evaluate_background(self, analysis1d): # WHEN - analysis1d.instrument_model.background_model.get_component_collection = ( - MagicMock() - ) + analysis1d.instrument_model.background_model.get_component_collection = MagicMock() analysis1d._evaluate_components = MagicMock() # THEN @@ -518,15 +504,13 @@ def test_create_convolver(self, analysis1d): return_value=sample_components ) - analysis1d.instrument_model.resolution_model.get_component_collection = ( - MagicMock(return_value=resolution_components) + analysis1d.instrument_model.resolution_model.get_component_collection = MagicMock( + return_value=resolution_components ) - analysis1d.instrument_model.get_energy_offset_at_Q = MagicMock( - return_value=123.0 - ) + analysis1d.instrument_model.get_energy_offset_at_Q = MagicMock(return_value=123.0) - with patch("easydynamics.analysis.analysis1d.Convolution") as MockConvolution: + with patch('easydynamics.analysis.analysis1d.Convolution') as MockConvolution: # THEN result = analysis1d._create_convolver() @@ -536,17 +520,15 @@ def test_create_convolver(self, analysis1d): _, kwargs = MockConvolution.call_args - assert kwargs["sample_components"] is sample_components - assert kwargs["resolution_components"] is resolution_components - assert sc.identical(kwargs["energy"], analysis1d.energy) - assert kwargs["temperature"] is analysis1d.temperature - assert kwargs["energy_offset"] == 123.0 + assert kwargs['sample_components'] is sample_components + assert kwargs['resolution_components'] is resolution_components + assert sc.identical(kwargs['energy'], analysis1d.energy) + assert kwargs['temperature'] is analysis1d.temperature + assert kwargs['energy_offset'] == 123.0 assert result == MockConvolution.return_value - def test_create_convolver_returns_none_if_no_resolution_components( - self, analysis1d - ): + def test_create_convolver_returns_none_if_no_resolution_components(self, analysis1d): # WHEN analysis1d.instrument_model.resolution_model.clear_components() @@ -571,14 +553,14 @@ def test_create_convolver_returns_none_if_no_sample_components(self, analysis1d) ############# @pytest.mark.parametrize( - "background", + 'background', [ None, np.array([0.5, 0.5, 0.5]), ], ids=[ - "No background", - "With background", + 'No background', + 'With background', ], ) def test_create_component_scipp_array(self, analysis1d, background): @@ -590,23 +572,17 @@ def test_create_component_scipp_array(self, analysis1d, background): # WHEN # Mock the functions that will be called. - analysis1d._evaluate_sample_component = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) + analysis1d._evaluate_sample_component = MagicMock(return_value=np.array([1.0, 2.0, 3.0])) analysis1d._to_scipp_array = MagicMock() component = object() # THEN - analysis1d._create_component_scipp_array( - component=component, background=background - ) + analysis1d._create_component_scipp_array(component=component, background=background) # EXPECT - analysis1d._evaluate_sample_component.assert_called_once_with( - component=component - ) + analysis1d._evaluate_sample_component.assert_called_once_with(component=component) expected_values = np.array([1.0, 2.0, 3.0]) if background is not None: @@ -618,7 +594,7 @@ def test_create_component_scipp_array(self, analysis1d, background): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs["values"], + kwargs['values'], expected_values, ) @@ -641,9 +617,7 @@ def test_create_background_component_scipp_array(self, analysis1d): analysis1d._create_background_component_scipp_array(component=component) # EXPECT - analysis1d._evaluate_background_component.assert_called_once_with( - component=component - ) + analysis1d._evaluate_background_component.assert_called_once_with(component=component) analysis1d._to_scipp_array.assert_called_once() @@ -651,7 +625,7 @@ def test_create_background_component_scipp_array(self, analysis1d): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs["values"], + kwargs['values'], np.array([1.0, 2.0, 3.0]), ) @@ -678,14 +652,14 @@ def test_create_sample_scipp_array(self, analysis1d): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs["values"], + kwargs['values'], np.array([1.0, 2.0, 3.0]), ) @pytest.mark.parametrize( - "add_background", + 'add_background', [True, False], - ids=["With background", "Without background"], + ids=['With background', 'Without background'], ) def test_create_components_dataset_single_Q( self, @@ -704,7 +678,7 @@ def test_create_components_dataset_single_Q( # ---- Sample component ---- sample_component = MagicMock() - sample_component.display_name = "sample_comp" + sample_component.display_name = 'sample_comp' sample_collection = MagicMock() sample_collection.components = [sample_component] @@ -715,13 +689,13 @@ def test_create_components_dataset_single_Q( # ---- Background component ---- background_component = MagicMock() - background_component.display_name = "background_comp" + background_component.display_name = 'background_comp' background_collection = MagicMock() background_collection.components = [background_component] - analysis1d.instrument_model.background_model.get_component_collection = ( - MagicMock(return_value=background_collection) + analysis1d.instrument_model.background_model.get_component_collection = MagicMock( + return_value=background_collection ) # ---- Background evaluation ---- @@ -729,26 +703,18 @@ def test_create_components_dataset_single_Q( analysis1d._evaluate_background = MagicMock(return_value=background_value) # ---- Return scipp DataArrays ---- - fake_sample_da = sc.DataArray( - data=sc.array(dims=["energy"], values=[1.0, 2.0, 3.0]) - ) + fake_sample_da = sc.DataArray(data=sc.array(dims=['energy'], values=[1.0, 2.0, 3.0])) - analysis1d._create_component_scipp_array = MagicMock( - return_value=fake_sample_da - ) + analysis1d._create_component_scipp_array = MagicMock(return_value=fake_sample_da) - fake_background_da = sc.DataArray( - data=sc.array(dims=["energy"], values=[4.0, 5.0, 6.0]) - ) + fake_background_da = sc.DataArray(data=sc.array(dims=['energy'], values=[4.0, 5.0, 6.0])) analysis1d._create_background_component_scipp_array = MagicMock( return_value=fake_background_da ) # THEN - dataset = analysis1d._create_components_dataset_single_Q( - add_background=add_background - ) + dataset = analysis1d._create_components_dataset_single_Q(add_background=add_background) # EXPECT @@ -776,13 +742,13 @@ def test_create_components_dataset_single_Q( analysis1d._create_component_scipp_array.assert_called_once() _, kwargs = analysis1d._create_component_scipp_array.call_args - assert kwargs["component"] is sample_component + assert kwargs['component'] is sample_component if expected_background is None: - assert kwargs["background"] is None + assert kwargs['background'] is None else: np.testing.assert_array_equal( - kwargs["background"], + kwargs['background'], expected_background, ) @@ -793,8 +759,8 @@ def test_create_components_dataset_single_Q( # Dataset content assert isinstance(dataset, sc.Dataset) - assert "sample_comp" in dataset - assert "background_comp" in dataset + assert 'sample_comp' in dataset + assert 'background_comp' in dataset def test_to_scipp_array(self, analysis1d): # WHEN @@ -808,10 +774,10 @@ def test_to_scipp_array(self, analysis1d): np.testing.assert_array_equal(scipp_array.values, numpy_array) np.testing.assert_array_equal( - scipp_array.coords["energy"].values, analysis1d.experiment.energy.values + scipp_array.coords['energy'].values, analysis1d.experiment.energy.values ) np.testing.assert_array_equal( - scipp_array.coords["Q"].values, + scipp_array.coords['Q'].values, analysis1d.experiment.Q[analysis1d.Q_index].values, ) diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py index 13b667cf..33987fce 100644 --- a/tests/unit/easydynamics/analysis/test_analysis_base.py +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -21,7 +21,7 @@ def analysis_base(self): sample_model = SampleModel() instrument_model = InstrumentModel() analysis_base = AnalysisBase( - display_name="TestAnalysis", + display_name='TestAnalysis', experiment=experiment, sample_model=sample_model, instrument_model=instrument_model, @@ -32,67 +32,65 @@ def test_init(self, analysis_base): # WHEN THEN # EXPECT - assert analysis_base.display_name == "TestAnalysis" + assert analysis_base.display_name == 'TestAnalysis' assert isinstance(analysis_base._experiment, Experiment) assert isinstance(analysis_base._sample_model, SampleModel) assert isinstance(analysis_base._instrument_model, InstrumentModel) assert analysis_base._extra_parameters == [] def test_init_extra_parameter(self): - extra_parameter = Parameter(name="param1", value=1.0) + extra_parameter = Parameter(name='param1', value=1.0) analysis = AnalysisBase(extra_parameters=extra_parameter) assert analysis._extra_parameters == [extra_parameter] def test_init_extra_parameters(self): extra_parameters = [ - Parameter(name="param1", value=1.0), - Parameter(name="param2", value=2.0), + Parameter(name='param1', value=1.0), + Parameter(name='param2', value=2.0), ] analysis = AnalysisBase(extra_parameters=extra_parameters) assert analysis._extra_parameters == extra_parameters def test_init_calls_on_experiment_changed(self): - with patch.object( - AnalysisBase, "_on_experiment_changed" - ) as mock_on_experiment_changed: + with patch.object(AnalysisBase, '_on_experiment_changed') as mock_on_experiment_changed: AnalysisBase() mock_on_experiment_changed.assert_called_once() @pytest.mark.parametrize( - "kwargs, expected_exception, expected_message", + 'kwargs, expected_exception, expected_message', [ ( - {"experiment": 123}, + {'experiment': 123}, TypeError, - "experiment must be an instance of Experiment", + 'experiment must be an instance of Experiment', ), ( - {"sample_model": "not a model"}, + {'sample_model': 'not a model'}, TypeError, - "sample_model must be an instance of SampleModel", + 'sample_model must be an instance of SampleModel', ), ( - {"instrument_model": "not a model"}, + {'instrument_model': 'not a model'}, TypeError, - "instrument_model must be an instance of InstrumentModel", + 'instrument_model must be an instance of InstrumentModel', ), ( - {"extra_parameters": 123}, + {'extra_parameters': 123}, TypeError, - "extra_parameters must be a Parameter or a list of Parameters.", + 'extra_parameters must be a Parameter or a list of Parameters.', ), ( - {"extra_parameters": [123]}, + {'extra_parameters': [123]}, TypeError, - "extra_parameters must be a Parameter or a list of Parameters.", + 'extra_parameters must be a Parameter or a list of Parameters.', ), ], ids=[ - "invalid experiment", - "invalid sample_model", - "invalid instrument_model", - "invalid extra_parameters", - "invalid extra_parameters list", + 'invalid experiment', + 'invalid sample_model', + 'invalid instrument_model', + 'invalid extra_parameters', + 'invalid extra_parameters list', ], ) def test_init_invalid_inputs(self, kwargs, expected_exception, expected_message): @@ -100,18 +98,14 @@ def test_init_invalid_inputs(self, kwargs, expected_exception, expected_message) AnalysisBase(**kwargs) def test_experiment_setter_calls_on_experiment_changed(self, analysis_base): - with patch.object( - analysis_base, "_on_experiment_changed" - ) as mock_on_experiment_changed: + with patch.object(analysis_base, '_on_experiment_changed') as mock_on_experiment_changed: new_experiment = Experiment() analysis_base.experiment = new_experiment mock_on_experiment_changed.assert_called_once() def test_experiment_setter_invalid_type(self, analysis_base): - with pytest.raises( - TypeError, match="experiment must be an instance of Experiment" - ): - analysis_base.experiment = "not an experiment" + with pytest.raises(TypeError, match='experiment must be an instance of Experiment'): + analysis_base.experiment = 'not an experiment' def test_experiment_setter_valid(self, analysis_base): new_experiment = Experiment() @@ -119,10 +113,8 @@ def test_experiment_setter_valid(self, analysis_base): assert analysis_base.experiment == new_experiment def test_sample_model_setter_invalid_type(self, analysis_base): - with pytest.raises( - TypeError, match="sample_model must be an instance of SampleModel" - ): - analysis_base.sample_model = "not a sample model" + with pytest.raises(TypeError, match='sample_model must be an instance of SampleModel'): + analysis_base.sample_model = 'not a sample model' def test_sample_model_setter_valid(self, analysis_base): new_sample_model = SampleModel() @@ -131,7 +123,7 @@ def test_sample_model_setter_valid(self, analysis_base): def test_sample_model_setter_calls_on_sample_model_changed(self, analysis_base): with patch.object( - analysis_base, "_on_sample_model_changed" + analysis_base, '_on_sample_model_changed' ) as mock_on_sample_model_changed: new_sample_model = SampleModel() analysis_base.sample_model = new_sample_model @@ -139,20 +131,18 @@ def test_sample_model_setter_calls_on_sample_model_changed(self, analysis_base): def test_instrument_model_setter_invalid_type(self, analysis_base): with pytest.raises( - TypeError, match="instrument_model must be an instance of InstrumentModel" + TypeError, match='instrument_model must be an instance of InstrumentModel' ): - analysis_base.instrument_model = "not an instrument model" + analysis_base.instrument_model = 'not an instrument model' def test_instrument_model_setter_valid(self, analysis_base): new_instrument_model = InstrumentModel() analysis_base.instrument_model = new_instrument_model assert analysis_base.instrument_model == new_instrument_model - def test_instrument_model_setter_calls_on_instrument_model_changed( - self, analysis_base - ): + def test_instrument_model_setter_calls_on_instrument_model_changed(self, analysis_base): with patch.object( - analysis_base, "_on_instrument_model_changed" + analysis_base, '_on_instrument_model_changed' ) as mock_on_instrument_model_changed: new_instrument_model = InstrumentModel() analysis_base.instrument_model = new_instrument_model @@ -164,7 +154,7 @@ def test_Q_property(self, analysis_base): # Patch the 'experiment' attribute's Q property with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock + type(analysis_base.experiment), 'Q', new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q result = analysis_base.Q # Access the property @@ -174,7 +164,7 @@ def test_Q_property(self, analysis_base): def test_Q_setter_raises(self, analysis_base): with pytest.raises( AttributeError, - match="Q is a read-only property derived from the Experiment.", + match='Q is a read-only property derived from the Experiment.', ): analysis_base.Q = [1, 2, 3] @@ -184,7 +174,7 @@ def test_energy_property(self, analysis_base): # Patch the 'experiment' attribute's energy property with patch.object( - type(analysis_base.experiment), "energy", new_callable=PropertyMock + type(analysis_base.experiment), 'energy', new_callable=PropertyMock ) as mock_energy: mock_energy.return_value = fake_energy result = analysis_base.energy # Access the property @@ -194,7 +184,7 @@ def test_energy_property(self, analysis_base): def test_energy_setter_raises(self, analysis_base): with pytest.raises( AttributeError, - match="energy is a read-only property derived from the Experiment.", + match='energy is a read-only property derived from the Experiment.', ): analysis_base.energy = [10, 20, 30] @@ -202,7 +192,7 @@ def test_temperature_property_no_temperature(self, analysis_base): # Patch the 'experiment' attribute's temperature property to # return None with patch.object( - type(analysis_base.sample_model), "temperature", new_callable=PropertyMock + type(analysis_base.sample_model), 'temperature', new_callable=PropertyMock ) as mock_temperature: mock_temperature.return_value = None result = analysis_base.temperature # Access the property @@ -215,7 +205,7 @@ def test_temperature_property(self, analysis_base): # Patch the 'sample_model' attribute's temperature property with patch.object( - type(analysis_base.sample_model), "temperature", new_callable=PropertyMock + type(analysis_base.sample_model), 'temperature', new_callable=PropertyMock ) as mock_temperature: mock_temperature.return_value = fake_temperature result = analysis_base.temperature # Access the property @@ -225,22 +215,22 @@ def test_temperature_property(self, analysis_base): def test_temperature_setter_raises(self, analysis_base): with pytest.raises( AttributeError, - match="temperature is a read-only property", + match='temperature is a read-only property', ): analysis_base.temperature = 300 @pytest.mark.parametrize( - "extra_parameters", + 'extra_parameters', [ - Parameter(name="param1", value=1.0), + Parameter(name='param1', value=1.0), [ - Parameter(name="param1", value=1.0), - Parameter(name="param2", value=2.0), + Parameter(name='param1', value=1.0), + Parameter(name='param2', value=2.0), ], ], ids=[ - "single parameter", - "list of parameters", + 'single parameter', + 'list of parameters', ], ) def test_extra_parameters_property(self, analysis_base, extra_parameters): @@ -252,30 +242,26 @@ def test_extra_parameters_property(self, analysis_base, extra_parameters): # EXPECT expected = ( - [extra_parameters] - if isinstance(extra_parameters, Parameter) - else extra_parameters + [extra_parameters] if isinstance(extra_parameters, Parameter) else extra_parameters ) assert analysis_base.extra_parameters == expected @pytest.mark.parametrize( - "invalid_extra_parameters", + 'invalid_extra_parameters', [ - "not a parameter", - [Parameter(name="param1", value=1.0), "not a parameter"], + 'not a parameter', + [Parameter(name='param1', value=1.0), 'not a parameter'], ], ids=[ - "single invalid parameter", - "list with invalid parameter", + 'single invalid parameter', + 'list with invalid parameter', ], ) - def test_extra_parameters_setter_invalid_type( - self, analysis_base, invalid_extra_parameters - ): + def test_extra_parameters_setter_invalid_type(self, analysis_base, invalid_extra_parameters): with pytest.raises( TypeError, - match="extra_parameters must be", + match='extra_parameters must be', ): analysis_base.extra_parameters = invalid_extra_parameters @@ -285,7 +271,7 @@ def test_on_experiment_changed_updates_Q(self, analysis_base): # Patch the Q property of analysis_base with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock + type(analysis_base.experiment), 'Q', new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q @@ -304,7 +290,7 @@ def test_on_sample_model_changed_updates_Q(self, analysis_base): # Patch the Q property of analysis_base with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock + type(analysis_base.experiment), 'Q', new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q @@ -319,7 +305,7 @@ def test_on_instrument_model_changed_updates_Q(self, analysis_base): # Patch the Q property of analysis_base with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock + type(analysis_base.experiment), 'Q', new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q @@ -341,7 +327,7 @@ def test_verify_Q_index_invalid(self, analysis_base): invalid_Q_index = -1 # THEN / EXPECT - with pytest.raises(IndexError, match="Q_index must be a valid index"): + with pytest.raises(IndexError, match='Q_index must be a valid index'): analysis_base._verify_Q_index(invalid_Q_index) def test_repr(self, analysis_base): @@ -349,6 +335,6 @@ def test_repr(self, analysis_base): repr_str = repr(analysis_base) # THEN EXPECT - assert "AnalysisBase" in repr_str - assert "display_name=TestAnalysis" in repr_str - assert "unique_name=" in repr_str + assert 'AnalysisBase' in repr_str + assert 'display_name=TestAnalysis' in repr_str + assert 'unique_name=' in repr_str diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index e2837233..6b499481 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -15,12 +15,29 @@ class TestExperiment: @pytest.fixture def experiment(self): - Q = sc.linspace("Q", 0.5, 1.5, num=10, unit="1/Angstrom") - energy = sc.linspace("energy", -5, 5, num=11, unit="meV") - values = sc.array(dims=["Q", "energy"], values=np.ones((10, 11))) - data = sc.DataArray(data=values, coords={"Q": Q, "energy": energy}) + Q = sc.linspace('Q', 0.5, 1.5, num=10, unit='1/Angstrom') + energy = sc.linspace('energy', -5, 5, num=11, unit='meV') + values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))) + data = sc.DataArray(data=values, coords={'Q': Q, 'energy': energy}) + + experiment = Experiment(display_name='test_experiment', data=data) + return experiment + + @pytest.fixture + def experiment_with_data(self): + "Fixture that provides an Experiment with data for testing methods that require data" + Q = sc.array(dims=['Q'], values=[1, 2, 3], unit='1/Angstrom') + energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') + data = sc.array( + dims=['Q', 'energy'], + values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], + variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], + ) + + data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) + + experiment = Experiment(data=data_array) - experiment = Experiment(display_name="test_experiment", data=data) return experiment ############## @@ -30,51 +47,51 @@ def experiment(self): def test_init_array(self, experiment): "Test initialization with a Scipp DataArray" # WHEN THEN EXPECT - assert experiment.display_name == "test_experiment" + assert experiment.display_name == 'test_experiment' assert isinstance(experiment._data, sc.DataArray) - assert "Q" in experiment._data.dims - assert "energy" in experiment._data.dims - assert experiment._data.sizes["Q"] == 10 - assert experiment._data.sizes["energy"] == 11 + assert 'Q' in experiment._data.dims + assert 'energy' in experiment._data.dims + assert experiment._data.sizes['Q'] == 10 + assert experiment._data.sizes['energy'] == 11 assert sc.identical( experiment._data.data, - sc.array(dims=["Q", "energy"], values=np.ones((10, 11))), + sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))), ) def test_init_string(self, tmp_path): "Test initialization with a filename string," - "should load the file" + 'should load the file' # WHEN - Q = sc.linspace("Q", 0.5, 1.5, num=10, unit="1/Angstrom") - energy = sc.linspace("energy", -5, 5, num=11, unit="meV") - values = sc.array(dims=["Q", "energy"], values=np.ones((10, 11))) - data = sc.DataArray(data=values, coords={"Q": Q, "energy": energy}) + Q = sc.linspace('Q', 0.5, 1.5, num=10, unit='1/Angstrom') + energy = sc.linspace('energy', -5, 5, num=11, unit='meV') + values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))) + data = sc.DataArray(data=values, coords={'Q': Q, 'energy': energy}) - filename = tmp_path / "test_experiment.h5" + filename = tmp_path / 'test_experiment.h5' sc.io.save_hdf5(data, filename) # THEN - experiment = Experiment(display_name="loaded_experiment", data=str(filename)) + experiment = Experiment(display_name='loaded_experiment', data=str(filename)) # EXPECT - assert experiment.display_name == "loaded_experiment" + assert experiment.display_name == 'loaded_experiment' assert isinstance(experiment._data, sc.DataArray) - assert "Q" in experiment._data.dims - assert "energy" in experiment._data.dims - assert experiment._data.sizes["Q"] == 10 - assert experiment._data.sizes["energy"] == 11 + assert 'Q' in experiment._data.dims + assert 'energy' in experiment._data.dims + assert experiment._data.sizes['Q'] == 10 + assert experiment._data.sizes['energy'] == 11 assert sc.identical( experiment._data.data, - sc.array(dims=["Q", "energy"], values=np.ones((10, 11))), + sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))), ) def test_init_no_data(self): "Test initialization with no data" # WHEN - experiment = Experiment(display_name="empty_experiment") + experiment = Experiment(display_name='empty_experiment') # THEN EXPECT - assert experiment.display_name == "empty_experiment" + assert experiment.display_name == 'empty_experiment' assert experiment._data is None assert experiment.energy is None assert experiment.Q is None @@ -91,34 +108,34 @@ def test_init_invalid_data(self): def test_load_hdf5(self, tmp_path, experiment): "Test loading data from an HDF5 file." - "First use scipp to save data to a file, " - "then load it using the method." + 'First use scipp to save data to a file, ' + 'then load it using the method.' # WHEN # First create a file to load from - filename = tmp_path / "test.h5" + filename = tmp_path / 'test.h5' data_to_save = experiment.data sc.io.save_hdf5(data_to_save, filename) # THEN - new_experiment = Experiment(display_name="new_experiment") - new_experiment.load_hdf5(str(filename), display_name="loaded_data") + new_experiment = Experiment(display_name='new_experiment') + new_experiment.load_hdf5(str(filename), display_name='loaded_data') loaded_data = new_experiment.data # EXPECT assert sc.identical(data_to_save, loaded_data) - assert new_experiment.display_name == "loaded_data" + assert new_experiment.display_name == 'loaded_data' def test_load_hdf5_invalid_name_raises(self, experiment): "Test loading data from an HDF5 file," - "giving the Experiment an invalid name" + 'giving the Experiment an invalid name' # WHEN / THEN EXPECT with pytest.raises(TypeError): - experiment.load_hdf5("some_file.h5", display_name=123) + experiment.load_hdf5('some_file.h5', display_name=123) def test_load_hdf5_invalid_filename_raises(self, experiment): "Test loading data from an HDF5 file with an invalid filename" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match="must be a string"): + with pytest.raises(TypeError, match='must be a string'): experiment.load_hdf5(123) def test_load_hdf5_invalid_file_raises(self, experiment): @@ -126,13 +143,13 @@ def test_load_hdf5_invalid_file_raises(self, experiment): # WHEN / THEN EXPECT with pytest.raises(OSError): - experiment.load_hdf5("non_existent_file.h5") + experiment.load_hdf5('non_existent_file.h5') def test_save_hdf5(self, tmp_path, experiment): "Test saving data to an HDF5 file. Load the saved file" - "using scipp and compare to the original data." + 'using scipp and compare to the original data.' # WHEN THEN - filename = tmp_path / "saved_data.h5" + filename = tmp_path / 'saved_data.h5' experiment.save_hdf5(str(filename)) # EXPECT @@ -149,25 +166,25 @@ def test_save_hdf5_default_filename(self, tmp_path, experiment, monkeypatch): experiment.save_hdf5() # EXPECT - expected_filename = tmp_path / f"{experiment.unique_name}.h5" + expected_filename = tmp_path / f'{experiment.unique_name}.h5' loaded_data = sc.io.load_hdf5(str(expected_filename)) original_data = experiment.data assert sc.identical(original_data, loaded_data) def test_save_hdf5_no_data_raises(self): "Test saving data to an HDF5 file when no data is present" - "in the experiment" + 'in the experiment' # WHEN experiment = Experiment() # THEN EXPECT with pytest.raises(ValueError): - experiment.save_hdf5("should_fail.h5") + experiment.save_hdf5('should_fail.h5') def test_save_hdf5_invalid_filename_raises(self, experiment): "Test saving data to an HDF5 file with an invalid filename" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match="must be a string"): + with pytest.raises(TypeError, match='must be a string'): experiment.save_hdf5(123) def test_remove_data(self, experiment): @@ -179,11 +196,11 @@ def test_remove_data(self, experiment): assert experiment._data is None @pytest.mark.parametrize( - "new_Q_bins, new_energy_bins", + 'new_Q_bins, new_energy_bins', [ ( - sc.linspace("Q", 0.5, 1.5, num=7, unit="1/Angstrom"), - sc.linspace("energy", -5, 5, num=8, unit="meV"), + sc.linspace('Q', 0.5, 1.5, num=7, unit='1/Angstrom'), + sc.linspace('energy', -5, 5, num=8, unit='meV'), ), ( 6, @@ -194,23 +211,23 @@ def test_remove_data(self, experiment): 7.0, ), ( - sc.linspace("Q", 0.5, 1.5, num=7, unit="1/Angstrom"), + sc.linspace('Q', 0.5, 1.5, num=7, unit='1/Angstrom'), 7, ), ], - ids=["sc_bins", "integers_bins", "float_bins", "mixed_bins"], + ids=['sc_bins', 'integers_bins', 'float_bins', 'mixed_bins'], ) def test_rebin(self, experiment, new_Q_bins, new_energy_bins): "Test rebinning data in the experiment" # WHEN # THEN - experiment.rebin({"Q": new_Q_bins, "energy": new_energy_bins}) + experiment.rebin({'Q': new_Q_bins, 'energy': new_energy_bins}) # EXPECT rebinned_data = experiment.binned_data - assert rebinned_data.sizes["Q"] == 6 - assert rebinned_data.sizes["energy"] == 7 + assert rebinned_data.sizes['Q'] == 6 + assert rebinned_data.sizes['energy'] == 7 def test_rebin_no_data_raises(self): "Test rebinning data when no data is present" @@ -219,34 +236,34 @@ def test_rebin_no_data_raises(self): # THEN EXPECT with pytest.raises(ValueError): - experiment.rebin({"Q": 6, "energy": 7}) + experiment.rebin({'Q': 6, 'energy': 7}) def test_rebin_invalid_dimensions_raises(self, experiment): "Test rebinning data with invalid dimensions" # WHEN / THEN EXPECT with pytest.raises(TypeError): - experiment.rebin("invalid_dimensions") + experiment.rebin('invalid_dimensions') def test_rebin_invalid_dimension_name_raises(self, experiment): "Test rebinning data with invalid dimension name" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match="Dimension keys must be strings"): - experiment.rebin({123: 6, "energy": 7}) + with pytest.raises(TypeError, match='Dimension keys must be strings'): + experiment.rebin({123: 6, 'energy': 7}) def test_rebin_dimension_not_in_data_raises(self, experiment): "Test rebinning data with a dimension not in the data" # WHEN / THEN EXPECT with pytest.raises(KeyError, match="Dimension 'time' not a valid"): - experiment.rebin({"time": 6, "energy": 7}) + experiment.rebin({'time': 6, 'energy': 7}) def test_rebin_invalid_bin_values_raises(self, experiment): "Test rebinning data with invalid bin values" # WHEN / THEN EXPECT with pytest.raises( TypeError, - match="Dimension values must be integers or", + match='Dimension values must be integers or', ): - experiment.rebin({"Q": [0.5, 1.0, 1.5], "energy": 7}) + experiment.rebin({'Q': [0.5, 1.0, 1.5], 'energy': 7}) ############## # test setters and getters @@ -284,8 +301,8 @@ def test_plot_data_success(self, experiment): "Test plotting data successfully when in notebook environment" # WHEN with ( - patch(f"{Experiment.__module__}._in_notebook", return_value=True), - patch("plopp.plot") as mock_plot, + patch(f'{Experiment.__module__}._in_notebook', return_value=True), + patch('plopp.plot') as mock_plot, ): mock_fig = MagicMock() mock_plot.return_value = mock_fig @@ -297,7 +314,7 @@ def test_plot_data_success(self, experiment): mock_plot.assert_called_once() args, kwargs = mock_plot.call_args assert sc.identical(args[0], experiment._data.transpose()) - assert kwargs["title"] == f"{experiment.display_name}" + assert kwargs['title'] == f'{experiment.display_name}' assert result == mock_fig def test_plot_data_no_data_raises(self): @@ -306,18 +323,18 @@ def test_plot_data_no_data_raises(self): experiment = Experiment() # THEN EXPECT - with pytest.raises(ValueError, match="No data to plot"): + 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" + 'when not in notebook environment' # WHEN - with patch(f"{Experiment.__module__}._in_notebook", return_value=False): + with patch(f'{Experiment.__module__}._in_notebook', return_value=False): # THEN EXPECT with pytest.raises( RuntimeError, - match="plot_data\\(\\) can only be used in a Jupyter notebook environment", + match='plot_data\\(\\) can only be used in a Jupyter notebook environment', ): experiment.plot_data() @@ -332,42 +349,40 @@ def test_validate_coordinates(self, experiment): def test_validate_coordinates_raises_missing_Q(self, experiment): "Test that _validate_coordinates raises ValueError when Q coord" - "is missing" + 'is missing' # WHEN invalid_data = experiment._data.copy() - invalid_data.coords.pop("Q") + invalid_data.coords.pop('Q') # THEN EXPECT - with pytest.raises(ValueError, match="missing required coordinate"): + with pytest.raises(ValueError, match='missing required coordinate'): experiment._validate_coordinates(invalid_data) def test_validate_coordinates_raises_missing_energy(self, experiment): "Test that _validate_coordinates raises ValueError when energy" - "coord is missing" + 'coord is missing' # WHEN invalid_data = experiment._data.copy() - invalid_data.coords.pop("energy") + invalid_data.coords.pop('energy') # THEN EXPECT - with pytest.raises(ValueError, match="missing required coordinate"): + with pytest.raises(ValueError, match='missing required coordinate'): experiment._validate_coordinates(invalid_data) def test_validate_coordinates_raises_not_DataArray(self): "Test that _validate_coordinates raises TypeError when data is" - "not a Scipp DataArray" + 'not a Scipp DataArray' # WHEN THEN EXPECT - with pytest.raises(TypeError, match="must be a"): - Experiment()._validate_coordinates("not_a_data_array") + with pytest.raises(TypeError, match='must be a'): + Experiment()._validate_coordinates('not_a_data_array') def test_convert_to_bin_centers(self, experiment): "Test that _convert_to_bin_centers converts edges to centers" # WHEN - Q_edges = sc.linspace("Q", 0.0, 2.0, num=11, unit="1/Angstrom") - energy_edges = sc.linspace("energy", -6, 6, num=13, unit="meV") - values = sc.array(dims=["Q", "energy"], values=np.ones((10, 12))) - binned_data = sc.DataArray( - data=values, coords={"Q": Q_edges, "energy": energy_edges} - ) + Q_edges = sc.linspace('Q', 0.0, 2.0, num=11, unit='1/Angstrom') + energy_edges = sc.linspace('energy', -6, 6, num=13, unit='meV') + values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 12))) + binned_data = sc.DataArray(data=values, coords={'Q': Q_edges, 'energy': energy_edges}) # THEN experiment._data = binned_data # Set data to avoid warnings @@ -377,36 +392,56 @@ def test_convert_to_bin_centers(self, experiment): expected_Q = 0.5 * (Q_edges[:-1] + Q_edges[1:]) expected_energy = 0.5 * (energy_edges[:-1] + energy_edges[1:]) - assert sc.identical(converted_data.coords["Q"], expected_Q) - assert sc.identical(converted_data.coords["energy"], expected_energy) + assert sc.identical(converted_data.coords['Q'], expected_Q) + assert sc.identical(converted_data.coords['energy'], expected_energy) assert sc.identical(converted_data.data, binned_data.data) - def test_extract_x_y_weights(self): + def test_extract_x_y_e(self, experiment_with_data): # WHEN - Q = sc.array(dims=["Q"], values=[1, 2, 3], unit="1/Angstrom") - energy = sc.array(dims=["energy"], values=[10.0, 20.0, 30.0], unit="meV") - data = sc.array( - dims=["Q", "energy"], - values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], - variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], + + Q_index = 0 + + # THEN + x, y, e = experiment_with_data._extract_x_y_e(Q_index=Q_index) + + # EXPECT + assert np.array_equal(x, experiment_with_data.energy.values) + assert np.array_equal(y, experiment_with_data.data.values[Q_index]) + assert np.array_equal( + e, + experiment_with_data.data.variances[Q_index] ** 0.5, ) - data_array = sc.DataArray(data=data, coords={"Q": Q, "energy": energy}) + def test_extract_x_y_weights_only_finite_zero_variances(self, experiment_with_data): + "Test that _extract_x_y_weights_only_finite raises ValueError when variances contain zeros" + # WHEN + Q_index = 0 + invalid_data = experiment_with_data._data.copy() + invalid_data.data.variances[Q_index] = 0 # Set variances to zero - experiment = Experiment(data=data_array) + # THEN EXPECT + with pytest.raises(ValueError, match='Cannot compute weights: some variances are zero'): + Experiment(data=invalid_data)._extract_x_y_weights_only_finite(Q_index=Q_index) + def test_extract_x_y_weights_only_finite(self, experiment_with_data): + "Test that _extract_x_y_weights_only_finite only returns finite values" + # WHEN Q_index = 0 + invalid_data = experiment_with_data._data.copy() + invalid_data.data.values[Q_index][0] = np.inf + invalid_data.data.variances[Q_index][1] = np.nan # THEN - x, y, weights = experiment._extract_x_y_weights(Q_index=Q_index) + x, y, weights = Experiment(data=invalid_data)._extract_x_y_weights_only_finite( + Q_index=Q_index + ) # EXPECT - assert np.array_equal(x, experiment.energy.values) - assert np.array_equal(y, experiment.data.values[Q_index]) - assert np.array_equal( - weights, - 1 / experiment.data.variances[Q_index] ** 0.5, - ) + assert np.isfinite(x).all() + assert np.isfinite(y).all() + assert np.isfinite(weights).all() + assert weights[0] == 1.0 / (experiment_with_data.data.variances[Q_index][2] ** 0.5) + assert len(x) == len(y) == len(weights) == 1 # 2 values should be removed ############## # test dunder methods @@ -417,15 +452,12 @@ def test_repr(self, experiment): repr_str = repr(experiment) # THEN EXPECT - assert ( - repr_str - == f"Experiment `{experiment.unique_name}` with data: {experiment._data}" - ) + assert repr_str == f'Experiment `{experiment.unique_name}` with data: {experiment._data}' def test_copy_experiment(self, experiment): "Test copying an Experiment object." - "The copied object should have the same attributes " - "but be a different object in memory." + 'The copied object should have the same attributes ' + 'but be a different object in memory.' # WHEN copied_experiment = copy(experiment) From 50314935565badeaeead29cc72537add6c68afb4 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Mon, 9 Mar 2026 22:33:51 +0100 Subject: [PATCH 3/5] Update slilcer to have the right x axis --- docs/docs/tutorials/analysis.ipynb | 9 +++++---- src/easydynamics/analysis/analysis.py | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 0ea7d90a..f1c7b2de 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -16,7 +16,7 @@ { "cell_type": "code", "execution_count": null, - "id": "bca91d3c", + "id": "f2f898dc", "metadata": {}, "outputs": [], "source": [ @@ -188,6 +188,7 @@ "source": [ "# Now we set up the model, similarly to how we set up the model for the\n", "# vanadium data.\n", + "\n", "delta_function = DeltaFunction(display_name='DeltaFunction', area=0.2)\n", "lorentzian = Lorentzian(display_name='Lorentzian', area=0.5, width=0.3)\n", "component_collection = ComponentCollection(\n", @@ -342,9 +343,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python (Pixi)", + "display_name": "easydynamics_newbase", "language": "python", - "name": "pixi-kernel-python3" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -356,7 +357,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.13" + "version": "3.12.12" } }, "nbformat": 4, diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 33a5d948..ec48210c 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -288,6 +288,7 @@ def plot_data_and_model( 'marker': {'Data': 'o', 'Model': None}, 'color': {'Data': 'black', 'Model': 'red'}, 'markerfacecolor': {'Data': 'none', 'Model': 'none'}, + 'keep': 'energy', } data_and_model = { 'Data': self.experiment.binned_data, From 71aa2d19b822a369bcdb71d08a3de401e1629b23 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 10 Mar 2026 08:26:55 +0100 Subject: [PATCH 4/5] Update link to data files --- docs/docs/tutorials/analysis.ipynb | 5 +++-- docs/docs/tutorials/analysis1d.ipynb | 3 +-- docs/docs/tutorials/experiment.ipynb | 2 +- docs/docs/tutorials/tutorial1_brownian.ipynb | 6 ++++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index f1c7b2de..623fd942 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -49,7 +49,7 @@ "# Load the vanadium data\n", "vanadium_experiment = Experiment('Vanadium')\n", "file_path = pooch.retrieve(\n", - " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/docs/docs/docs/tutorials/data/vanadium_data_example.h5',\n", + " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/vanadium_data_example.h5',\n", " known_hash='16cc1b327c303feeb88fb9dda5390dc4880b62396b1793f98c6fef0b27c7b873',\n", ")\n", "\n", @@ -173,7 +173,8 @@ "diffusion_experiment = Experiment('Diffusion')\n", "\n", "file_path = pooch.retrieve(\n", - " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/docs/docs/docs/tutorials/data/diffusion_data_example.h5',\n", + " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/diffusion_data_example.h5',\n", + " known_hash='5fe846b19aacbda8b8b936eb2e5310d025dc56c25b0b353521e7d6b921f229ab',\n", ")\n", "\n", "diffusion_experiment.load_hdf5(filename=file_path)" diff --git a/docs/docs/tutorials/analysis1d.ipynb b/docs/docs/tutorials/analysis1d.ipynb index 8230118a..55edc76f 100644 --- a/docs/docs/tutorials/analysis1d.ipynb +++ b/docs/docs/tutorials/analysis1d.ipynb @@ -41,11 +41,10 @@ "vanadium_experiment = Experiment('Vanadium')\n", "\n", "file_path = pooch.retrieve(\n", - " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/docs/docs/docs/tutorials/data/vanadium_data_example.h5',\n", + " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/vanadium_data_example.h5',\n", " known_hash='16cc1b327c303feeb88fb9dda5390dc4880b62396b1793f98c6fef0b27c7b873',\n", ")\n", "\n", - "\n", "vanadium_experiment.load_hdf5(filename=file_path)" ] }, diff --git a/docs/docs/tutorials/experiment.ipynb b/docs/docs/tutorials/experiment.ipynb index 0c4572f4..f6056399 100644 --- a/docs/docs/tutorials/experiment.ipynb +++ b/docs/docs/tutorials/experiment.ipynb @@ -35,7 +35,7 @@ "vanadium_experiment = Experiment('Vanadium')\n", "\n", "file_path = pooch.retrieve(\n", - " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/docs/docs/docs/tutorials/data/vanadium_data_example.h5',\n", + " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/vanadium_data_example.h5',\n", " known_hash='16cc1b327c303feeb88fb9dda5390dc4880b62396b1793f98c6fef0b27c7b873',\n", ")\n", "\n", diff --git a/docs/docs/tutorials/tutorial1_brownian.ipynb b/docs/docs/tutorials/tutorial1_brownian.ipynb index 86725cfa..90689f0a 100644 --- a/docs/docs/tutorials/tutorial1_brownian.ipynb +++ b/docs/docs/tutorials/tutorial1_brownian.ipynb @@ -61,10 +61,11 @@ "vanadium_experiment = Experiment('Vanadium')\n", "\n", "file_path = pooch.retrieve(\n", - " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/docs/docs/docs/tutorials/data/vanadium_data_example.h5',\n", + " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/vanadium_data_example.h5',\n", " known_hash='16cc1b327c303feeb88fb9dda5390dc4880b62396b1793f98c6fef0b27c7b873',\n", ")\n", "\n", + "\n", "vanadium_experiment.load_hdf5(filename=file_path)" ] }, @@ -315,7 +316,8 @@ "diffusion_experiment = Experiment('Diffusion')\n", "\n", "file_path = pooch.retrieve(\n", - " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/docs/docs/docs/tutorials/data/diffusion_data_example.h5',\n", + " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/diffusion_data_example.h5',\n", + " known_hash='5fe846b19aacbda8b8b936eb2e5310d025dc56c25b0b353521e7d6b921f229ab',\n", ")\n", "\n", "diffusion_experiment.load_hdf5(filename=file_path)" From 78904776f75f19ee69f18654612511385e56f796 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 10 Mar 2026 11:09:34 +0100 Subject: [PATCH 5/5] Update test --- tests/unit/easydynamics/experiment/test_experiment.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index 6b499481..da0258a0 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -418,6 +418,8 @@ def test_extract_x_y_weights_only_finite_zero_variances(self, experiment_with_da Q_index = 0 invalid_data = experiment_with_data._data.copy() invalid_data.data.variances[Q_index] = 0 # Set variances to zero + # throw in a nan for good measure + invalid_data.data.variances[Q_index][0] = np.nan # THEN EXPECT with pytest.raises(ValueError, match='Cannot compute weights: some variances are zero'):