diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b65adfa0..9820bfde 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -49,7 +49,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v3 # ℹī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -63,4 +63,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 \ No newline at end of file + uses: github/codeql-action/analyze@v3 \ No newline at end of file diff --git a/.github/workflows/ossar-analysis.yml b/.github/workflows/ossar-analysis.yml index a80972a4..07362dce 100644 --- a/.github/workflows/ossar-analysis.yml +++ b/.github/workflows/ossar-analysis.yml @@ -43,6 +43,6 @@ jobs: # Upload results to the Security tab - name: Upload OSSAR results - uses: github/codeql-action/upload-sarif@v1 + uses: github/codeql-action/upload-sarif@v3 with: sarif_file: ${{ steps.ossar.outputs.sarifFile }} \ No newline at end of file diff --git a/EasyReflectometry/sample/item.py b/EasyReflectometry/sample/item.py index 7031e9b8..926ba47a 100644 --- a/EasyReflectometry/sample/item.py +++ b/EasyReflectometry/sample/item.py @@ -12,4 +12,6 @@ from .items.repeating_multilayer import RepeatingMultiLayer from .items.surfactant_layer import SurfactantLayer -_ = (MultiLayer, RepeatingMultiLayer, SurfactantLayer) +from .items.gradient_layer import GradientLayer + +_ = (MultiLayer, RepeatingMultiLayer, SurfactantLayer, GradientLayer) diff --git a/EasyReflectometry/sample/items/gradient_layer.py b/EasyReflectometry/sample/items/gradient_layer.py new file mode 100644 index 00000000..6ea7b617 --- /dev/null +++ b/EasyReflectometry/sample/items/gradient_layer.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from easyCore.Fitting.Constraints import ObjConstraint +from numpy import arange + +from EasyReflectometry.sample.layer import Layer +from EasyReflectometry.sample.material import Material + +from .multilayer import MultiLayer + + +class GradientLayer(MultiLayer): + """ + A :py:class:`GradientLayer` constructs a gradient multilayer for the + provided initial and final material. + """ + def __init__( + self, + initial_material: Material, + final_material: Material, + thickness: float, + roughness: float, + discretisation_elements: int = 10, + name: str = 'EasyGradienLayer', + interface=None + ) -> GradientLayer: + """ + :param initial_material: Material of initial "part" of the layer + :param final_material: Material of final "part" of the layer + :param thickness: Thicknkess of the layer + :param roughness: Roughness of the layer + :param discretisation_elements: Number of discrete layers + :param name: Name for gradient layer + :param interface: Interface to use for the layer + """ + self._initial_material = initial_material + self._final_material = final_material + if discretisation_elements < 2: + raise ValueError("Discretisation elements must be greater than 2.") + self._discretisation_elements = discretisation_elements + + gradient_layers = _prepare_gradient_layers( + initial_material=initial_material, + final_material=final_material, + discretisation_elements=discretisation_elements, + interface=interface) + + super().__init__( + layers=gradient_layers, + name=name, + interface=interface, + type='Gradient-layer' + ) + + _apply_thickness_constraints(self.layers) + # Set the thickness and roughness properties + self.thickness = thickness + self.roughness = roughness + + @property + def thickness(self) -> float: + """ + :return: Thickness of the gradient layer + """ + # Layer 0 is the deciding layer as set in _apply_thickness_constraints + return self.layers[0].thickness.raw_value * self._discretisation_elements + + @thickness.setter + def thickness(self, thickness: float) -> None: + """ + :param thickness: Thickness of the gradient layer + """ + # Layer 0 is the deciding layer as set in _apply_thickness_constraints + self.layers[0].thickness.value = thickness / self._discretisation_elements + + @property + def roughness(self) -> float: + """ + :return: Roughness of the gradient layer + """ + # Layer 0 ir -1 is the deciding layer + return self.layers[0].roughness.raw_value + + @roughness.setter + def roughness(self, roughness: float) -> None: + """ + :param roughness: Roughness of the gradient layer + """ + # Layer 0 is facing the beam + # Layer -1 is away from the beam + self.layers[0].roughness.value = roughness + self.layers[-1].roughness.value = roughness + + # Class constructors + @classmethod + def default(cls, name: str = 'Air-Deuterium', interface=None) -> GradientLayer: + """ + Default constructor for a gradient layer object. The default is air to deuterium. + + :return: Gradient layer object. + """ + initial_material = Material.from_pars(0.0, 0.0, 'Air') + final_material = Material.from_pars(6.36, 0.0, 'D2O') + + return cls( + initial_material=initial_material, + final_material=final_material, + thickness=2., + roughness=0.2, + discretisation_elements=10, + name=name, + interface=interface + ) + + @classmethod + def from_pars( + cls, + initial_material: Material, + final_material: Material, + thickness: float, + roughness: float, + discretisation_elements: int, + name: str = 'EasyGradientLayer', + interface=None + ) -> GradientLayer: + """ + Constructor for the gradient layer where the parameters are known, + :py:attr:`initial` is facing the neutron beam. + + :param initial_material: Material of initial "part" of the layer + :param final_material: Material of final "part" of the layer + :param thickness: Thicknkess of the layer + :param roughness: Roughness of the layer + :param discretisation_elements: Number of dicrete layers + :param name: Name for gradient layer + """ + + return cls( + initial_material=initial_material, + final_material=final_material, + thickness=thickness, + roughness=roughness, + discretisation_elements=discretisation_elements, + name=name, + interface=interface + ) + + def add_layer(self, layer: Layer) -> None: + raise NotImplementedError("Cannot add layers to a gradient layer.") + + def duplicate_layer(self, idx: int) -> None: + raise NotImplementedError("Cannot duplicate a layer for a gradient layer.") + + def remove_layer(self, idx: int) -> None: + raise NotImplementedError("Cannot remove layer from a gradient layer.") + + @property + def _dict_repr(self) -> dict[str, str]: + """ + A simplified dict representation. + + :return: Simple dictionary + """ + return { + 'type': self.type, + 'thickness': self.thickness, + 'discretisation_elements': self._discretisation_elements, + 'initial_layer': self.layers[0]._dict_repr, + 'final_layer': self.layers[-1]._dict_repr + } + + def as_dict(self, skip: list = None) -> dict: + """ + Custom as_dict method to skip generated layers. + + :return: Cleaned dictionary. + """ + if skip is None: + skip = [] + this_dict = super().as_dict(skip=skip) + del this_dict['layers'] + return this_dict + + +def _linear_gradient( + init_value: float, + final_value: float, + discretisation_elements: int + ) -> list[float]: + discrete_step = (final_value - init_value) / discretisation_elements + if discrete_step != 0: + # Both initial and final values are included + gradient = arange(init_value, final_value + discrete_step, discrete_step) + else: + gradient = [init_value] * discretisation_elements + return gradient + + +def _prepare_gradient_layers( + initial_material: Material, + final_material: Material, + discretisation_elements: int, + interface=None + ) -> list[Layer]: + gradient_sld = _linear_gradient( + init_value=initial_material.sld.raw_value, + final_value=final_material.sld.raw_value, + discretisation_elements=discretisation_elements + ) + gradient_isld = _linear_gradient( + init_value=initial_material.isld.raw_value, + final_value=final_material.isld.raw_value, + discretisation_elements=discretisation_elements + ) + gradient_layers = [] + for i in range(discretisation_elements): + layer = Layer.from_pars( + material=Material.from_pars(gradient_sld[i], gradient_isld[i]), + thickness=0.0, + roughness=0.0, + name=str(i), + interface=interface + ) + gradient_layers.append(layer) + return gradient_layers + + +def _apply_thickness_constraints(layers) -> None: + # Add thickness constraint, layer 0 is the deciding layer + for i in range(1, len(layers)): + layers[i].thickness.enabled = True + layer_constraint = ObjConstraint( + dependent_obj=layers[i].thickness, + operator='', + independent_obj=layers[0].thickness + ) + layers[0].thickness.user_constraints[f'thickness_{i}'] = layer_constraint + layers[0].thickness.user_constraints[f'thickness_{i}'].enabled = True + + layers[0].thickness.enabled = True + diff --git a/EasyReflectometry/sample/items/multilayer.py b/EasyReflectometry/sample/items/multilayer.py index cc5a59e6..58b25247 100644 --- a/EasyReflectometry/sample/items/multilayer.py +++ b/EasyReflectometry/sample/items/multilayer.py @@ -1,7 +1,10 @@ -from typing import Union, List -import yaml +from __future__ import annotations + +from typing import List, Union +import yaml from easyCore.Objects.ObjectClasses import BaseObj + from EasyReflectometry.sample.layer import Layer from EasyReflectometry.sample.layers import Layers @@ -20,21 +23,24 @@ class MultiLayer(BaseObj): .. _`item library documentation`: ./item_library.html#multilayer """ - def __init__(self, - layers: Union[Layers, Layer, List[Layer]], - name: str = 'EasyMultiLayer', - interface=None): + def __init__( + self, + layers: Union[Layers, Layer, List[Layer]], + name: str = 'EasyMultiLayer', + interface=None, + type: str='Multi-layer' + ): if isinstance(layers, Layer): layers = Layers(layers, name=layers.name) elif isinstance(layers, list): layers = Layers(*layers, name='/'.join([layer.name for layer in layers])) - self.type = 'Multi-layer' + self.type = type super().__init__(name, layers=layers) self.interface = interface # Class constructors @classmethod - def default(cls, interface=None) -> "MultiLayer": + def default(cls, interface=None) -> MultiLayer: """ Default constructor for a multi-layer item. @@ -48,7 +54,7 @@ def default(cls, interface=None) -> "MultiLayer": def from_pars(cls, layers: Layers, name: str = "EasyMultiLayer", - interface=None) -> "MultiLayer": + interface=None) -> MultiLayer: """ Constructor of a multi-layer item where the parameters are known. diff --git a/pyproject.toml b/pyproject.toml index 0aa41e09..172a7fc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "EasyReflectometryLib" -version = "0.0.3" +version = "0.0.4-dev" description = "A reflectometry python package built on the EasyScience framework." readme = "README.rst" authors = [ @@ -99,4 +99,8 @@ deps = coverage commands = pip install -e '.[dev]' pytest --cov --cov-report=xml -""" \ No newline at end of file +""" + +[tool.isort] +multi_line_output = 3 +include_trailing_comma = true diff --git a/tests/sample/items/test_gradient_layer.py b/tests/sample/items/test_gradient_layer.py new file mode 100644 index 00000000..25d32082 --- /dev/null +++ b/tests/sample/items/test_gradient_layer.py @@ -0,0 +1,218 @@ +from unittest.mock import MagicMock + +import pytest +from numpy.testing import assert_almost_equal + +import EasyReflectometry.sample.items.gradient_layer +from EasyReflectometry.sample.item import GradientLayer +from EasyReflectometry.sample.items.gradient_layer import ( + _apply_thickness_constraints, + _linear_gradient, + _prepare_gradient_layers, +) +from EasyReflectometry.sample.layer import Layer +from EasyReflectometry.sample.material import Material + + +class TestGradientLayer(): + + @pytest.fixture + def gradient_layer(self): + self.init = Material.from_pars(10.0, -10.0, 'Material_1') + self.final = Material.from_pars(0.0, 0.0, 'Material_2') + + return GradientLayer( + initial_material=self.init, + final_material=self.final, + thickness=1.0, + roughness=2.0, + discretisation_elements=10, + name='Test', + interface=None + ) + + def test_init(self, gradient_layer): + # When Then Expect + assert len(gradient_layer.layers) == 10 + assert gradient_layer.name, 'Test' + assert gradient_layer.type, 'Gradient-layer' + assert gradient_layer.interface is None + assert gradient_layer.thickness == 1.0 + assert gradient_layer.layers.name == '0/1/2/3/4/5/6/7/8/9' + assert gradient_layer.layers[0].material.sld.raw_value == 10.0 + assert gradient_layer.layers[5].material.sld.raw_value == 5.0 + assert gradient_layer.layers[0].thickness.raw_value == 0.1 + assert gradient_layer.layers[5].material.isld.raw_value == -5.0 + assert gradient_layer.layers[9].material.isld.raw_value == -1.0 + + def test_default(self): + # When Then + result = GradientLayer.default() + + # Expect + assert result.name == 'Air-Deuterium' + assert result.type, 'Gradient-layer' + assert result.interface is None + assert len(result.layers) == 10 + assert result.layers.name == '0/1/2/3/4/5/6/7/8/9' + + def test_from_pars(self): + # When + init = Material.from_pars(6.908, -0.278, 'Boron') + final = Material.from_pars(0.487, 0.000, 'Potassium') + + # Then + result = GradientLayer.from_pars( + initial_material=init, + final_material=final, + thickness=10.0, + roughness=1.0, + discretisation_elements=5, + name='gradientItem' + ) + + # Expect + assert result.name, 'gradientItem' + assert result.type, 'Gradient-layer' + assert result.interface is None + assert len(result.layers) == 5 + assert result.layers.name == '0/1/2/3/4' + + def test_add_layer(self, gradient_layer): + # When Then Expect + with pytest.raises(NotImplementedError, match=r".* add .*"): + gradient_layer.add_layer(Layer.default()) + + def test_duplicate_layer(self, gradient_layer): + # When Then Expect + with pytest.raises(NotImplementedError, match=r".* duplicate .*"): + gradient_layer.duplicate_layer(1) + + def test_remove_layer(self, gradient_layer): + # When Then Expect + with pytest.raises(NotImplementedError, match=r".* remove .*"): + gradient_layer.remove_layer(1) + + def test_repr(self, gradient_layer): + # When Then Expect + expected_str = "type: Gradient-layer\nthickness: 1.0\ndiscretisation_elements: 10\ninitial_layer:\n '0':\n material:\n EasyMaterial:\n sld: 10.000e-6 1 / angstrom ** 2\n isld: -10.000e-6 1 / angstrom ** 2\n thickness: 0.100 angstrom\n roughness: 2.000 angstrom\nfinal_layer:\n '9':\n material:\n EasyMaterial:\n sld: 1.000e-6 1 / angstrom ** 2\n isld: -1.000e-6 1 / angstrom ** 2\n thickness: 0.100 angstrom\n roughness: 2.000 angstrom\n" + assert gradient_layer.__repr__() == expected_str + + def test_dict_round_trip(self, gradient_layer): + # When Then + result = GradientLayer.from_dict(gradient_layer.as_dict()) + + # Expect + assert result.as_data_dict() == gradient_layer.as_data_dict() + assert len(gradient_layer.layers) == len(result.layers) + # Just one layer of the generated layers is checked + assert gradient_layer.layers[5].__repr__() == result.layers[5].__repr__() + + def test_thickness_setter(self, gradient_layer): + # When + gradient_layer.thickness = 10.0 + + # Then + assert gradient_layer.thickness == 10.0 + assert gradient_layer.layers[0].thickness.raw_value == 1.0 + assert gradient_layer.layers[9].thickness.raw_value == 1.0 + + def test_thickness_getter(self, gradient_layer): + # When + gradient_layer.layers = MagicMock() + gradient_layer.layers[0].thickness.raw_value = 10.0 + + # Then + # discretisation_elements * discrete_layer_thickness + assert gradient_layer.thickness == 10.0 + + def test_roughness_setter(self, gradient_layer): + # When + gradient_layer.roughness = 10.0 + + # Then + assert gradient_layer.roughness == 10.0 + assert gradient_layer.layers[0].roughness.raw_value == 10.0 + assert gradient_layer.layers[9].roughness.raw_value == 10.0 + + def test_thickness_getter(self, gradient_layer): + # When + gradient_layer.layers = MagicMock() + gradient_layer.layers[0].roughness.raw_value = 10.0 + + # Then + # discretisation_elements * discrete_layer_thickness + assert gradient_layer.roughness == 10.0 + + +def test_linear_gradient_increasing(): + # When Then + result = _linear_gradient(init_value=1.5, final_value=2.5, discretisation_elements=10) + + # Expect + assert_almost_equal([1.5, 1.6, 1.7, 1.8, 1.9, 2. , 2.1, 2.2, 2.3, 2.4, 2.5], result) + + +def test_linear_gradient_decreasing(): + # When Then + result = _linear_gradient(init_value=2.5, final_value=1.5, discretisation_elements=10) + + # Expect + assert_almost_equal([2.5, 2.4, 2.3, 2.2, 2.1, 2. , 1.9, 1.8, 1.7, 1.6, 1.5], result) + + +def test_linear_gradient_same(): + # When Then + result = _linear_gradient(init_value=2.5, final_value=2.5, discretisation_elements=10) + + # Expect + assert_almost_equal([2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5], result) + + +def test_prepare_gradient_layers(monkeypatch): + # When + mock_material_1 = MagicMock() + mock_material_2 = MagicMock() + mock_Layer = MagicMock() + mock_Material = MagicMock() + mock_Material.from_pars = MagicMock(return_value='Material_from_pars') + mock_linear_gradient = MagicMock(return_value=[1.0, 2.0, 3.0]) + monkeypatch.setattr(EasyReflectometry.sample.items.gradient_layer, '_linear_gradient', mock_linear_gradient) + monkeypatch.setattr(EasyReflectometry.sample.items.gradient_layer, 'Layer', mock_Layer) + monkeypatch.setattr(EasyReflectometry.sample.items.gradient_layer, 'Material', mock_Material) + + # Then + result = _prepare_gradient_layers(mock_material_1, mock_material_2, 3, None) + + # When + assert mock_Material.from_pars.call_count == 3 + assert mock_Material.from_pars.call_args_list[0][0] == (1.0, 1.0) + assert mock_Material.from_pars.call_args_list[1][0] == (2.0, 2.0) + assert mock_Material.from_pars.call_args_list[2][0] == (3.0, 3.0) + assert mock_Layer.from_pars.call_count == 3 + assert mock_Layer.from_pars.call_args_list[0][1]['material'] == 'Material_from_pars' + assert mock_Layer.from_pars.call_args_list[0][1]['thickness'] == 0.0 + assert mock_Layer.from_pars.call_args_list[0][1]['name'] == '0' + assert mock_Layer.from_pars.call_args_list[0][1]['interface'] == None + +def test_apply_thickness_constraints(monkeypatch): + # When + mock_layer_0 = MagicMock() + mock_layer_0.thickness = MagicMock() + mock_layer_0.thickness.user_constraints = {} + mock_layer_1 = MagicMock() + layers = [mock_layer_0, mock_layer_1] + mock_layer_1.thickness = MagicMock() + mock_obj_constraint = MagicMock() + mock_ObjConstraint = MagicMock(return_value=mock_obj_constraint) + monkeypatch.setattr(EasyReflectometry.sample.items.gradient_layer, 'ObjConstraint', mock_ObjConstraint) + + #Then + _apply_thickness_constraints(layers) + + #Expect + assert mock_layer_0.thickness.enabled is True + assert mock_layer_1.thickness.enabled is True + assert layers[0].thickness.user_constraints['thickness_1'].enabled is True + assert layers[0].thickness.user_constraints['thickness_1'] == mock_obj_constraint + mock_ObjConstraint.assert_called_once_with(dependent_obj=mock_layer_1.thickness, operator='', independent_obj=mock_layer_0.thickness)