diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index 7255f88a..04a5bbdd 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -245,6 +245,7 @@ def create_component_collections( self._component_collections = [] return self._component_collections + Q = Q.values component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the # Lorentzians and the delta function. diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 0ee8861b..0f181481 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -469,6 +469,7 @@ def create_component_collections( Q = self.Q if Q is None: return [] + Q = Q.values if self._allow_Q_variation['A_0'] is True: A_0_list, A_1_list = self._create_A0_A1_parameter_lists(self.A_0) @@ -812,7 +813,7 @@ def _create_A0_A1_parameter_lists( """ A_0_list = [] A_1_list = [] - for _ in self.Q: + for _ in range(len(self.Q)): a0 = Parameter( name='A_0', value=float(A_0.value), @@ -858,7 +859,7 @@ def _create_lorentzian_width_parameter_list( min=MINIMUM_WIDTH, unit=self.unit, ) - for _ in self.Q + for _ in range(len(self.Q)) ] # ------------------------------------------------------------------ diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index de28beac..ab97f8b9 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -146,14 +146,14 @@ def scale(self, scale: Numeric) -> None: self._scale.value = float(scale) @property - def Q(self) -> np.ndarray | None: + def Q(self) -> sc.Variable | None: """ Get the Q values of the SampleModel. Returns ------- - np.ndarray | None - The Q values of the SampleModel, or None if not set. + sc.Variable | None + The Q values of the SampleModel in 1/angstrom, or None if not set. """ return self._Q @@ -185,7 +185,7 @@ def Q(self, value: Q_type | None) -> None: self._on_Q_change() return - if len(old_Q) != len(new_Q) or not np.allclose(old_Q, new_Q): + if len(old_Q) != len(new_Q) or not sc.allclose(old_Q, new_Q): raise ValueError( 'New Q values are not similar to the old ones. ' 'To change Q values, first run clear_Q().' @@ -483,7 +483,7 @@ def _ensure_Q(self, Q: Q_type) -> np.ndarray: if Q is None: raise ValueError('Q must be provided either as an argument or set in the model.') - return _validate_and_convert_Q(Q) + return _validate_and_convert_Q(Q).values # ------------------------------------------------------------------ # dunder methods diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index 0cd8a828..9dd288ca 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -311,6 +311,7 @@ def create_component_collections( self._component_collections = [] return self._component_collections + Q = Q.values component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the # Lorentzians and the delta function. diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index 4ff3b4f3..7c233351 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -3,7 +3,6 @@ from copy import copy -import numpy as np import scipp as sc from easyscience.base_classes.new_base import NewBase from easyscience.variable import Parameter @@ -217,14 +216,14 @@ def background_model(self, value: BackgroundModel) -> None: self._on_background_model_change() @property - def Q(self) -> np.ndarray | None: + def Q(self) -> sc.Variable | None: """ Get the Q values of the InstrumentModel. Returns ------- - np.ndarray | None - The Q values of the InstrumentModel, or None if not set. + sc.Variable | None + The Q values of the InstrumentModel in 1/angstrom, or None if not set. """ return self._Q @@ -257,7 +256,7 @@ def Q(self, value: Q_type | None) -> None: self._on_Q_change() return - if len(old_Q) != len(new_Q) or not np.allclose(old_Q, new_Q): + if len(old_Q) != len(new_Q) or not sc.allclose(old_Q, new_Q): raise ValueError( 'New Q values are not similar to the old ones. ' 'To change Q values, first run clear_Q().' @@ -532,7 +531,7 @@ def _generate_energy_offsets(self) -> None: self._energy_offsets = [] return - self._energy_offsets = [copy(self._energy_offset) for _ in self._Q] + self._energy_offsets = [copy(self._energy_offset) for _ in range(len(self._Q))] def _on_Q_change(self) -> None: """Handle changes to the Q values.""" diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 288d42be..12437b7a 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -187,14 +187,14 @@ def component_collections_is_dirty(self) -> bool: return self._component_collections_is_dirty @property - def Q(self) -> np.ndarray | None: + def Q(self) -> sc.Variable | None: """ Get the Q values of the SampleModel. Returns ------- - np.ndarray | None - The Q values of the SampleModel, or None if not set. + sc.Variable | None + The Q values of the SampleModel in 1/angstrom, or None if not set. """ return self._Q @@ -226,7 +226,7 @@ def Q(self, value: Q_type | None) -> None: self._on_Q_change() return - if len(old_Q) != len(new_Q) or not np.allclose(old_Q, new_Q): + if len(old_Q) != len(new_Q) or not sc.allclose(old_Q, new_Q): raise ValueError( 'New Q values are not similar to the old ones. ' 'To change Q values, first run clear_Q().' @@ -377,7 +377,7 @@ def _generate_component_collections(self) -> None: return self._component_collections = [] - for _ in self.Q: + for _ in range(len(self.Q)): self._component_collections.append(copy(self._components)) def _on_Q_change(self) -> None: @@ -405,6 +405,6 @@ def __repr__(self) -> str: f'{self.__class__.__name__}(' f'unique_name={self.unique_name!r}, ' f'unit={self.unit}, ' - f'Q={self.Q}, ' + f'Q={None if self.Q is None else self.Q.values}, ' f'components={self.components})' ) diff --git a/src/easydynamics/utils/utils.py b/src/easydynamics/utils/utils.py index 54c1b46a..06661df2 100644 --- a/src/easydynamics/utils/utils.py +++ b/src/easydynamics/utils/utils.py @@ -164,14 +164,17 @@ def energy_to_scipp(energy: np.ndarray, unit: str | sc.Unit) -> sc.Variable: def _validate_and_convert_Q( Q: np.ndarray | Numeric | list | ArrayLike | sc.Variable | None, -) -> np.ndarray | None: +) -> sc.Variable | None: """ - Validate and convert Q to a numpy array. + Validate and convert Q to a scipp Variable in 1/angstrom. + + Numbers, lists, and numpy arrays are assumed to be in 1/angstrom. Scipp Variables may be in any + unit convertible to 1/angstrom and are converted. Parameters ---------- Q : np.ndarray | Numeric | list | ArrayLike | sc.Variable | None - Scattering vector values in 1/angstrom. + Scattering vector values. Raises ------ @@ -183,8 +186,8 @@ def _validate_and_convert_Q( Returns ------- - np.ndarray | None - Q as a np.ndarray or None if Q is None. + sc.Variable | None + Q as a sc.Variable with dimension 'Q' and unit 1/angstrom, or None if Q is None. """ if Q is None: return None @@ -199,13 +202,13 @@ def _validate_and_convert_Q( if Q.ndim > 1: raise ValueError('Q must be a 1-dimensional array.') - Q = sc.array(dims=['Q'], values=Q, unit='1/angstrom') + Q = sc.array(dims=['Q'], values=Q, unit=CANONICAL_Q_UNIT) if isinstance(Q, sc.Variable): if Q.dims != ('Q',): raise ValueError("Q must have a single dimension named 'Q'.") - Q = Q.to(unit='1/angstrom') - return Q.values + Q = Q.to(unit=CANONICAL_Q_UNIT) + return Q def _validate_unit(unit: str | sc.Unit | None) -> sc.Unit | None: diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py index 0abd7e1f..313ad09e 100644 --- a/tests/unit/easydynamics/analysis/test_analysis_base.py +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -501,8 +501,8 @@ def test_on_experiment_changed_updates_Q(self, analysis_base): # EXPECT # assert that the Q attribute was set np.testing.assert_array_equal(analysis_base.Q, fake_Q) - np.testing.assert_array_equal(analysis_base.sample_model.Q, fake_Q) - np.testing.assert_array_equal(analysis_base.instrument_model.Q, fake_Q) + np.testing.assert_array_equal(analysis_base.sample_model.Q.values, fake_Q) + np.testing.assert_array_equal(analysis_base.instrument_model.Q.values, fake_Q) def test_on_sample_model_changed_updates_Q(self, analysis_base): # WHEN @@ -518,7 +518,7 @@ def test_on_sample_model_changed_updates_Q(self, analysis_base): analysis_base._on_sample_model_changed() # EXPECT - np.testing.assert_array_equal(analysis_base.sample_model.Q, fake_Q) + np.testing.assert_array_equal(analysis_base.sample_model.Q.values, fake_Q) def test_on_instrument_model_changed_updates_Q(self, analysis_base): fake_Q = [1, 2, 3] @@ -530,7 +530,7 @@ def test_on_instrument_model_changed_updates_Q(self, analysis_base): mock_Q.return_value = fake_Q analysis_base._on_instrument_model_changed() - np.testing.assert_array_equal(analysis_base.instrument_model.Q, fake_Q) + np.testing.assert_array_equal(analysis_base.instrument_model.Q.values, fake_Q) def test_repr(self, analysis_base): # WHEN diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py index bcfc704e..5441713c 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py @@ -404,7 +404,7 @@ def test_calculate_EISF_with_Q(self, delta_lorentz_model_with_Q): for i in range(len(eisf)): expected = delta_lorentz_model_with_Q._A_0_list[i].value * np.exp( -delta_lorentz_model_with_Q.mean_u_squared.value - * delta_lorentz_model_with_Q.Q[i] ** 2 + * delta_lorentz_model_with_Q.Q.values[i] ** 2 ) assert eisf[i] == pytest.approx(expected) @@ -428,7 +428,7 @@ def test_calculate_QISF_with_Q(self, delta_lorentz_model_with_Q): for i in range(len(qisf)): expected = delta_lorentz_model_with_Q._A_1_list[i].value * np.exp( -delta_lorentz_model_with_Q.mean_u_squared.value - * delta_lorentz_model_with_Q.Q[i] ** 2 + * delta_lorentz_model_with_Q.Q.values[i] ** 2 ) assert qisf[i] == pytest.approx(expected) diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py index 27a47949..c92c5fb0 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py @@ -3,6 +3,7 @@ import numpy as np import pytest +import scipp as sc from easyscience.variable.parameter import Parameter from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase @@ -131,7 +132,9 @@ def test_Q_property(self, diffusion_model): diffusion_model.Q = [1.0, 2.0, 3.0] # EXPECT - np.testing.assert_allclose(diffusion_model.Q, [1.0, 2.0, 3.0]) + assert isinstance(diffusion_model.Q, sc.Variable) + assert diffusion_model.Q.unit == sc.Unit('1/angstrom') + np.testing.assert_allclose(diffusion_model.Q.values, [1.0, 2.0, 3.0]) # THEN EXPECT with pytest.raises(ValueError, match=r'New Q values are not similar to the old ones'): @@ -147,6 +150,16 @@ def test_Q_property(self, diffusion_model): # EXPECT assert diffusion_model.Q is None + def test_Q_setter_accepts_equivalent_scipp_Q_in_other_unit(self, diffusion_model): + # WHEN + diffusion_model.Q = [1.0, 2.0, 3.0] + + # THEN: the same Q in 1/nm is equivalent after conversion, so no error is raised + diffusion_model.Q = sc.Variable(dims=['Q'], values=[10.0, 20.0, 30.0], unit='1/nm') + + # EXPECT + np.testing.assert_allclose(diffusion_model.Q.values, [1.0, 2.0, 3.0]) + def test_repr(self, diffusion_model): # WHEN THEN repr_str = repr(diffusion_model) diff --git a/tests/unit/easydynamics/sample_model/test_background_model.py b/tests/unit/easydynamics/sample_model/test_background_model.py index a698a1bf..8b08bbd8 100644 --- a/tests/unit/easydynamics/sample_model/test_background_model.py +++ b/tests/unit/easydynamics/sample_model/test_background_model.py @@ -45,7 +45,7 @@ def test_init(self, background_model): assert model.display_name == 'InitModel' assert model.unit == 'meV' assert len(model.components) == 2 - np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) + np.testing.assert_array_equal(model.Q.values, np.array([1.0, 2.0, 3.0])) @pytest.mark.parametrize( 'invalid_component, expected_error_msg', diff --git a/tests/unit/easydynamics/sample_model/test_instrument_model.py b/tests/unit/easydynamics/sample_model/test_instrument_model.py index cb716fc4..5c04c55e 100644 --- a/tests/unit/easydynamics/sample_model/test_instrument_model.py +++ b/tests/unit/easydynamics/sample_model/test_instrument_model.py @@ -68,12 +68,12 @@ def test_init(self, instrument_model): # EXPECT assert model.display_name == 'TestInstrumentModel' - np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) + np.testing.assert_array_equal(model.Q.values, np.array([1.0, 2.0, 3.0])) assert isinstance(model.background_model, BackgroundModel) assert isinstance(model.resolution_model, ResolutionModel) - np.testing.assert_array_equal(model.background_model.Q, np.array([1.0, 2.0, 3.0])) - np.testing.assert_array_equal(model.resolution_model.Q, np.array([1.0, 2.0, 3.0])) - np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) + np.testing.assert_array_equal(model.background_model.Q.values, np.array([1.0, 2.0, 3.0])) + np.testing.assert_array_equal(model.resolution_model.Q.values, np.array([1.0, 2.0, 3.0])) + np.testing.assert_array_equal(model.Q.values, np.array([1.0, 2.0, 3.0])) def test_init_defaults(self): # WHEN THEN @@ -441,8 +441,12 @@ def test_Q_setter(self, instrument_model_without_Q): # EXPECT assert instrument_model_without_Q._energy_offsets_is_dirty is True - np.testing.assert_array_equal(instrument_model_without_Q.background_model.Q, first_new_Q) - np.testing.assert_array_equal(instrument_model_without_Q.resolution_model.Q, first_new_Q) + np.testing.assert_array_equal( + instrument_model_without_Q.background_model.Q.values, first_new_Q + ) + np.testing.assert_array_equal( + instrument_model_without_Q.resolution_model.Q.values, first_new_Q + ) # THEN new_Q = np.array([4.0, 5.0, 6.0]) @@ -457,8 +461,12 @@ def test_Q_setter(self, instrument_model_without_Q): # EXPECT # Q values remain unchanged - np.testing.assert_array_equal(instrument_model_without_Q.background_model.Q, first_new_Q) - np.testing.assert_array_equal(instrument_model_without_Q.resolution_model.Q, first_new_Q) + np.testing.assert_array_equal( + instrument_model_without_Q.background_model.Q.values, first_new_Q + ) + np.testing.assert_array_equal( + instrument_model_without_Q.resolution_model.Q.values, first_new_Q + ) # THEN - set Q to an equivalent scipp Variable; values match so should be accepted new_Q = sc.Variable(dims=['Q'], values=[1.0, 2.0, 3.0], unit='1/angstrom') @@ -466,8 +474,12 @@ def test_Q_setter(self, instrument_model_without_Q): # EXPECT - Q propagated to child models, offsets marked dirty again assert instrument_model_without_Q._energy_offsets_is_dirty is True - np.testing.assert_array_equal(instrument_model_without_Q.background_model.Q, first_new_Q) - np.testing.assert_array_equal(instrument_model_without_Q.resolution_model.Q, first_new_Q) + np.testing.assert_array_equal( + instrument_model_without_Q.background_model.Q.values, first_new_Q + ) + np.testing.assert_array_equal( + instrument_model_without_Q.resolution_model.Q.values, first_new_Q + ) def test_fix_and_free_offset(self, instrument_model): # WHEN diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index 56c0a9db..312dcf7b 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -51,7 +51,7 @@ def test_init(self, model_base): assert model.display_name == 'InitModel' assert model.unit == 'meV' assert len(model.components) == 2 - np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) + np.testing.assert_array_equal(model.Q.values, np.array([1.0, 2.0, 3.0])) def test_init_raises_with_invalid_components(self): # WHEN / THEN / EXPECT @@ -352,7 +352,7 @@ def test_Q_setter_when_current_Q_is_none(self, model_base): model_base.Q = new_Q # EXPECT - np.testing.assert_array_equal(model_base.Q, np.array(new_Q)) + np.testing.assert_array_equal(model_base.Q.values, np.array(new_Q)) def test_clear_Q(self, model_base): # WHEN diff --git a/tests/unit/easydynamics/sample_model/test_resolution_model.py b/tests/unit/easydynamics/sample_model/test_resolution_model.py index f894daf2..a2491c0d 100644 --- a/tests/unit/easydynamics/sample_model/test_resolution_model.py +++ b/tests/unit/easydynamics/sample_model/test_resolution_model.py @@ -78,7 +78,7 @@ def test_init(self, resolution_model): assert model.display_name == 'InitModel' assert model.unit == 'meV' assert len(model.components) == 2 - np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) + np.testing.assert_array_equal(model.Q.values, np.array([1.0, 2.0, 3.0])) @pytest.mark.parametrize( 'invalid_component, expected_error_msg', @@ -218,7 +218,7 @@ def test_from_sample_model( assert resolution_model.unit == 'meV' assert len(resolution_model.components) == 2 np.testing.assert_array_equal( - resolution_model.Q, + resolution_model.Q.values, np.array([1.0, 2.0, 3.0]), ) diff --git a/tests/unit/easydynamics/sample_model/test_sample_model.py b/tests/unit/easydynamics/sample_model/test_sample_model.py index 8086c92e..a6c7f79f 100644 --- a/tests/unit/easydynamics/sample_model/test_sample_model.py +++ b/tests/unit/easydynamics/sample_model/test_sample_model.py @@ -71,7 +71,7 @@ def test_init(self, sample_model): assert model.normalize_detailed_balance is True assert model.use_detailed_balance is True assert isinstance(model.detailed_balance_settings, DetailedBalanceSettings) - np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) + np.testing.assert_array_equal(model.Q.values, np.array([1.0, 2.0, 3.0])) def test_init_custom_input(self): # WHEN THEN diff --git a/tests/unit/easydynamics/utils/test_utils.py b/tests/unit/easydynamics/utils/test_utils.py index 3044d2f4..7899f249 100644 --- a/tests/unit/easydynamics/utils/test_utils.py +++ b/tests/unit/easydynamics/utils/test_utils.py @@ -124,12 +124,14 @@ class TestValidateAndConvertQ: ], ) def test_validate_and_convert_Q_numeric_and_array(self, Q_input, expected): - # WHEN THEN + # WHEN THEN: numbers, lists, and numpy arrays are assumed to be in 1/angstrom result = _validate_and_convert_Q(Q_input) # EXPECT - assert isinstance(result, np.ndarray) - np.testing.assert_allclose(result, expected) + assert isinstance(result, sc.Variable) + assert result.dims == ('Q',) + assert result.unit == sc.Unit('1/angstrom') + np.testing.assert_allclose(result.values, expected) def test_validate_and_convert_Q_scipp_variable(self): # WHEN @@ -139,8 +141,20 @@ def test_validate_and_convert_Q_scipp_variable(self): result = _validate_and_convert_Q(Q) # EXPECT - assert isinstance(result, np.ndarray) - np.testing.assert_allclose(result, [1.0, 2.0]) + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('1/angstrom') + np.testing.assert_allclose(result.values, [1.0, 2.0]) + + def test_validate_and_convert_Q_scipp_variable_other_unit(self): + # WHEN: a scipp Q in 1/nm + Q = sc.array(dims=['Q'], values=[10.0, 20.0], unit='1/nm') + + # THEN + result = _validate_and_convert_Q(Q) + + # EXPECT: converted to 1/angstrom + assert result.unit == sc.Unit('1/angstrom') + np.testing.assert_allclose(result.values, [1.0, 2.0]) def test_validate_and_convert_Q_none(self): # WHEN THEN EXPECT