diff --git a/news/no-empty-object.rst b/news/no-empty-object.rst new file mode 100644 index 00000000..7e4ec7a4 --- /dev/null +++ b/news/no-empty-object.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* `DiffractionObject` requires 3 input parameters of `xarray`, `yarray`, `xtype`, to be instantiated. It can be instantiated with empty arrays. + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/utils/diffraction_objects.py b/src/diffpy/utils/diffraction_objects.py index a242c895..d72a2889 100644 --- a/src/diffpy/utils/diffraction_objects.py +++ b/src/diffpy/utils/diffraction_objects.py @@ -35,27 +35,84 @@ def _setter_wmsg(attribute): class DiffractionObject: + """ + Initialize a DiffractionObject instance. + + Parameters + ---------- + xarray : array-like + The independent variable array containing "q", "tth", or "d" values. + yarray : array-like + The dependent variable array corresponding to intensity values. + xtype : str + The type of the independent variable in `xarray`. Must be one of {*XQUANTITIES}. + wavelength : float, optional + The wavelength of the incoming beam, specified in angstroms (Å). Default is none. + scat_quantity : str, optional + The type of scattering experiment (e.g., "x-ray", "neutron"). Default is an empty string "". + name : str, optional + The name or label for the scattering data. Default is an empty string "". + metadata : dict, optional + The additional metadata associated with the diffraction object. Default is {}. + + Examples + -------- + Create a DiffractionObject for X-ray scattering data: + + >>> import numpy as np + >>> from diffpy.utils.diffraction_objects import DiffractionObject + ... + >>> x = np.array([0.12, 0.24, 0.31, 0.4]) # independent variable (e.g., q) + >>> y = np.array([10, 20, 40, 60]) # intensity values + >>> metadata = { + ... "sample": "rock salt from the beach", + ... "composition": "NaCl", + ... "temperature": "300 K,", + ... "experimenters": "Phill, Sally" + ... } + >>> do = DiffractionObject( + ... xarray=x, + ... yarray=y, + ... xtype="q", + ... wavelength=1.54, + ... scat_quantity="x-ray", + ... name="beach_rock_salt_1", + ... metadata=metadata + ... ) + >>> print(do.metadata) + """ + def __init__( - self, name=None, wavelength=None, scat_quantity=None, metadata=None, xarray=None, yarray=None, xtype=None + self, + xarray, + yarray, + xtype, + wavelength=None, + scat_quantity="", + name="", + metadata={}, ): - if name is None: - name = "" - self.name = name - if metadata is None: - metadata = {} - self.metadata = metadata - if xtype is None: - xtype = "" - self.scat_quantity = scat_quantity - self.wavelength = wavelength - - if xarray is None: - xarray = np.empty(0) - if yarray is None: - yarray = np.empty(0) self._id = uuid.uuid4() - self.input_data(xarray, yarray, xtype) + self._input_data(xarray, yarray, xtype, wavelength, scat_quantity, name, metadata) + + def _input_data(self, xarray, yarray, xtype, wavelength, scat_quantity, name, metadata): + if xtype not in XQUANTITIES: + raise ValueError(_xtype_wmsg(xtype)) + if len(xarray) != len(yarray): + raise ValueError( + "'xarray' and 'yarray' are different lengths. They must " + "correspond to each other and have the same length. " + "Please re-initialize 'DiffractionObject'" + "with valid 'xarray' and 'yarray's" + ) + self.scat_quantity = scat_quantity + self.wavelength = wavelength + self.metadata = metadata + self.name = name + self._input_xtype = xtype + self._set_arrays(xarray, yarray, xtype) + self._set_min_max_xarray() def __eq__(self, other): if not isinstance(other, DiffractionObject): @@ -231,16 +288,16 @@ def get_array_index(self, value, xtype=None): the index of the value in the array """ - if xtype is None: - xtype = self._input_xtype + xtype = self._input_xtype array = self.on_xtype(xtype)[0] if len(array) == 0: raise ValueError(f"The '{xtype}' array is empty. Please ensure it is initialized.") i = (np.abs(array - value)).argmin() return i - def _set_xarrays(self, xarray, xtype): + def _set_arrays(self, xarray, yarray, xtype): self._all_arrays = np.empty(shape=(len(xarray), 4)) + self._all_arrays[:, 0] = yarray if xtype.lower() in QQUANTITIES: self._all_arrays[:, 1] = xarray self._all_arrays[:, 2] = q_to_tth(xarray, self.wavelength) @@ -253,6 +310,8 @@ def _set_xarrays(self, xarray, xtype): self._all_arrays[:, 3] = xarray self._all_arrays[:, 1] = d_to_q(xarray) self._all_arrays[:, 2] = d_to_tth(xarray, self.wavelength) + + def _set_min_max_xarray(self): self.qmin = np.nanmin(self._all_arrays[:, 1], initial=np.inf) self.qmax = np.nanmax(self._all_arrays[:, 1], initial=0.0) self.tthmin = np.nanmin(self._all_arrays[:, 2], initial=np.inf) @@ -260,63 +319,6 @@ def _set_xarrays(self, xarray, xtype): self.dmin = np.nanmin(self._all_arrays[:, 3], initial=np.inf) self.dmax = np.nanmax(self._all_arrays[:, 3], initial=0.0) - def input_data( - self, - xarray, - yarray, - xtype, - metadata={}, - scat_quantity=None, - name=None, - wavelength=None, - ): - f""" - insert a new scattering quantity into the scattering object - - Parameters - ---------- - xarray array-like of floats - the independent variable array - yarray array-like of floats - the dependent variable array - xtype string - the type of quantity for the independent variable from {*XQUANTITIES, } - metadata, scat_quantity, name and wavelength are optional. They have the same - meaning as in the constructor. Values will only be overwritten if non-empty values are passed. - - Returns - ------- - Nothing. Updates the object in place. - - """ - - # Check xarray and yarray have the same length - if len(xarray) != len(yarray): - raise ValueError( - "'xarray' and 'yarray' must have the same length. " - "Please re-initialize 'DiffractionObject' or re-run the method 'input_data' " - "with 'xarray' and 'yarray' of identical length." - ) - - self._set_xarrays(xarray, xtype) - self._all_arrays[:, 0] = yarray - self._input_xtype = xtype - # only update these optional values if non-empty quantities are passed to avoid overwriting - # valid data inadvertently - if metadata: - self.metadata = metadata - if scat_quantity is not None: - self.scat_quantity = scat_quantity - if name is not None: - self.name = name - if wavelength is not None: - self.wavelength = wavelength - - # Check xtype is valid. An empty string is the default value. - if xtype != "": - if xtype not in XQUANTITIES: - raise ValueError(_xtype_wmsg(xtype)) - def _get_original_array(self): if self._input_xtype in QQUANTITIES: return self.on_q(), "q" diff --git a/tests/conftest.py b/tests/conftest.py index c0767b49..0dd5024d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,6 +33,13 @@ def _load(filename): return _load +@pytest.fixture +def do_minimal(): + # Create an instance of DiffractionObject with empty xarray and yarray values, and a non-empty wavelength + return DiffractionObject(xarray=np.empty(0), yarray=np.empty(0), xtype="tth", wavelength=1.54) + + @pytest.fixture def do_minimal_tth(): + # Create an instance of DiffractionObject with non-empty xarray, yarray, and wavelength values return DiffractionObject(wavelength=2 * np.pi, xarray=np.array([30, 60]), yarray=np.array([1, 2]), xtype="tth") diff --git a/tests/test_diffraction_objects.py b/tests/test_diffraction_objects.py index 4d210e3e..dfb67a3d 100644 --- a/tests/test_diffraction_objects.py +++ b/tests/test_diffraction_objects.py @@ -11,11 +11,6 @@ from diffpy.utils.diffraction_objects import XQUANTITIES, DiffractionObject params = [ - ( # Default - {}, - {}, - True, - ), ( # Compare same attributes { "name": "same", @@ -40,18 +35,14 @@ ( # Different names { "name": "something", - "scat_quantity": "", - "wavelength": None, - "xtype": "", + "xtype": "tth", "xarray": np.empty(0), "yarray": np.empty(0), "metadata": {"thing1": 1, "thing2": "thing2"}, }, { "name": "something else", - "scat_quantity": "", - "wavelength": None, - "xtype": "", + "xtype": "tth", "xarray": np.empty(0), "yarray": np.empty(0), "metadata": {"thing1": 1, "thing2": "thing2"}, @@ -60,17 +51,14 @@ ), ( # Different wavelengths { - "scat_quantity": "", "wavelength": 0.71, - "xtype": "", + "xtype": "tth", "xarray": np.empty(0), "yarray": np.empty(0), "metadata": {"thing1": 1, "thing2": "thing2"}, }, { - "scat_quantity": "", - "wavelength": None, - "xtype": "", + "xtype": "tth", "xarray": np.empty(0), "yarray": np.empty(0), "metadata": {"thing1": 1, "thing2": "thing2"}, @@ -79,17 +67,15 @@ ), ( # Different wavelengths { - "scat_quantity": "", "wavelength": 0.71, - "xtype": "", + "xtype": "tth", "xarray": np.empty(0), "yarray": np.empty(0), "metadata": {"thing1": 1, "thing2": "thing2"}, }, { - "scat_quantity": "", "wavelength": 0.711, - "xtype": "", + "xtype": "tth", "xarray": np.empty(0), "yarray": np.empty(0), "metadata": {"thing1": 1, "thing2": "thing2"}, @@ -99,16 +85,14 @@ ( # Different scat_quantity { "scat_quantity": "x-ray", - "wavelength": None, - "xtype": "", + "xtype": "tth", "xarray": np.empty(0), "yarray": np.empty(0), "metadata": {"thing1": 1, "thing2": "thing2"}, }, { "scat_quantity": "neutron", - "wavelength": None, - "xtype": "", + "xtype": "tth", "xarray": np.empty(0), "yarray": np.empty(0), "metadata": {"thing1": 1, "thing2": "thing2"}, @@ -117,16 +101,11 @@ ), ( # Different on_q { - "scat_quantity": "", - "wavelength": None, "xtype": "q", "xarray": np.array([1.0, 2.0]), "yarray": np.array([100.0, 200.0]), - "metadata": {}, }, { - "scat_quantity": "", - "wavelength": None, "xtype": "q", "xarray": np.array([3.0, 4.0]), "yarray": np.array([100.0, 200.0]), @@ -136,17 +115,13 @@ ), ( # Different metadata { - "scat_quantity": "", - "wavelength": None, - "xtype": "", + "xtype": "q", "xarray": np.empty(0), "yarray": np.empty(0), "metadata": {"thing1": 0, "thing2": "thing2"}, }, { - "scat_quantity": "", - "wavelength": None, - "xtype": "", + "xtype": "q", "xarray": np.empty(0), "yarray": np.empty(0), "metadata": {"thing1": 1, "thing2": "thing2"}, @@ -187,7 +162,7 @@ def test_init_invalid_xtype(): f"Please rerun specifying an xtype from {*XQUANTITIES, }" ), ): - DiffractionObject(xtype="invalid_type") + return DiffractionObject(xarray=np.empty(0), yarray=np.empty(0), xtype="invalid_type", wavelength=1.54) params_scale_to = [ @@ -400,41 +375,7 @@ def test_dump(tmp_path, mocker): assert actual == expected -tc_params = [ - ( - {}, - { - "_all_arrays": np.empty(shape=(0, 4)), # instantiate empty - "metadata": {}, - "_input_xtype": "", - "name": "", - "scat_quantity": None, - "qmin": np.float64(np.inf), - "qmax": np.float64(0.0), - "tthmin": np.float64(np.inf), - "tthmax": np.float64(0.0), - "dmin": np.float64(np.inf), - "dmax": np.float64(0.0), - "wavelength": None, - }, - ), - ( # instantiate just non-array attributes - {"name": "test", "scat_quantity": "x-ray", "metadata": {"thing": "1", "another": "2"}}, - { - "_all_arrays": np.empty(shape=(0, 4)), - "metadata": {"thing": "1", "another": "2"}, - "_input_xtype": "", - "name": "test", - "scat_quantity": "x-ray", - "qmin": np.float64(np.inf), - "qmax": np.float64(0.0), - "tthmin": np.float64(np.inf), - "tthmax": np.float64(0.0), - "dmin": np.float64(np.inf), - "dmax": np.float64(0.0), - "wavelength": None, - }, - ), +test_init_valid_params = [ ( # instantiate just array attributes { "xarray": np.array([0.0, 90.0, 180.0]), @@ -453,7 +394,7 @@ def test_dump(tmp_path, mocker): "metadata": {}, "_input_xtype": "tth", "name": "", - "scat_quantity": None, + "scat_quantity": "", "qmin": np.float64(0.0), "qmax": np.float64(1.0), "tthmin": np.float64(0.0), @@ -495,13 +436,39 @@ def test_dump(tmp_path, mocker): ] -@pytest.mark.parametrize("inputs, expected", tc_params) -def test_constructor(inputs, expected): - actual = DiffractionObject(**inputs).__dict__ - diff = DeepDiff(actual, expected, ignore_order=True, significant_digits=13, exclude_paths="root['_id']") +@pytest.mark.parametrize( + "init_args, expected_do_dict", + test_init_valid_params, +) +def test_init_valid(init_args, expected_do_dict): + actual_do_dict = DiffractionObject(**init_args).__dict__ + diff = DeepDiff( + actual_do_dict, expected_do_dict, ignore_order=True, significant_digits=13, exclude_paths="root['_id']" + ) assert diff == {} +test_init_invalid_params = [ + ( # UC1: no arguments provided + {}, + "missing 3 required positional arguments: 'xarray', 'yarray', and 'xtype'", + ), + ( # UC2: only xarray and yarray provided + {"xarray": np.array([0.0, 90.0]), "yarray": np.array([0.0, 90.0])}, + "missing 1 required positional argument: 'xtype'", + ), +] + + +@pytest.mark.parametrize("init_args, expected_error_msg", test_init_invalid_params) +def test_init_invalid_args( + init_args, + expected_error_msg, +): + with pytest.raises(TypeError, match=expected_error_msg): + DiffractionObject(**init_args) + + def test_all_array_getter(): actual_do = DiffractionObject( xarray=np.array([0.0, 90.0, 180.0]), @@ -519,9 +486,8 @@ def test_all_array_getter(): assert np.allclose(actual_do.all_arrays, expected_all_arrays) -def test_all_array_setter(): - do = DiffractionObject() - +def test_all_array_setter(do_minimal): + do = do_minimal # Attempt to directly modify the property with pytest.raises( AttributeError, @@ -531,21 +497,21 @@ def test_all_array_setter(): do.all_arrays = np.empty((4, 4)) -def test_id_getter(): - do = DiffractionObject() +def test_id_getter(do_minimal): + do = do_minimal assert hasattr(do, "id") assert isinstance(do.id, UUID) assert len(str(do.id)) == 36 -def test_id_getter_with_mock(mocker): +def test_id_getter_with_mock(mocker, do_minimal): mocker.patch.object(DiffractionObject, "id", new_callable=lambda: UUID("d67b19c6-3016-439f-81f7-cf20a04bee87")) - do = DiffractionObject() + do = do_minimal assert do.id == UUID("d67b19c6-3016-439f-81f7-cf20a04bee87") -def test_id_setter_error(): - do = DiffractionObject() +def test_id_setter_error(do_minimal): + do = do_minimal with pytest.raises( AttributeError, @@ -557,22 +523,22 @@ def test_id_setter_error(): def test_xarray_yarray_length_mismatch(): with pytest.raises( ValueError, - match="'xarray' and 'yarray' must have the same length. " - "Please re-initialize 'DiffractionObject' or re-run the method 'input_data' " - "with 'xarray' and 'yarray' of identical length", + match="'xarray' and 'yarray' are different lengths. " + "They must correspond to each other and have the same length. Please " + "re-initialize 'DiffractionObject'with valid 'xarray' and 'yarray's", ): - DiffractionObject(xarray=np.array([1.0, 2.0]), yarray=np.array([0.0, 0.0, 0.0])) + DiffractionObject( + xarray=np.array([1.0, 2.0]), yarray=np.array([0.0, 0.0, 0.0]), xtype="tth", wavelength=1.54 + ) -def test_input_xtype_getter(): - do = DiffractionObject(xtype="tth") +def test_input_xtype_getter(do_minimal): + do = do_minimal assert do.input_xtype == "tth" -def test_input_xtype_setter_error(): - do = DiffractionObject(xtype="tth") - - # Attempt to directly modify the property +def test_input_xtype_setter_error(do_minimal): + do = do_minimal with pytest.raises( AttributeError, match="Direct modification of attribute 'input_xtype' is not allowed. "