Skip to content

orso_utils

ORSO file support: reading and writing .ort (text) and .orb (binary) files.

Reading is built on orsopy.fileio. Data columns follow the ORSO specification: the first four columns are Qz, R, sR, sQz (fixed order); sR and sQz default to sigma but a value_is: FWHM declaration is honoured and converted to sigma on load. Q values declared in 1/nm are converted to the internal 1/angstrom unit. Errors are stored as variances on the scipp arrays (sigma squared), which is also the convention used by :class:~easyreflectometry.model.resolution_functions.Pointwise and by project serialization (sQz_data_points); any exporter must convert back to sigma.

The ORSO "simple model" language is resolved with units honoured (the language default length unit is nm; internal thicknesses/roughnesses are angstrom) and with sub-stack repetitions mapped to :class:~easyreflectometry.sample.assemblies.repeating_multilayer.RepeatingMultilayer.

logger = logging.getLogger(__name__) module-attribute

SIGMA_TO_FWHM = 2 * np.sqrt(2 * np.log(2)) module-attribute

ORSO_BANNER = 'ORSO reflectivity data file' module-attribute

_HDF5_MAGIC = b'\x89HDF\r\n\x1a\n' module-attribute

_LENGTH_UNIT_TO_ANGSTROM = {None: 1.0, 'angstrom': 1.0, 'A': 1.0, 'nm': 10.0, 'um': 10000.0, 'mm': 10000000.0} module-attribute

_SLD_UNIT_TO_INV_ANGSTROM_SQUARED = {None: 1.0, '1/angstrom^2': 1.0, '1/nm^2': 0.01} module-attribute

_Q_UNIT_TO_INV_ANGSTROM = {None: 1.0, '1/angstrom': 1.0, '1/nm': 0.1} module-attribute

_MASS_DENSITY_UNIT_TO_G_CM3 = {None: 1.0, 'g/cm^3': 1.0, 'g/ml': 1.0, 'kg/m^3': 0.001} module-attribute

_NUMBER_DENSITY_UNIT_TO_INV_ANGSTROM3 = {None: 0.001, '1/nm^3': 0.001, '1/angstrom^3': 1.0, '1/cm^3': 1e-24} module-attribute

_MAGNETIC_MOMENT_UNIT_TO_MUB = {None: 1.0, 'muB': 1.0, 'mu_B': 1.0, 'bohr_magneton': 1.0} module-attribute

_STANDARD_COLUMNS = [Column(name='Qz', unit='1/angstrom', physical_quantity='wavevector transfer'), Column(name='R', physical_quantity='reflectivity'), ErrorColumn(error_of='R', error_type='uncertainty', value_is='sigma'), ErrorColumn(error_of='Qz', error_type='resolution', value_is='sigma')] module-attribute

DataSet1D

Bases: SerializerComponent

Source code in src/easyreflectometry/data/data_store.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
class DataSet1D(SerializerComponent):
    def __init__(
        self,
        name: str = 'Series',
        x: Optional[Union[np.ndarray, list]] = None,
        y: Optional[Union[np.ndarray, list]] = None,
        ye: Optional[Union[np.ndarray, list]] = None,
        xe: Optional[Union[np.ndarray, list]] = None,
        model: Optional['Model'] = None,  # delay type checking until runtime (quotes)
        x_label: str = 'x',
        y_label: str = 'y',
        auto_background: bool = True,
        resolution_function: Optional[ResolutionFunction] = None,
    ):
        """Init function.

        Parameters
        ----------
        resolution_function : Optional[ResolutionFunction], optional
            The q-resolution this dataset was measured with (typically a
            ``Pointwise`` built from its sQz column). Fitting and model
            curves evaluated against this dataset smear with it instead of
            the model's resolution. ``None`` (the default) means the model's
            resolution applies; it is not derived from ``xe`` here, see
            :func:`easyreflectometry.data.resolution_from_dataset`.
        """
        self._model = model
        self.resolution_function = resolution_function
        #: Whether :meth:`Project.prepare_fit` fits this experiment by default.
        self.include_in_fit = True
        if y is not None and model is not None and auto_background:
            self._model.background = max(np.min(y), 1e-10)

        if x is None:
            x = np.array([])
        if y is None:
            y = np.array([])
        if ye is None:
            ye = np.zeros_like(x)
        if xe is None:
            xe = np.zeros_like(x)

        if len(x) != len(y):
            raise ValueError('x and y must be the same length')

        self.name = name
        if not isinstance(x, np.ndarray):
            x = np.array(x)
        if not isinstance(y, np.ndarray):
            y = np.array(y)
        if not isinstance(ye, np.ndarray):
            ye = np.array(ye)
        if not isinstance(xe, np.ndarray):
            xe = np.array(xe)

        self.x = x
        self.y = y
        self.ye = ye
        self.xe = xe

        self.x_label = x_label
        self.y_label = y_label

        self._color = None

    @property
    def model(self) -> 'Model':  # delay type checking until runtime (quotes)
        """Model function."""
        return self._model

    @model.setter
    def model(self, new_model: 'Model') -> None:
        """Model function."""
        self._model = new_model

    @property
    def is_experiment(self) -> bool:
        """Is experiment."""
        return self._model is not None

    @property
    def is_simulation(self) -> bool:
        """Is simulation."""
        return self._model is None

    def data_points(self) -> tuple[float, float, float, float]:
        """Data points."""
        return zip(self.x, self.y, self.ye, self.xe)

    def __repr__(self) -> str:
        """Repr function."""
        return "1D DataStore of '{:s}' Vs '{:s}' with {} data points".format(self.x_label, self.y_label, len(self.x))

model property writable

Model function.

is_experiment property

Is experiment.

is_simulation property

Is simulation.

__init__(name='Series', x=None, y=None, ye=None, xe=None, model=None, x_label='x', y_label='y', auto_background=True, resolution_function=None)

Init function.

Parameters:

Name Type Description Default
resolution_function Optional[ResolutionFunction]

The q-resolution this dataset was measured with (typically a Pointwise built from its sQz column). Fitting and model curves evaluated against this dataset smear with it instead of the model's resolution. None (the default) means the model's resolution applies; it is not derived from xe here, see :func:easyreflectometry.data.resolution_from_dataset.

None
Source code in src/easyreflectometry/data/data_store.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def __init__(
    self,
    name: str = 'Series',
    x: Optional[Union[np.ndarray, list]] = None,
    y: Optional[Union[np.ndarray, list]] = None,
    ye: Optional[Union[np.ndarray, list]] = None,
    xe: Optional[Union[np.ndarray, list]] = None,
    model: Optional['Model'] = None,  # delay type checking until runtime (quotes)
    x_label: str = 'x',
    y_label: str = 'y',
    auto_background: bool = True,
    resolution_function: Optional[ResolutionFunction] = None,
):
    """Init function.

    Parameters
    ----------
    resolution_function : Optional[ResolutionFunction], optional
        The q-resolution this dataset was measured with (typically a
        ``Pointwise`` built from its sQz column). Fitting and model
        curves evaluated against this dataset smear with it instead of
        the model's resolution. ``None`` (the default) means the model's
        resolution applies; it is not derived from ``xe`` here, see
        :func:`easyreflectometry.data.resolution_from_dataset`.
    """
    self._model = model
    self.resolution_function = resolution_function
    #: Whether :meth:`Project.prepare_fit` fits this experiment by default.
    self.include_in_fit = True
    if y is not None and model is not None and auto_background:
        self._model.background = max(np.min(y), 1e-10)

    if x is None:
        x = np.array([])
    if y is None:
        y = np.array([])
    if ye is None:
        ye = np.zeros_like(x)
    if xe is None:
        xe = np.zeros_like(x)

    if len(x) != len(y):
        raise ValueError('x and y must be the same length')

    self.name = name
    if not isinstance(x, np.ndarray):
        x = np.array(x)
    if not isinstance(y, np.ndarray):
        y = np.array(y)
    if not isinstance(ye, np.ndarray):
        ye = np.array(ye)
    if not isinstance(xe, np.ndarray):
        xe = np.array(xe)

    self.x = x
    self.y = y
    self.ye = ye
    self.xe = xe

    self.x_label = x_label
    self.y_label = y_label

    self._color = None

data_points()

Data points.

Source code in src/easyreflectometry/data/data_store.py
101
102
103
def data_points(self) -> tuple[float, float, float, float]:
    """Data points."""
    return zip(self.x, self.y, self.ye, self.xe)

__repr__()

Repr function.

Source code in src/easyreflectometry/data/data_store.py
105
106
107
def __repr__(self) -> str:
    """Repr function."""
    return "1D DataStore of '{:s}' Vs '{:s}' with {} data points".format(self.x_label, self.y_label, len(self.x))

Multilayer

Bases: BaseAssembly

A multi layer is build from a single or a list of Layer or LayerCollection.

The multi layer will arrange the layers as slabs, allowing the reflectometry to be determined from them. The front layer is where the neutron beam starts in, it has an index of 0.

More information about the usage of this assembly is available in the multilayer documentation_

.. _multilayer documentation: ../sample/assemblies_library.html#multilayer

Source code in src/easyreflectometry/sample/assemblies/multilayer.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
class Multilayer(BaseAssembly):
    """A multi layer is build from a single or a list of `Layer` or `LayerCollection`.

    The multi layer will arrange the layers as slabs, allowing the reflectometry to be determined from them.
    The front layer is where the neutron beam starts in, it has an index of 0.

    More information about the usage of this assembly is available in the
    `multilayer documentation`_

    .. _`multilayer documentation`: ../sample/assemblies_library.html#multilayer
    """

    def __init__(
        self,
        layers: Union[Layer, list[Layer], LayerCollection, None] = None,
        name: str = 'EasyMultilayer',
        unique_name: Optional[str] = None,
        interface=None,
        type: str = 'Multi-layer',
        populate_if_none: Optional[bool] = True,
        conformal_thickness: bool = False,
        conformal_roughness: bool = False,
    ):
        """Constructor.

        Parameters
        ----------
        populate_if_none : Optional[bool], optional
            By default, True.
        unique_name : Optional[str], optional
            By default, None.
        layers : Union[Layer, list[Layer], LayerCollection, None], optional
            The layers that make up the multi-layer. By default, None.
        name : str, optional
            Name for multi layer. By default, 'EasyMultilayer'.
        interface :
            Calculator interface. By default, None.
        type : str, optional
            Type of the constructed instance. By default, 'Multi-layer'.
        conformal_thickness : bool, optional
            Tie every layer's thickness to the front layer's. Serialization
            reads the current graph state through the matching property, so
            the ties are rebuilt on ``from_dict``. By default, False.
        conformal_roughness : bool, optional
            Tie every layer's roughness to the front layer's, likewise
            persistent. By default, False.
        """
        if layers is None:
            if populate_if_none:
                layers = LayerCollection([Layer(interface=interface)])
            else:
                layers = LayerCollection()
        elif isinstance(layers, Layer):
            layers = LayerCollection(layers, name=layers.name)
        elif isinstance(layers, list):
            layers = LayerCollection(*layers, name='/'.join([layer.name for layer in layers]))
        # Needed to ensure an empty list is created when saving and instatiating the object as_dict -> from_dict
        # Else collisions might occur in global_object.map
        self.populate_if_none = False

        super().__init__(
            name=name,
            type=type,
            interface=interface,
            layers=layers,
            unique_name=unique_name,
        )
        if conformal_thickness:
            self.conformal_thickness = True
        if conformal_roughness:
            self.conformal_roughness = True

    def add_layer(self, *layers: tuple[Layer]) -> None:
        """Add a layer to the multi layer.

        Parameters
        ----------
        *layers : tuple[Layer]
            Layers to add to the multi layer.
        """
        for arg in layers:
            if issubclass(arg.__class__, Layer):
                self.layers.append(arg)
                if self.interface is not None:
                    self.interface().add_layer_to_item(arg.unique_name, self.unique_name)

    def duplicate_layer(self, idx: int) -> None:
        """Duplicate a given layer.

        Parameters
        ----------
        idx : int
            Index of layer to duplicate.
        """
        to_duplicate = self.layers[idx]
        duplicate_layer = Layer(
            material=to_duplicate.material,
            thickness=to_duplicate.thickness.value,
            roughness=to_duplicate.roughness.value,
            name=to_duplicate.name + ' duplicate',
        )
        self.add_layer(duplicate_layer)

    def remove_layer(self, idx: int) -> None:
        """Remove a layer from the item.

        Parameters
        ----------
        idx : int
            Index of layer to remove.
        """
        if self.interface is not None:
            self.interface().remove_layer_from_item(self.layers[idx].unique_name, self.unique_name)
        del self.layers[idx]

    # Representation
    @property
    def _dict_repr(self) -> dict:
        """A simplified dict representation."""
        return {self.name: self.layers._dict_repr}

    @classmethod
    def from_dict(cls, data: dict) -> Multilayer:
        """Create a Multilayer from a dictionary."""
        multilayer = super().from_dict(data)
        return multilayer

__init__(layers=None, name='EasyMultilayer', unique_name=None, interface=None, type='Multi-layer', populate_if_none=True, conformal_thickness=False, conformal_roughness=False)

Constructor.

Parameters:

Name Type Description Default
populate_if_none Optional[bool]

By default, True.

True
unique_name Optional[str]

By default, None.

None
layers Union[Layer, list[Layer], LayerCollection, None]

The layers that make up the multi-layer. By default, None.

None
name str

Name for multi layer. By default, 'EasyMultilayer'.

'EasyMultilayer'
interface

Calculator interface. By default, None.

None
type str

Type of the constructed instance. By default, 'Multi-layer'.

'Multi-layer'
conformal_thickness bool

Tie every layer's thickness to the front layer's. Serialization reads the current graph state through the matching property, so the ties are rebuilt on from_dict. By default, False.

False
conformal_roughness bool

Tie every layer's roughness to the front layer's, likewise persistent. By default, False.

False
Source code in src/easyreflectometry/sample/assemblies/multilayer.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def __init__(
    self,
    layers: Union[Layer, list[Layer], LayerCollection, None] = None,
    name: str = 'EasyMultilayer',
    unique_name: Optional[str] = None,
    interface=None,
    type: str = 'Multi-layer',
    populate_if_none: Optional[bool] = True,
    conformal_thickness: bool = False,
    conformal_roughness: bool = False,
):
    """Constructor.

    Parameters
    ----------
    populate_if_none : Optional[bool], optional
        By default, True.
    unique_name : Optional[str], optional
        By default, None.
    layers : Union[Layer, list[Layer], LayerCollection, None], optional
        The layers that make up the multi-layer. By default, None.
    name : str, optional
        Name for multi layer. By default, 'EasyMultilayer'.
    interface :
        Calculator interface. By default, None.
    type : str, optional
        Type of the constructed instance. By default, 'Multi-layer'.
    conformal_thickness : bool, optional
        Tie every layer's thickness to the front layer's. Serialization
        reads the current graph state through the matching property, so
        the ties are rebuilt on ``from_dict``. By default, False.
    conformal_roughness : bool, optional
        Tie every layer's roughness to the front layer's, likewise
        persistent. By default, False.
    """
    if layers is None:
        if populate_if_none:
            layers = LayerCollection([Layer(interface=interface)])
        else:
            layers = LayerCollection()
    elif isinstance(layers, Layer):
        layers = LayerCollection(layers, name=layers.name)
    elif isinstance(layers, list):
        layers = LayerCollection(*layers, name='/'.join([layer.name for layer in layers]))
    # Needed to ensure an empty list is created when saving and instatiating the object as_dict -> from_dict
    # Else collisions might occur in global_object.map
    self.populate_if_none = False

    super().__init__(
        name=name,
        type=type,
        interface=interface,
        layers=layers,
        unique_name=unique_name,
    )
    if conformal_thickness:
        self.conformal_thickness = True
    if conformal_roughness:
        self.conformal_roughness = True

add_layer(*layers)

Add a layer to the multi layer.

Parameters:

Name Type Description Default
*layers tuple[Layer]

Layers to add to the multi layer.

()
Source code in src/easyreflectometry/sample/assemblies/multilayer.py
86
87
88
89
90
91
92
93
94
95
96
97
98
def add_layer(self, *layers: tuple[Layer]) -> None:
    """Add a layer to the multi layer.

    Parameters
    ----------
    *layers : tuple[Layer]
        Layers to add to the multi layer.
    """
    for arg in layers:
        if issubclass(arg.__class__, Layer):
            self.layers.append(arg)
            if self.interface is not None:
                self.interface().add_layer_to_item(arg.unique_name, self.unique_name)

duplicate_layer(idx)

Duplicate a given layer.

Parameters:

Name Type Description Default
idx int

Index of layer to duplicate.

required
Source code in src/easyreflectometry/sample/assemblies/multilayer.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def duplicate_layer(self, idx: int) -> None:
    """Duplicate a given layer.

    Parameters
    ----------
    idx : int
        Index of layer to duplicate.
    """
    to_duplicate = self.layers[idx]
    duplicate_layer = Layer(
        material=to_duplicate.material,
        thickness=to_duplicate.thickness.value,
        roughness=to_duplicate.roughness.value,
        name=to_duplicate.name + ' duplicate',
    )
    self.add_layer(duplicate_layer)

remove_layer(idx)

Remove a layer from the item.

Parameters:

Name Type Description Default
idx int

Index of layer to remove.

required
Source code in src/easyreflectometry/sample/assemblies/multilayer.py
117
118
119
120
121
122
123
124
125
126
127
def remove_layer(self, idx: int) -> None:
    """Remove a layer from the item.

    Parameters
    ----------
    idx : int
        Index of layer to remove.
    """
    if self.interface is not None:
        self.interface().remove_layer_from_item(self.layers[idx].unique_name, self.unique_name)
    del self.layers[idx]

from_dict(data) classmethod

Create a Multilayer from a dictionary.

Source code in src/easyreflectometry/sample/assemblies/multilayer.py
135
136
137
138
139
@classmethod
def from_dict(cls, data: dict) -> Multilayer:
    """Create a Multilayer from a dictionary."""
    multilayer = super().from_dict(data)
    return multilayer

RepeatingMultilayer

Bases: Multilayer

A repeating multi layer is build from a Multilayer and which it repeats for a given number of times. This enables a computational efficiency in many reflectometry engines as the operation can be performed for a single Multilayer and cheaply combined for the appropriate number of repetitions.

More information about the usage of this assembly is available in the repeating multilayer documentation_

.. _repeating multilayer documentation: ../sample/assemblies_library.html#repeatingmultilayer

Source code in src/easyreflectometry/sample/assemblies/repeating_multilayer.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
class RepeatingMultilayer(Multilayer):
    """A repeating multi layer is build from a `Multilayer` and which it repeats
    for a given number of times. This enables a computational efficiency in many
    reflectometry engines as the operation can be performed for a single
    `Multilayer` and cheaply combined for the appropriate number of
    `repetitions`.

    More information about the usage of this assembly is available in the
    `repeating multilayer documentation`_

    .. _`repeating multilayer documentation`: ../sample/assemblies_library.html#repeatingmultilayer
    """

    def __init__(
        self,
        layers: Union[LayerCollection, Layer, list[Layer], None] = None,
        repetitions: Union[Parameter, int, None] = None,
        name: str = 'EasyRepeatingMultilayer',
        unique_name: Optional[str] = None,
        interface=None,
        populate_if_none: bool = True,
        conformal_thickness: bool = False,
        conformal_roughness: bool = False,
    ):
        """Constructor.

        Parameters
        ----------
        populate_if_none : bool, optional
            By default, True.
        unique_name : Optional[str], optional
            By default, None.
        layers : Union[LayerCollection, Layer, list[Layer], None], optional
            The layers that make up the multi-layer that will be repeated. By default, None.
        repetitions : Union[Parameter, int, None], optional
            Number of repetitions of the given series of layers. By default, None.
        name : str, optional
            Name for the repeating multi layer. By default, 'EasyRepeatingMultilayer'.
        interface :
            Calculator interface. By default, None.
        conformal_thickness : bool, optional
            Tie every layer's thickness to the front layer's (persisted). By default, False.
        conformal_roughness : bool, optional
            Tie every layer's roughness to the front layer's (persisted). By default, False.
        """
        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)

        if layers is None:
            if populate_if_none:
                layers = LayerCollection([Layer(interface=interface)])
            else:
                layers = LayerCollection()
        elif isinstance(layers, Layer):
            layers = LayerCollection(layers, name=layers.name)
        elif isinstance(layers, list):
            layers = LayerCollection(*layers, name='/'.join([layer.name for layer in layers]))

        repetitions = get_as_parameter(
            name='repetitions',
            value=repetitions,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Repetitions',
        )

        super().__init__(
            layers=layers,
            name=name,
            unique_name=unique_name,
            interface=None,
            type='Repeating Multi-layer',
            populate_if_none=False,
            conformal_thickness=conformal_thickness,
            conformal_roughness=conformal_roughness,
        )
        self._repetitions = repetitions

        if interface is not None:
            self.interface = interface

    @property
    def repetitions(self) -> Parameter:
        return self._repetitions

    @repetitions.setter
    def repetitions(self, value) -> None:
        self._repetitions.value = value

    # Representation
    @property
    def _dict_repr(self) -> dict:
        """A simplified dict representation."""
        d_dict = {self.name: self.layers._dict_repr}
        d_dict[self.name]['repetitions'] = float(self.repetitions.value)
        return d_dict

__init__(layers=None, repetitions=None, name='EasyRepeatingMultilayer', unique_name=None, interface=None, populate_if_none=True, conformal_thickness=False, conformal_roughness=False)

Constructor.

Parameters:

Name Type Description Default
populate_if_none bool

By default, True.

True
unique_name Optional[str]

By default, None.

None
layers Union[LayerCollection, Layer, list[Layer], None]

The layers that make up the multi-layer that will be repeated. By default, None.

None
repetitions Union[Parameter, int, None]

Number of repetitions of the given series of layers. By default, None.

None
name str

Name for the repeating multi layer. By default, 'EasyRepeatingMultilayer'.

'EasyRepeatingMultilayer'
interface

Calculator interface. By default, None.

None
conformal_thickness bool

Tie every layer's thickness to the front layer's (persisted). By default, False.

False
conformal_roughness bool

Tie every layer's roughness to the front layer's (persisted). By default, False.

False
Source code in src/easyreflectometry/sample/assemblies/repeating_multilayer.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def __init__(
    self,
    layers: Union[LayerCollection, Layer, list[Layer], None] = None,
    repetitions: Union[Parameter, int, None] = None,
    name: str = 'EasyRepeatingMultilayer',
    unique_name: Optional[str] = None,
    interface=None,
    populate_if_none: bool = True,
    conformal_thickness: bool = False,
    conformal_roughness: bool = False,
):
    """Constructor.

    Parameters
    ----------
    populate_if_none : bool, optional
        By default, True.
    unique_name : Optional[str], optional
        By default, None.
    layers : Union[LayerCollection, Layer, list[Layer], None], optional
        The layers that make up the multi-layer that will be repeated. By default, None.
    repetitions : Union[Parameter, int, None], optional
        Number of repetitions of the given series of layers. By default, None.
    name : str, optional
        Name for the repeating multi layer. By default, 'EasyRepeatingMultilayer'.
    interface :
        Calculator interface. By default, None.
    conformal_thickness : bool, optional
        Tie every layer's thickness to the front layer's (persisted). By default, False.
    conformal_roughness : bool, optional
        Tie every layer's roughness to the front layer's (persisted). By default, False.
    """
    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)

    if layers is None:
        if populate_if_none:
            layers = LayerCollection([Layer(interface=interface)])
        else:
            layers = LayerCollection()
    elif isinstance(layers, Layer):
        layers = LayerCollection(layers, name=layers.name)
    elif isinstance(layers, list):
        layers = LayerCollection(*layers, name='/'.join([layer.name for layer in layers]))

    repetitions = get_as_parameter(
        name='repetitions',
        value=repetitions,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Repetitions',
    )

    super().__init__(
        layers=layers,
        name=name,
        unique_name=unique_name,
        interface=None,
        type='Repeating Multi-layer',
        populate_if_none=False,
        conformal_thickness=conformal_thickness,
        conformal_roughness=conformal_roughness,
    )
    self._repetitions = repetitions

    if interface is not None:
        self.interface = interface

Sample

Bases: BaseCollection

A sample is a collection of assemblies that represent the structure for which experimental measurements exist.

Source code in src/easyreflectometry/sample/collections/sample.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
class Sample(BaseCollection):
    """A sample is a collection of assemblies that represent the structure for which experimental measurements exist."""

    def __init__(
        self,
        *assemblies: Optional[List[BaseAssembly]],
        name: str = 'EasySample',
        interface=None,
        unique_name: Optional[str] = None,
        populate_if_none: bool = True,
        **kwargs,
    ):
        """Constructor.

        Parameters
        ----------
        **kwargs :
        populate_if_none : bool, optional
            By default, True.
        unique_name : Optional[str], optional
            By default, None.
        *assemblies : Optional[List[BaseAssembly]]
            The assemblies in the sample.
        name : str, optional
            Name of the sample. By default, 'EasySample'.
        interface :
            Calculator interface. By default, None.
        """
        # `from_dict` (via `EasyList.from_dict`) passes the items as a single
        # list-positional arg; unpack that so validation and super() agree.
        if len(assemblies) == 1 and isinstance(assemblies[0], list):
            assemblies = tuple(assemblies[0])

        if not assemblies:
            if populate_if_none:
                assemblies = DEFAULT_ELEMENTS(interface)
            else:
                assemblies = []

        for assembly in assemblies:
            if not issubclass(type(assembly), BaseAssembly):
                raise ValueError('The elements must be an Assembly.')
        super().__init__(
            name,
            interface,
            *assemblies,
            unique_name=unique_name,
            populate_if_none=populate_if_none,
            **kwargs,
        )

    def add_assembly(self, assembly: Optional[BaseAssembly] = None):
        """Add an assembly to the sample.

        Parameters
        ----------
        assembly : Optional[BaseAssembly], optional
            Assembly to add. By default, None.
        """
        if assembly is None:
            assembly = Multilayer(
                name='EasyMultilayer added',
                interface=self.interface,
            )
        self.append(assembly)

    def duplicate_assembly(self, index: int):
        """Duplicate an assembly of the sample.

        Parameters
        ----------
        index : int
            Index of the assembly to duplicate.
        """
        # Order matters: RepeatingMultilayer and SurfactantLayer are subclasses of
        # BaseAssembly but not Multilayer; however a RepeatingMultilayer IS a
        # Multilayer, so the most-specific check must come first to avoid
        # serialising it through the wrong `from_dict`.
        to_be_duplicated = self[index]
        if isinstance(to_be_duplicated, RepeatingMultilayer):
            duplicate = RepeatingMultilayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
        elif isinstance(to_be_duplicated, SurfactantLayer):
            duplicate = SurfactantLayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
        elif isinstance(to_be_duplicated, Multilayer):
            duplicate = Multilayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
        else:
            raise TypeError(f'Cannot duplicate assembly of type {type(to_be_duplicated).__name__}')
        duplicate.name = duplicate.name + ' duplicate'
        self.append(duplicate)

    def move_up(self, index: int):
        """Move the assembly at the given index up in the sample.

        Parameters
        ----------
        index : int
            Index of the assembly to move up.
        """
        super().move_up(index)

    def move_down(self, index: int):
        """Move the assembly at the given index down in the sample.

        Parameters
        ----------
        index : int
            Index of the assembly to move down.
        """
        super().move_down(index)

    def remove_assembly(self, index: int):
        """Remove the assembly at the given index from the sample.

        Parameters
        ----------
        index : int
            Index of the assembly to remove.
        """
        self.pop(index)

    @property
    def superphase(self) -> Layer:
        """The superphase of the sample."""
        return self[0].front_layer

    @property
    def subphase(self) -> Layer:
        """The subphase of the sample."""
        # This assembly only got one layer
        if self[-1].back_layer is None:
            return self[-1].front_layer
        else:
            return self[-1].back_layer

    def volume_fraction_profile(
        self,
        z: Optional[np.ndarray] = None,
        *,
        max_delta_z: Optional[float] = None,
        merge_equivalent: bool = True,
    ) -> VolumeFractionProfile:
        """Volume fraction (occupancy) of every component of the sample versus depth.

        A thin wrapper around :func:`easyreflectometry.sample.volume_fraction.volume_fraction_profile`;
        see there for the parameters, the depth convention and the caveats.

        Parameters
        ----------
        z : np.ndarray, optional
            Depths (Å) to evaluate at; ``z = 0`` is the superphase / first layer interface.
            By default a 500-point grid spanning the sample.
        max_delta_z : float, optional
            Maximum spacing of the default grid. Cannot be combined with ``z``.
        merge_equivalent : bool, optional
            Merge materials with equal name and SLD into one component. By default, True.

        Returns
        -------
        VolumeFractionProfile
            The per-component profiles, summing to one at every depth.
        """
        return volume_fraction_profile(self, z, max_delta_z=max_delta_z, merge_equivalent=merge_equivalent)

superphase property

The superphase of the sample.

subphase property

The subphase of the sample.

__init__(*assemblies, name='EasySample', interface=None, unique_name=None, populate_if_none=True, **kwargs)

Constructor.

Parameters:

Name Type Description Default
**kwargs
{}
populate_if_none bool

By default, True.

True
unique_name Optional[str]

By default, None.

None
*assemblies Optional[List[BaseAssembly]]

The assemblies in the sample.

()
name str

Name of the sample. By default, 'EasySample'.

'EasySample'
interface

Calculator interface. By default, None.

None
Source code in src/easyreflectometry/sample/collections/sample.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def __init__(
    self,
    *assemblies: Optional[List[BaseAssembly]],
    name: str = 'EasySample',
    interface=None,
    unique_name: Optional[str] = None,
    populate_if_none: bool = True,
    **kwargs,
):
    """Constructor.

    Parameters
    ----------
    **kwargs :
    populate_if_none : bool, optional
        By default, True.
    unique_name : Optional[str], optional
        By default, None.
    *assemblies : Optional[List[BaseAssembly]]
        The assemblies in the sample.
    name : str, optional
        Name of the sample. By default, 'EasySample'.
    interface :
        Calculator interface. By default, None.
    """
    # `from_dict` (via `EasyList.from_dict`) passes the items as a single
    # list-positional arg; unpack that so validation and super() agree.
    if len(assemblies) == 1 and isinstance(assemblies[0], list):
        assemblies = tuple(assemblies[0])

    if not assemblies:
        if populate_if_none:
            assemblies = DEFAULT_ELEMENTS(interface)
        else:
            assemblies = []

    for assembly in assemblies:
        if not issubclass(type(assembly), BaseAssembly):
            raise ValueError('The elements must be an Assembly.')
    super().__init__(
        name,
        interface,
        *assemblies,
        unique_name=unique_name,
        populate_if_none=populate_if_none,
        **kwargs,
    )

add_assembly(assembly=None)

Add an assembly to the sample.

Parameters:

Name Type Description Default
assembly Optional[BaseAssembly]

Assembly to add. By default, None.

None
Source code in src/easyreflectometry/sample/collections/sample.py
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def add_assembly(self, assembly: Optional[BaseAssembly] = None):
    """Add an assembly to the sample.

    Parameters
    ----------
    assembly : Optional[BaseAssembly], optional
        Assembly to add. By default, None.
    """
    if assembly is None:
        assembly = Multilayer(
            name='EasyMultilayer added',
            interface=self.interface,
        )
    self.append(assembly)

duplicate_assembly(index)

Duplicate an assembly of the sample.

Parameters:

Name Type Description Default
index int

Index of the assembly to duplicate.

required
Source code in src/easyreflectometry/sample/collections/sample.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def duplicate_assembly(self, index: int):
    """Duplicate an assembly of the sample.

    Parameters
    ----------
    index : int
        Index of the assembly to duplicate.
    """
    # Order matters: RepeatingMultilayer and SurfactantLayer are subclasses of
    # BaseAssembly but not Multilayer; however a RepeatingMultilayer IS a
    # Multilayer, so the most-specific check must come first to avoid
    # serialising it through the wrong `from_dict`.
    to_be_duplicated = self[index]
    if isinstance(to_be_duplicated, RepeatingMultilayer):
        duplicate = RepeatingMultilayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
    elif isinstance(to_be_duplicated, SurfactantLayer):
        duplicate = SurfactantLayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
    elif isinstance(to_be_duplicated, Multilayer):
        duplicate = Multilayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
    else:
        raise TypeError(f'Cannot duplicate assembly of type {type(to_be_duplicated).__name__}')
    duplicate.name = duplicate.name + ' duplicate'
    self.append(duplicate)

move_up(index)

Move the assembly at the given index up in the sample.

Parameters:

Name Type Description Default
index int

Index of the assembly to move up.

required
Source code in src/easyreflectometry/sample/collections/sample.py
120
121
122
123
124
125
126
127
128
def move_up(self, index: int):
    """Move the assembly at the given index up in the sample.

    Parameters
    ----------
    index : int
        Index of the assembly to move up.
    """
    super().move_up(index)

move_down(index)

Move the assembly at the given index down in the sample.

Parameters:

Name Type Description Default
index int

Index of the assembly to move down.

required
Source code in src/easyreflectometry/sample/collections/sample.py
130
131
132
133
134
135
136
137
138
def move_down(self, index: int):
    """Move the assembly at the given index down in the sample.

    Parameters
    ----------
    index : int
        Index of the assembly to move down.
    """
    super().move_down(index)

remove_assembly(index)

Remove the assembly at the given index from the sample.

Parameters:

Name Type Description Default
index int

Index of the assembly to remove.

required
Source code in src/easyreflectometry/sample/collections/sample.py
140
141
142
143
144
145
146
147
148
def remove_assembly(self, index: int):
    """Remove the assembly at the given index from the sample.

    Parameters
    ----------
    index : int
        Index of the assembly to remove.
    """
    self.pop(index)

volume_fraction_profile(z=None, *, max_delta_z=None, merge_equivalent=True)

Volume fraction (occupancy) of every component of the sample versus depth.

A thin wrapper around :func:easyreflectometry.sample.volume_fraction.volume_fraction_profile; see there for the parameters, the depth convention and the caveats.

Parameters:

Name Type Description Default
z np.ndarray

Depths (Å) to evaluate at; z = 0 is the superphase / first layer interface. By default a 500-point grid spanning the sample.

None
max_delta_z float

Maximum spacing of the default grid. Cannot be combined with z.

None
merge_equivalent bool

Merge materials with equal name and SLD into one component. By default, True.

True

Returns:

Type Description
VolumeFractionProfile

The per-component profiles, summing to one at every depth.

Source code in src/easyreflectometry/sample/collections/sample.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def volume_fraction_profile(
    self,
    z: Optional[np.ndarray] = None,
    *,
    max_delta_z: Optional[float] = None,
    merge_equivalent: bool = True,
) -> VolumeFractionProfile:
    """Volume fraction (occupancy) of every component of the sample versus depth.

    A thin wrapper around :func:`easyreflectometry.sample.volume_fraction.volume_fraction_profile`;
    see there for the parameters, the depth convention and the caveats.

    Parameters
    ----------
    z : np.ndarray, optional
        Depths (Å) to evaluate at; ``z = 0`` is the superphase / first layer interface.
        By default a 500-point grid spanning the sample.
    max_delta_z : float, optional
        Maximum spacing of the default grid. Cannot be combined with ``z``.
    merge_equivalent : bool, optional
        Merge materials with equal name and SLD into one component. By default, True.

    Returns
    -------
    VolumeFractionProfile
        The per-component profiles, summing to one at every depth.
    """
    return volume_fraction_profile(self, z, max_delta_z=max_delta_z, merge_equivalent=merge_equivalent)

Layer

Bases: BaseCore

Source code in src/easyreflectometry/sample/elements/layers/layer.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
class Layer(BaseCore):
    def __init__(
        self,
        material: Union[Material, None] = None,
        thickness: Union[Parameter, float, None] = None,
        roughness: Union[Parameter, float, None] = None,
        name: str = 'EasyLayer',
        unique_name: Optional[str] = None,
        interface=None,
        magnetism: Union[LayerMagnetism, None] = None,
        rho_m_coupled: bool = False,
    ):
        """Constructor.

        Parameters
        ----------
        unique_name : Optional[str], optional
            By default, None.
        material : Union[Material, None], optional
            The material for the layer. By default, None.
        thickness : Union[Parameter, float, None], optional
            Layer thickness in Angstrom. By default, None.
        roughness : Union[Parameter, float, None], optional
            Upper roughness on the layer in Angstrom. By default, None.
        magnetism : Union[LayerMagnetism, None], optional
            Magnetic properties of the layer; None for a non-magnetic layer.
            By default, None.
        rho_m_coupled : bool, optional
            Take the magnetic SLD from the material (see :attr:`rho_m_coupled`).
            By default, False.
        name : str, optional
            Name of the layer. By default, 'EasyLayer'.
        interface :
            Interface object. By default, None.
        """
        if material is None:
            material = Material(interface=interface)

        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)

        thickness_value = thickness
        thickness = get_as_parameter(
            name='thickness',
            value=thickness,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Thickness',
        )
        thickness.default_limits_pending = not isinstance(thickness_value, Parameter)

        roughness_value = roughness
        roughness = get_as_parameter(
            name='roughness',
            value=roughness,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Roughness',
        )
        roughness.default_limits_pending = not isinstance(roughness_value, Parameter)

        super().__init__(name=name, unique_name=unique_name)
        self._material = material
        self._thickness = thickness
        self._roughness = roughness
        self._magnetism = magnetism
        self._rho_m_coupled = False
        if rho_m_coupled:
            self.rho_m_coupled = True

        if interface is not None:
            self.interface = interface

    # ----- interface (override BaseCore's to switch on calculator magnetism) -----

    @BaseCore.interface.setter
    def interface(self, new_interface) -> None:
        """Set the interface; runs `generate_bindings` and, for a magnetic layer,
        enables magnetism on the calculator (raising if it does not support it).
        """
        BaseCore.interface.fset(self, new_interface)
        if new_interface is not None and self._magnetism is not None:
            self._enable_calculator_magnetism()

    def _enable_calculator_magnetism(self) -> None:
        """Turn on magnetism on the attached calculator.

        Raises `NotImplementedError` when the active calculator cannot model
        magnetic samples (e.g. refnx or bornagain).
        """
        self.interface().include_magnetism = True

    @property
    def material(self) -> Material:
        return self._material

    @material.setter
    def material(self, value: Material) -> None:
        coupled = self.rho_m_coupled
        self._material = value
        self._reconcile_rho_m_coupling(coupled)

    @property
    def thickness(self) -> Parameter:
        return self._thickness

    @thickness.setter
    def thickness(self, value: float) -> None:
        self._thickness.value = value

    @property
    def roughness(self) -> Parameter:
        return self._roughness

    @roughness.setter
    def roughness(self, value: float) -> None:
        self._roughness.value = value

    @property
    def magnetism(self) -> Optional[LayerMagnetism]:
        return self._magnetism

    @magnetism.setter
    def magnetism(self, value: Optional[LayerMagnetism]) -> None:
        """Attach or remove the magnetic properties of this layer.

        Attaching regenerates the calculator bindings so `rho_m`/`theta_m` become
        live on the backend; removing drops the layer's magnetic state from the
        backend (and switches calculator magnetism off entirely when this was the
        last magnetic layer).
        """
        previous = self._magnetism
        coupled = self.rho_m_coupled
        if previous is not None and previous is not value:
            if value is None and self.interface is not None:
                self.interface().remove_layer_magnetism(self.unique_name)
            # The detached object must not follow the material any more...
            if coupled:
                _release(previous.rho_m)
            # ...and its value changes must no longer reach the backend.
            # `property()` (fget/fset/fdel all None) is easyscience's own "no
            # callback" sentinel -- `Parameter.__copy__` sets it the same way --
            # so a later `fset` guard in `Parameter` cleanly no-ops.
            previous.rho_m._callback = property()
            previous.theta_m._callback = property()
        self._magnetism = value
        self._reconcile_rho_m_coupling(coupled)
        if value is not None and self.interface is not None:
            self._enable_calculator_magnetism()
            self.generate_bindings()

    @property
    def rho_m_coupled(self) -> bool:
        """Whether the layer's magnetic SLD is the material's ``rho_m``
        (a :class:`~easyreflectometry.sample.MaterialMagnetic`).

        When True, ``magnetism.rho_m`` is a dependent parameter following the
        current material; the layer keeps its own ``theta_m``. Opt-in: layers of
        one material may carry different net moments. Setting it attaches a
        default :class:`LayerMagnetism` when the layer has none, and requires a
        material with ``rho_m``. Uncoupling keeps the current value. Removing
        the magnetism, or a user ``unconstrain`` of ``rho_m``, uncouples.

        Only the layer's own wiring counts: a user constraint placed on
        ``rho_m`` (:func:`~easyreflectometry.constraints.constrain`) reads as
        uncoupled, is saved as the user constraint it is, and is left alone
        when the material is later replaced. Setting this to True explicitly
        replaces such a constraint with the coupling.
        """
        if not self._rho_m_coupled or self._magnetism is None:
            return False
        rho_m = self._magnetism.rho_m
        if rho_m.independent or getattr(rho_m, USER_CONSTRAINT_FLAG, False):
            return False
        source = getattr(self._material, 'rho_m', None)
        dependency_map = rho_m.dependency_map
        return source is not None and len(dependency_map) == 1 and dependency_map.get('rho_m') is source

    @rho_m_coupled.setter
    def rho_m_coupled(self, couple: bool) -> None:
        couple = bool(couple)
        if couple and getattr(self._material, 'rho_m', None) is None:
            raise ValueError(f'The material of layer {self.name!r} has no magnetic SLD to couple to.')
        if couple and self._magnetism is None:
            self.magnetism = LayerMagnetism()
        if not couple and self.rho_m_coupled:
            _release(self._magnetism.rho_m)
        self._reconcile_rho_m_coupling(couple)

    def _reconcile_rho_m_coupling(self, couple: bool) -> None:
        """Re-establish the invariant of :attr:`rho_m_coupled` after a change of
        material, magnetism or coupling: coupled means the current magnetism's
        ``rho_m`` follows the current material's ``rho_m``.

        `couple` is the state to keep, read *before* the change (afterwards the
        dependency may point at a replaced object).
        """
        self._rho_m_coupled = False
        if not couple or self._magnetism is None:
            return
        rho_m = self._magnetism.rho_m
        source = getattr(self._material, 'rho_m', None)
        if source is None:
            warnings.warn(
                f'The material of layer {self.name!r} has no magnetic SLD; '
                'the layer keeps its current rho_m and is no longer coupled.',
                UserWarning,
                stacklevel=3,
            )
            if not rho_m.independent:
                _release(rho_m)
            return
        # Drops any previous dependency, including a stale user-constraint marker
        # that would make the project persist this internal wiring.
        unconstrain(rho_m)
        rho_m.make_dependent_on(dependency_expression='rho_m', dependency_map={'rho_m': source})
        self._rho_m_coupled = True

    def assign_material(self, material: Material) -> None:
        """Assign a material to the layer interface.

        Parameters
        ----------
        material : Material
            The material to assign to the layer.
        """
        coupled = self.rho_m_coupled
        self._material = material
        self._reconcile_rho_m_coupling(coupled)
        if self.interface is not None:
            self.interface().assign_material_to_layer(self.material.unique_name, self.unique_name)

    # Representation
    @property
    def _dict_repr(self) -> dict[str, str]:
        """A simplified dict representation."""
        this_dict = {
            self.name: {
                'material': self.material._dict_repr,
                'thickness': f'{self.thickness.value:.3f} {self.thickness.unit}',
                'roughness': f'{self.roughness.value:.3f} {self.roughness.unit}',
            }
        }
        if self._magnetism is not None:
            this_dict[self.name]['magnetism'] = self._magnetism._dict_repr
        return this_dict

rho_m_coupled property writable

Whether the layer's magnetic SLD is the material's rho_m (a :class:~easyreflectometry.sample.MaterialMagnetic).

When True, magnetism.rho_m is a dependent parameter following the current material; the layer keeps its own theta_m. Opt-in: layers of one material may carry different net moments. Setting it attaches a default :class:LayerMagnetism when the layer has none, and requires a material with rho_m. Uncoupling keeps the current value. Removing the magnetism, or a user unconstrain of rho_m, uncouples.

Only the layer's own wiring counts: a user constraint placed on rho_m (:func:~easyreflectometry.constraints.constrain) reads as uncoupled, is saved as the user constraint it is, and is left alone when the material is later replaced. Setting this to True explicitly replaces such a constraint with the coupling.

__init__(material=None, thickness=None, roughness=None, name='EasyLayer', unique_name=None, interface=None, magnetism=None, rho_m_coupled=False)

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
material Union[Material, None]

The material for the layer. By default, None.

None
thickness Union[Parameter, float, None]

Layer thickness in Angstrom. By default, None.

None
roughness Union[Parameter, float, None]

Upper roughness on the layer in Angstrom. By default, None.

None
magnetism Union[LayerMagnetism, None]

Magnetic properties of the layer; None for a non-magnetic layer. By default, None.

None
rho_m_coupled bool

Take the magnetic SLD from the material (see :attr:rho_m_coupled). By default, False.

False
name str

Name of the layer. By default, 'EasyLayer'.

'EasyLayer'
interface

Interface object. By default, None.

None
Source code in src/easyreflectometry/sample/elements/layers/layer.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def __init__(
    self,
    material: Union[Material, None] = None,
    thickness: Union[Parameter, float, None] = None,
    roughness: Union[Parameter, float, None] = None,
    name: str = 'EasyLayer',
    unique_name: Optional[str] = None,
    interface=None,
    magnetism: Union[LayerMagnetism, None] = None,
    rho_m_coupled: bool = False,
):
    """Constructor.

    Parameters
    ----------
    unique_name : Optional[str], optional
        By default, None.
    material : Union[Material, None], optional
        The material for the layer. By default, None.
    thickness : Union[Parameter, float, None], optional
        Layer thickness in Angstrom. By default, None.
    roughness : Union[Parameter, float, None], optional
        Upper roughness on the layer in Angstrom. By default, None.
    magnetism : Union[LayerMagnetism, None], optional
        Magnetic properties of the layer; None for a non-magnetic layer.
        By default, None.
    rho_m_coupled : bool, optional
        Take the magnetic SLD from the material (see :attr:`rho_m_coupled`).
        By default, False.
    name : str, optional
        Name of the layer. By default, 'EasyLayer'.
    interface :
        Interface object. By default, None.
    """
    if material is None:
        material = Material(interface=interface)

    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)

    thickness_value = thickness
    thickness = get_as_parameter(
        name='thickness',
        value=thickness,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Thickness',
    )
    thickness.default_limits_pending = not isinstance(thickness_value, Parameter)

    roughness_value = roughness
    roughness = get_as_parameter(
        name='roughness',
        value=roughness,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Roughness',
    )
    roughness.default_limits_pending = not isinstance(roughness_value, Parameter)

    super().__init__(name=name, unique_name=unique_name)
    self._material = material
    self._thickness = thickness
    self._roughness = roughness
    self._magnetism = magnetism
    self._rho_m_coupled = False
    if rho_m_coupled:
        self.rho_m_coupled = True

    if interface is not None:
        self.interface = interface

interface(new_interface)

Set the interface; runs generate_bindings and, for a magnetic layer, enables magnetism on the calculator (raising if it does not support it).

Source code in src/easyreflectometry/sample/elements/layers/layer.py
115
116
117
118
119
120
121
122
@BaseCore.interface.setter
def interface(self, new_interface) -> None:
    """Set the interface; runs `generate_bindings` and, for a magnetic layer,
    enables magnetism on the calculator (raising if it does not support it).
    """
    BaseCore.interface.fset(self, new_interface)
    if new_interface is not None and self._magnetism is not None:
        self._enable_calculator_magnetism()

assign_material(material)

Assign a material to the layer interface.

Parameters:

Name Type Description Default
material Material

The material to assign to the layer.

required
Source code in src/easyreflectometry/sample/elements/layers/layer.py
258
259
260
261
262
263
264
265
266
267
268
269
270
def assign_material(self, material: Material) -> None:
    """Assign a material to the layer interface.

    Parameters
    ----------
    material : Material
        The material to assign to the layer.
    """
    coupled = self.rho_m_coupled
    self._material = material
    self._reconcile_rho_m_coupling(coupled)
    if self.interface is not None:
        self.interface().assign_material_to_layer(self.material.unique_name, self.unique_name)

LayerMagnetism

Bases: BaseCore

Magnetic properties of a layer: magnetic SLD amplitude and in-plane moment angle.

Attach to a Layer via its magnetism argument/property to make the layer magnetic. Both attributes are Parameter objects, so they can be fitted and serialized like any other sample parameter. A calculator that supports magnetism (refl1d) binds them as magnetism_rhoM / magnetism_thetaM on the corresponding slab.

Source code in src/easyreflectometry/sample/elements/layers/layer_magnetism.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
class LayerMagnetism(BaseCore):
    """Magnetic properties of a layer: magnetic SLD amplitude and in-plane moment angle.

    Attach to a `Layer` via its `magnetism` argument/property to make the layer magnetic.
    Both attributes are `Parameter` objects, so they can be fitted and serialized like any
    other sample parameter. A calculator that supports magnetism (refl1d) binds them as
    `magnetism_rhoM` / `magnetism_thetaM` on the corresponding slab.
    """

    def __init__(
        self,
        rho_m: Union[Parameter, float, None] = None,
        theta_m: Union[Parameter, float, None] = None,
        name: str = 'EasyLayerMagnetism',
        unique_name: Optional[str] = None,
        interface=None,
    ):
        """Constructor.

        Parameters
        ----------
        rho_m : Union[Parameter, float, None], optional
            Magnetic scattering length density in e-6 per squared angstrom. By default, 0.
        theta_m : Union[Parameter, float, None], optional
            In-plane moment angle in degrees. By default, 270 (aligned with the default
            guide field, i.e. no spin-flip).
        name : str, optional
            Name of the magnetism element. By default, 'EasyLayerMagnetism'.
        unique_name : Optional[str], optional
            By default, None.
        interface :
            Calculator interface. By default, None.
        """
        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)

        rho_m_value = rho_m
        rho_m = get_as_parameter(
            name='rho_m',
            value=rho_m,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_RhoM',
        )
        # The default bounds are infinite; `Project._sync_parameter_states`
        # narrows them to the shared SLD window unless the caller passed an
        # explicit Parameter with its own bounds (same contract as `Layer`).
        rho_m.default_limits_pending = not isinstance(rho_m_value, Parameter)
        theta_m = get_as_parameter(
            name='theta_m',
            value=theta_m,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_ThetaM',
        )

        super().__init__(name=name, unique_name=unique_name)
        self._rho_m = rho_m
        self._theta_m = theta_m

        if interface is not None:
            self.interface = interface

    @property
    def rho_m(self) -> Parameter:
        return self._rho_m

    @rho_m.setter
    def rho_m(self, value: float) -> None:
        self._rho_m.value = value

    @property
    def theta_m(self) -> Parameter:
        return self._theta_m

    @theta_m.setter
    def theta_m(self, value: float) -> None:
        self._theta_m.value = value

    # Representation
    @property
    def _dict_repr(self) -> dict[str, str]:
        """A simplified dict representation."""
        return {
            self.name: {
                'rho_m': f'{self._rho_m.value:.3f}e-6 {self._rho_m.unit}',
                'theta_m': f'{self._theta_m.value:.3f} {self._theta_m.unit}',
            }
        }

__init__(rho_m=None, theta_m=None, name='EasyLayerMagnetism', unique_name=None, interface=None)

Constructor.

Parameters:

Name Type Description Default
rho_m Union[Parameter, float, None]

Magnetic scattering length density in e-6 per squared angstrom. By default, 0.

None
theta_m Union[Parameter, float, None]

In-plane moment angle in degrees. By default, 270 (aligned with the default guide field, i.e. no spin-flip).

None
name str

Name of the magnetism element. By default, 'EasyLayerMagnetism'.

'EasyLayerMagnetism'
unique_name Optional[str]

By default, None.

None
interface

Calculator interface. By default, None.

None
Source code in src/easyreflectometry/sample/elements/layers/layer_magnetism.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def __init__(
    self,
    rho_m: Union[Parameter, float, None] = None,
    theta_m: Union[Parameter, float, None] = None,
    name: str = 'EasyLayerMagnetism',
    unique_name: Optional[str] = None,
    interface=None,
):
    """Constructor.

    Parameters
    ----------
    rho_m : Union[Parameter, float, None], optional
        Magnetic scattering length density in e-6 per squared angstrom. By default, 0.
    theta_m : Union[Parameter, float, None], optional
        In-plane moment angle in degrees. By default, 270 (aligned with the default
        guide field, i.e. no spin-flip).
    name : str, optional
        Name of the magnetism element. By default, 'EasyLayerMagnetism'.
    unique_name : Optional[str], optional
        By default, None.
    interface :
        Calculator interface. By default, None.
    """
    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)

    rho_m_value = rho_m
    rho_m = get_as_parameter(
        name='rho_m',
        value=rho_m,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_RhoM',
    )
    # The default bounds are infinite; `Project._sync_parameter_states`
    # narrows them to the shared SLD window unless the caller passed an
    # explicit Parameter with its own bounds (same contract as `Layer`).
    rho_m.default_limits_pending = not isinstance(rho_m_value, Parameter)
    theta_m = get_as_parameter(
        name='theta_m',
        value=theta_m,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_ThetaM',
    )

    super().__init__(name=name, unique_name=unique_name)
    self._rho_m = rho_m
    self._theta_m = theta_m

    if interface is not None:
        self.interface = interface

Material

Bases: BaseCore

Source code in src/easyreflectometry/sample/elements/materials/material.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
class Material(BaseCore):
    def __init__(
        self,
        sld: Union[Parameter, float, None] = None,
        isld: Union[Parameter, float, None] = None,
        name: str = 'EasyMaterial',
        unique_name: Optional[str] = None,
        interface=None,
    ):
        """Constructor.

        Parameters
        ----------
        unique_name : Optional[str], optional
            By default, None.
        sld : Union[Parameter, float, None], optional
            Real scattering length density. By default, None.
        isld : Union[Parameter, float, None], optional
            Imaginary scattering length density. By default, None.
        name : str, optional
            Name of the material. By default, 'EasyMaterial'.
        interface :
            Calculator interface. By default, None.
        """
        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)

        sld = get_as_parameter(
            name='sld',
            value=sld,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Sld',
        )
        apply_default_limits(sld, 'sld')

        isld = get_as_parameter(
            name='isld',
            value=isld,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Isld',
        )
        apply_default_limits(isld, 'isld')

        super().__init__(name=name, unique_name=unique_name)
        self._sld = sld
        self._isld = isld

        if interface is not None:
            self.interface = interface

    @property
    def sld(self) -> Parameter:
        return self._sld

    @sld.setter
    def sld(self, value: float) -> None:
        self._sld.value = value

    @property
    def isld(self) -> Parameter:
        return self._isld

    @isld.setter
    def isld(self, value: float) -> None:
        self._isld.value = value

    # Representation
    @property
    def _dict_repr(self) -> dict[str, str]:
        """A simplified dict representation."""
        return {
            self.name: {
                'sld': f'{self._sld.value:.3f}e-6 {self._sld.unit}',
                'isld': f'{self._isld.value:.3f}e-6 {self._isld.unit}',
            }
        }

__init__(sld=None, isld=None, name='EasyMaterial', unique_name=None, interface=None)

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
sld Union[Parameter, float, None]

Real scattering length density. By default, None.

None
isld Union[Parameter, float, None]

Imaginary scattering length density. By default, None.

None
name str

Name of the material. By default, 'EasyMaterial'.

'EasyMaterial'
interface

Calculator interface. By default, None.

None
Source code in src/easyreflectometry/sample/elements/materials/material.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def __init__(
    self,
    sld: Union[Parameter, float, None] = None,
    isld: Union[Parameter, float, None] = None,
    name: str = 'EasyMaterial',
    unique_name: Optional[str] = None,
    interface=None,
):
    """Constructor.

    Parameters
    ----------
    unique_name : Optional[str], optional
        By default, None.
    sld : Union[Parameter, float, None], optional
        Real scattering length density. By default, None.
    isld : Union[Parameter, float, None], optional
        Imaginary scattering length density. By default, None.
    name : str, optional
        Name of the material. By default, 'EasyMaterial'.
    interface :
        Calculator interface. By default, None.
    """
    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)

    sld = get_as_parameter(
        name='sld',
        value=sld,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Sld',
    )
    apply_default_limits(sld, 'sld')

    isld = get_as_parameter(
        name='isld',
        value=isld,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Isld',
    )
    apply_default_limits(isld, 'isld')

    super().__init__(name=name, unique_name=unique_name)
    self._sld = sld
    self._isld = isld

    if interface is not None:
        self.interface = interface

MaterialDensity

Bases: Material

A material defined by chemical formula and mass density.

The scattering length density is derived rather than set: from the formula, the coherent neutron scattering length b (real and imaginary parts, tabulated per isotope) and the molecular weight M are computed, and sld/isld are wired as dependent parameters

sld = N_A * density * b / M

so density is the natural fit parameter and edits to the density or the formula propagate to the SLD automatically.

The coupling can be switched off per material via :attr:sld_coupled: when False, sld/isld are independent parameters that can be set and fitted directly, while density, molecular_weight and the scattering lengths stop affecting anything until the coupling is restored. Restoring it (sld_coupled = True) recomputes the SLDs from the current formula and density, discarding manually set values. The state round-trips through as_dict/from_dict, including the manual SLD values of a decoupled material; dictionaries from before this feature deserialize as coupled.

Assigning :attr:chemical_structure updates the scattering lengths and the molecular weight together; an invalid formula raises ValueError and leaves the material unchanged.

:attr:molecular_weight is a read-only DescriptorNumber, never a fit parameter: it is fully determined by the formula, and freeing it alongside density would make the fit degenerate (only density / molecular_weight enters the derived SLD).

Source code in src/easyreflectometry/sample/elements/materials/material_density.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
class MaterialDensity(Material):
    """A material defined by chemical formula and mass density.

    The scattering length density is derived rather than set: from the
    formula, the coherent neutron scattering length ``b`` (real and
    imaginary parts, tabulated per isotope) and the molecular weight ``M``
    are computed, and ``sld``/``isld`` are wired as *dependent* parameters

        sld = N_A * density * b / M

    so ``density`` is the natural fit parameter and edits to the density or
    the formula propagate to the SLD automatically.

    The coupling can be switched off per material via :attr:`sld_coupled`:
    when ``False``, ``sld``/``isld`` are independent parameters that can be
    set and fitted directly, while ``density``, ``molecular_weight`` and the
    scattering lengths stop affecting anything until the coupling is
    restored. Restoring it (``sld_coupled = True``) recomputes the SLDs from
    the current formula and density, discarding manually set values. The
    state round-trips through ``as_dict``/``from_dict``, including the
    manual SLD values of a decoupled material; dictionaries from before
    this feature deserialize as coupled.

    Assigning :attr:`chemical_structure` updates the scattering lengths and
    the molecular weight together; an invalid formula raises ``ValueError``
    and leaves the material unchanged.

    :attr:`molecular_weight` is a read-only ``DescriptorNumber``, never a fit
    parameter: it is fully determined by the formula, and freeing it alongside
    density would make the fit degenerate (only ``density / molecular_weight``
    enters the derived SLD).
    """

    def __init__(
        self,
        chemical_structure: Union[str, None] = None,
        density: Union[Parameter, float, None] = None,
        name: str = 'EasyMaterialDensity',
        unique_name: Optional[str] = None,
        interface=None,
    ):
        """Constructor.

        Parameters
        ----------
        unique_name : Optional[str], optional
            By default, None.
        chemical_structure : Union[str, None], optional
            Chemical formula for the material. By default, None.
        density : Union[Parameter, float, None], optional
            Mass density for the material. By default, None.
        name : str, optional
            Identifier. By default, 'EasyMaterialDensity'.
        interface :
            Interface object. By default, None.
        """
        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)

        if chemical_structure is None:
            chemical_structure = DEFAULTS['chemical_structure']

        density = get_as_parameter(
            name='density',
            value=density,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Density',
        )

        scattering_length = neutron_scattering_length(chemical_structure)

        mw = DescriptorNumber(
            name='molecular_weight',
            value=molecular_weight(chemical_structure),
            unit=DEFAULTS['molecular_weight']['unit'],
            description=DEFAULTS['molecular_weight']['description'],
            url=DEFAULTS['molecular_weight']['url'],
            unique_name=global_object.generate_unique_name(f'{unique_name}_Mw'),
        )
        avogadro = DescriptorNumber(
            name='avogadro',
            value=DEFAULTS['avogadro']['value'],
            unit=DEFAULTS['avogadro']['unit'],
            description=DEFAULTS['avogadro']['description'],
            url=DEFAULTS['avogadro']['url'],
            unique_name=global_object.generate_unique_name(f'{unique_name}_Avogadro'),
        )
        scattering_length_real = get_as_parameter(
            name='scattering_length_real',
            value=scattering_length.real,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_ScatteringLengthReal',
        )
        scattering_length_imag = get_as_parameter(
            name='scattering_length_imag',
            value=scattering_length.imag,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_ScatteringLengthImag',
        )
        sld = get_as_parameter(
            name='sld',
            value=density_to_sld(scattering_length_real.value, mw.value, density.value),
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Sld',
        )
        isld = get_as_parameter(
            name='isld',
            value=density_to_sld(scattering_length_imag.value, mw.value, density.value),
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Isld',
        )

        # Wired before `Material.__init__` so the derived parameters are
        # already dependent there and skip the default SLD limits.
        self._make_sld_dependent(sld, density, scattering_length_real, mw, avogadro)
        self._make_sld_dependent(isld, density, scattering_length_imag, mw, avogadro)

        super().__init__(sld=sld, isld=isld, name=name, unique_name=unique_name, interface=None)

        self._scattering_length_real = scattering_length_real
        self._scattering_length_imag = scattering_length_imag
        self._molecular_weight = mw
        self._avogadro = avogadro
        self._density = density
        self._chemical_structure = chemical_structure
        self._sld_coupled = True

        if interface is not None:
            self.interface = interface

    @staticmethod
    def _make_sld_dependent(
        derived: Parameter,
        density: Parameter,
        scattering_length: Parameter,
        mw: DescriptorNumber,
        avogadro: DescriptorNumber,
    ) -> None:
        """Make `derived` (an sld or isld) depend on density, scattering length
        and molecular weight. The single place the dependency is defined."""
        derived.make_dependent_on(
            dependency_expression=SLD_DEPENDENCY_EXPRESSION,
            dependency_map={'na': avogadro, 'd': density, 'sl': scattering_length, 'mw': mw},
            desired_unit=MATERIAL_DEFAULTS['sld']['unit'],
        )

    def _setup_sld_constraints(self) -> None:
        """Wire the derived `sld` / `isld` to depend on the current density and
        scattering-length Parameters.

        Idempotent — rebuilds the wiring done in `__init__`; invoked from
        `from_dict` after :class:`ModelBase` has swapped in the saved
        Parameter objects, and when the coupling is restored.
        """
        for derived, scattering_length in (
            (self._sld, self._scattering_length_real),
            (self._isld, self._scattering_length_imag),
        ):
            # Drops any previous dependency together with the user-constraint
            # marker set by `easyreflectometry.constraints`. Left behind, the
            # marker would make `Project` persist the internal density formula
            # as a user constraint and replay it on load, without its unit
            # conversion and with the molecular weight frozen as a constant.
            unconstrain(derived)
            self._make_sld_dependent(derived, self._density, scattering_length, self._molecular_weight, self._avogadro)

    @property
    def sld_coupled(self) -> bool:
        """Whether ``sld``/``isld`` are derived from formula & density (True,
        the default) or independent, directly editable/fittable parameters
        (False).

        An explicit flag reconciled with the live dependency state, because
        neither alone is reliable: a decoupled ``sld`` may still be made
        dependent by a user constraint, which must not make the material read
        as coupled again; and the density dependencies may be removed behind
        the material's back (``unconstrain(material.sld)`` or a raw
        ``make_independent()``), after which the flag alone would still claim
        coupling and serialization would drop the manual values.

        The coupling is all-or-nothing: ``sld`` and ``isld`` are decoupled and
        recoupled together, and only that joint state is serialized. Freeing
        just one of them (``unconstrain(material.sld)`` alone) reads as
        decoupled, and a save/load restores *both* as independent with their
        saved values; such a mixed state is session-only."""
        return self._sld_coupled and not self._sld.independent and not self._isld.independent

    @sld_coupled.setter
    def sld_coupled(self, couple: bool) -> None:
        couple = bool(couple)
        # Compared against the reconciled property, not the raw flag: a stale
        # flag must not stop an explicit request to restore the coupling.
        if couple != self.sld_coupled:
            if couple:
                # Recomputes sld/isld from the current density/scattering
                # length/molecular weight — manually set values and any user
                # constraint on them are discarded.
                self._setup_sld_constraints()
            else:
                # make_independent raises on an already-independent parameter,
                # so guard each individually. Values are kept.
                for parameter in (self._sld, self._isld):
                    if not parameter.independent:
                        parameter.make_independent()
        self._sld_coupled = couple

    def _convert_to_dict(self, d: dict, serializer, skip: Optional[list] = None, **kwargs) -> dict:
        """Serializer hook (see ``SerializerBase._convert_to_dict``).

        ``sld``/``isld`` are not constructor arguments, so the argspec-driven
        encoder never persists them; in the decoupled state their manually
        entered or fitted values would be lost on save/load without this.
        """
        d['sld_coupled'] = self.sld_coupled
        if not self.sld_coupled:
            d['sld'] = self._sld.value
            d['isld'] = self._isld.value
        return super()._convert_to_dict(d, serializer, skip=skip, **kwargs)

    @classmethod
    def from_dict(cls, obj_dict: dict) -> 'MaterialDensity':
        """Re-attach sld/isld dependencies after deserialization.

        :class:`ModelBase.from_dict` re-points `self._density` at the
        deserialized Parameter (because `density` is a constructor argument);
        the constraint graph built in `__init__` still references the
        temporary Parameter created from the float kwarg. Rebuild here so
        `q.density = X` propagates to the derived SLDs.

        The keys written by ``_convert_to_dict`` are not constructor
        arguments and must be removed before the parent's ``cls(**data)``
        call; they are then used to restore the coupling state. A dict
        without them (pre-feature project files) restores as coupled.
        """
        obj_dict = dict(obj_dict)
        sld_coupled = obj_dict.pop('sld_coupled', True)
        manual_sld = obj_dict.pop('sld', None)
        manual_isld = obj_dict.pop('isld', None)

        instance = super().from_dict(obj_dict)
        if sld_coupled:
            instance._setup_sld_constraints()
        else:
            # __init__ wired the dependencies; undo them and restore the
            # saved manual values.
            instance.sld_coupled = False
            if manual_sld is not None:
                instance._sld.value = manual_sld
            if manual_isld is not None:
                instance._isld.value = manual_isld
        return instance

    @property
    def chemical_structure(self) -> str:
        """Get the chemical structure string."""
        return self._chemical_structure

    @chemical_structure.setter
    def chemical_structure(self, structure_string: str) -> None:
        """Set the chemical structure string.

        Parameters
        ----------
        structure_string : str
            String that defines the chemical structure.
        """
        # Derive everything before mutating any state: an invalid formula
        # must leave the material fully unchanged. periodictable parses
        # garbage to an *empty* formula (b=0, mw=0) instead of raising, and
        # mw=0 would put a division by zero into the sld dependency.
        scattering_length = neutron_scattering_length(structure_string)
        # The molecular weight enters the sld dependency alongside the
        # scattering length; leaving it at the old formula's value would make
        # the derived sld a mix of two formulas.
        mw = molecular_weight(structure_string)
        if not mw:
            raise ValueError(f'Invalid chemical formula: {structure_string!r}')
        self._chemical_structure = structure_string
        self._scattering_length_real.value = scattering_length.real
        self._scattering_length_imag.value = scattering_length.imag
        self._molecular_weight.value = mw

    @property
    def density(self) -> Parameter:
        return self._density

    @density.setter
    def density(self, value: float) -> None:
        self._density.value = value

    @property
    def molecular_weight(self) -> DescriptorNumber:
        """The molecular weight of the formula. A read-only descriptor, not a
        fittable parameter: it is a constant of the chemical formula and is
        recomputed whenever :attr:`chemical_structure` is assigned."""
        return self._molecular_weight

    @property
    def number_density(self) -> float:
        """Number density of formula units in 1/angstrom^3, derived from the
        density and the formula. Read-only: `density` is the fit parameter;
        convert a number density with
        :func:`~easyreflectometry.special.calculations.mass_density_from_number_density`."""
        return number_density_from_mass_density(self._density.value, self._molecular_weight.value)

    @property
    def scattering_length_real(self) -> Parameter:
        return self._scattering_length_real

    @property
    def scattering_length_imag(self) -> Parameter:
        return self._scattering_length_imag

    @property
    def _dict_repr(self) -> dict[str, str]:
        """Dictionary representation of the instance."""
        mat_dict = super()._dict_repr
        mat_dict['chemical_structure'] = self._chemical_structure
        mat_dict['density'] = f'{self.density.value:.2e} {self.density.unit}'
        return mat_dict

sld_coupled property writable

Whether sld/isld are derived from formula & density (True, the default) or independent, directly editable/fittable parameters (False).

An explicit flag reconciled with the live dependency state, because neither alone is reliable: a decoupled sld may still be made dependent by a user constraint, which must not make the material read as coupled again; and the density dependencies may be removed behind the material's back (unconstrain(material.sld) or a raw make_independent()), after which the flag alone would still claim coupling and serialization would drop the manual values.

The coupling is all-or-nothing: sld and isld are decoupled and recoupled together, and only that joint state is serialized. Freeing just one of them (unconstrain(material.sld) alone) reads as decoupled, and a save/load restores both as independent with their saved values; such a mixed state is session-only.

chemical_structure property writable

Get the chemical structure string.

molecular_weight property

The molecular weight of the formula. A read-only descriptor, not a fittable parameter: it is a constant of the chemical formula and is recomputed whenever :attr:chemical_structure is assigned.

number_density property

Number density of formula units in 1/angstrom^3, derived from the density and the formula. Read-only: density is the fit parameter; convert a number density with :func:~easyreflectometry.special.calculations.mass_density_from_number_density.

__init__(chemical_structure=None, density=None, name='EasyMaterialDensity', unique_name=None, interface=None)

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
chemical_structure Union[str, None]

Chemical formula for the material. By default, None.

None
density Union[Parameter, float, None]

Mass density for the material. By default, None.

None
name str

Identifier. By default, 'EasyMaterialDensity'.

'EasyMaterialDensity'
interface

Interface object. By default, None.

None
Source code in src/easyreflectometry/sample/elements/materials/material_density.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def __init__(
    self,
    chemical_structure: Union[str, None] = None,
    density: Union[Parameter, float, None] = None,
    name: str = 'EasyMaterialDensity',
    unique_name: Optional[str] = None,
    interface=None,
):
    """Constructor.

    Parameters
    ----------
    unique_name : Optional[str], optional
        By default, None.
    chemical_structure : Union[str, None], optional
        Chemical formula for the material. By default, None.
    density : Union[Parameter, float, None], optional
        Mass density for the material. By default, None.
    name : str, optional
        Identifier. By default, 'EasyMaterialDensity'.
    interface :
        Interface object. By default, None.
    """
    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)

    if chemical_structure is None:
        chemical_structure = DEFAULTS['chemical_structure']

    density = get_as_parameter(
        name='density',
        value=density,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Density',
    )

    scattering_length = neutron_scattering_length(chemical_structure)

    mw = DescriptorNumber(
        name='molecular_weight',
        value=molecular_weight(chemical_structure),
        unit=DEFAULTS['molecular_weight']['unit'],
        description=DEFAULTS['molecular_weight']['description'],
        url=DEFAULTS['molecular_weight']['url'],
        unique_name=global_object.generate_unique_name(f'{unique_name}_Mw'),
    )
    avogadro = DescriptorNumber(
        name='avogadro',
        value=DEFAULTS['avogadro']['value'],
        unit=DEFAULTS['avogadro']['unit'],
        description=DEFAULTS['avogadro']['description'],
        url=DEFAULTS['avogadro']['url'],
        unique_name=global_object.generate_unique_name(f'{unique_name}_Avogadro'),
    )
    scattering_length_real = get_as_parameter(
        name='scattering_length_real',
        value=scattering_length.real,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_ScatteringLengthReal',
    )
    scattering_length_imag = get_as_parameter(
        name='scattering_length_imag',
        value=scattering_length.imag,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_ScatteringLengthImag',
    )
    sld = get_as_parameter(
        name='sld',
        value=density_to_sld(scattering_length_real.value, mw.value, density.value),
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Sld',
    )
    isld = get_as_parameter(
        name='isld',
        value=density_to_sld(scattering_length_imag.value, mw.value, density.value),
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Isld',
    )

    # Wired before `Material.__init__` so the derived parameters are
    # already dependent there and skip the default SLD limits.
    self._make_sld_dependent(sld, density, scattering_length_real, mw, avogadro)
    self._make_sld_dependent(isld, density, scattering_length_imag, mw, avogadro)

    super().__init__(sld=sld, isld=isld, name=name, unique_name=unique_name, interface=None)

    self._scattering_length_real = scattering_length_real
    self._scattering_length_imag = scattering_length_imag
    self._molecular_weight = mw
    self._avogadro = avogadro
    self._density = density
    self._chemical_structure = chemical_structure
    self._sld_coupled = True

    if interface is not None:
        self.interface = interface

from_dict(obj_dict) classmethod

Re-attach sld/isld dependencies after deserialization.

:class:ModelBase.from_dict re-points self._density at the deserialized Parameter (because density is a constructor argument); the constraint graph built in __init__ still references the temporary Parameter created from the float kwarg. Rebuild here so q.density = X propagates to the derived SLDs.

The keys written by _convert_to_dict are not constructor arguments and must be removed before the parent's cls(**data) call; they are then used to restore the coupling state. A dict without them (pre-feature project files) restores as coupled.

Source code in src/easyreflectometry/sample/elements/materials/material_density.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
@classmethod
def from_dict(cls, obj_dict: dict) -> 'MaterialDensity':
    """Re-attach sld/isld dependencies after deserialization.

    :class:`ModelBase.from_dict` re-points `self._density` at the
    deserialized Parameter (because `density` is a constructor argument);
    the constraint graph built in `__init__` still references the
    temporary Parameter created from the float kwarg. Rebuild here so
    `q.density = X` propagates to the derived SLDs.

    The keys written by ``_convert_to_dict`` are not constructor
    arguments and must be removed before the parent's ``cls(**data)``
    call; they are then used to restore the coupling state. A dict
    without them (pre-feature project files) restores as coupled.
    """
    obj_dict = dict(obj_dict)
    sld_coupled = obj_dict.pop('sld_coupled', True)
    manual_sld = obj_dict.pop('sld', None)
    manual_isld = obj_dict.pop('isld', None)

    instance = super().from_dict(obj_dict)
    if sld_coupled:
        instance._setup_sld_constraints()
    else:
        # __init__ wired the dependencies; undo them and restore the
        # saved manual values.
        instance.sld_coupled = False
        if manual_sld is not None:
            instance._sld.value = manual_sld
        if manual_isld is not None:
            instance._isld.value = manual_isld
    return instance

MaterialMagnetic

Bases: MaterialDensity

A density-defined material carrying a magnetic moment.

Adds :attr:magnetic_moment (µB per formula unit, the fit parameter) and a derived magnetic SLD

rho_m = N * mu * p,    N = N_A * density / M

with p the magnetic scattering length of one Bohr magneton (:data:~easyreflectometry.special.calculations.MAGNETIC_SCATTERING_LENGTH). The moment is the effective in-plane net moment per formula unit, which is the moment per atom only for a formula unit of one atom, such as Fe.

A layer uses the material's rho_m when its :attr:~easyreflectometry.sample.Layer.rho_m_coupled is set. The magnetic SLD stays derived when :attr:sld_coupled is switched off, which affects the nuclear sld/isld only.

Source code in src/easyreflectometry/sample/elements/materials/material_magnetic.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class MaterialMagnetic(MaterialDensity):
    """A density-defined material carrying a magnetic moment.

    Adds :attr:`magnetic_moment` (µB per formula unit, the fit parameter) and
    a derived magnetic SLD

        rho_m = N * mu * p,    N = N_A * density / M

    with ``p`` the magnetic scattering length of one Bohr magneton
    (:data:`~easyreflectometry.special.calculations.MAGNETIC_SCATTERING_LENGTH`).
    The moment is the effective in-plane net moment per formula unit, which is
    the moment per atom only for a formula unit of one atom, such as Fe.

    A layer uses the material's ``rho_m`` when its
    :attr:`~easyreflectometry.sample.Layer.rho_m_coupled` is set. The magnetic
    SLD stays derived when :attr:`sld_coupled` is switched off, which affects
    the nuclear ``sld``/``isld`` only.
    """

    def __init__(
        self,
        chemical_structure: Union[str, None] = 'Fe',
        density: Union[Parameter, float, None] = 7.874,
        magnetic_moment: Union[Parameter, float, None] = None,
        name: str = 'EasyMaterialMagnetic',
        unique_name: Optional[str] = None,
        interface=None,
    ):
        """Constructor.

        Parameters
        ----------
        chemical_structure : Union[str, None], optional
            Chemical formula. By default, 'Fe'.
        density : Union[Parameter, float, None], optional
            Mass density in g/cm^3. By default, 7.874 (iron).
        magnetic_moment : Union[Parameter, float, None], optional
            Moment in Bohr magnetons per formula unit. By default, 2.2 (iron).
            The default fit bounds are [0, 10]; a number outside them widens
            the bounds to include it rather than being refused, since a
            signed moment (an imported reversed magnetic SLD) or a formula
            unit of many atoms is a valid state, not a fitting choice.
        name : str, optional
            Identifier. By default, 'EasyMaterialMagnetic'.
        unique_name : Optional[str], optional
            By default, None.
        interface :
            Interface object. By default, None.
        """
        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)
        super().__init__(
            chemical_structure=chemical_structure,
            density=density,
            name=name,
            unique_name=unique_name,
            interface=None,
        )
        self._magnetic_moment = get_as_parameter(
            name='magnetic_moment',
            value=magnetic_moment,
            default_dict=_moment_defaults(magnetic_moment),
            unique_name_prefix=f'{unique_name}_MagneticMoment',
        )
        self._rho_m = get_as_parameter(
            name='magnetic_sld',
            value=None,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_RhoM',
        )
        self._magnetic_scattering_length = DescriptorNumber(
            name='magnetic_scattering_length',
            value=MAGNETIC_SCATTERING_LENGTH,
            unit='angstrom',
            unique_name=global_object.generate_unique_name(f'{unique_name}_MagneticScatteringLength'),
        )
        self._setup_magnetic_dependency()

        if interface is not None:
            self.interface = interface

    def _setup_magnetic_dependency(self) -> None:
        """Wire `rho_m` to the current density, formula and moment.

        Idempotent, and independent of the nuclear coupling: called from
        `__init__` and, after the saved parameters are swapped in, from
        `from_dict` whatever `sld_coupled` is.
        """
        unconstrain(self._rho_m)
        self._rho_m.make_dependent_on(
            dependency_expression=RHO_M_DEPENDENCY_EXPRESSION,
            dependency_map={
                'na': self._avogadro,
                'd': self._density,
                'mw': self._molecular_weight,
                'mu': self._magnetic_moment,
                'p': self._magnetic_scattering_length,
            },
            desired_unit=MATERIAL_DEFAULTS['sld']['unit'],
        )

    @classmethod
    def from_dict(cls, obj_dict: dict) -> 'MaterialMagnetic':
        instance = super().from_dict(obj_dict)
        instance._setup_magnetic_dependency()
        return instance

    @property
    def magnetic_moment(self) -> Parameter:
        """Effective in-plane net moment in Bohr magnetons per formula unit."""
        return self._magnetic_moment

    @magnetic_moment.setter
    def magnetic_moment(self, value: float) -> None:
        self._magnetic_moment.value = value

    @property
    def rho_m(self) -> Parameter:
        """Magnetic SLD in 1e-6 / angstrom^2, derived from density, formula and moment (read-only)."""
        return self._rho_m

    @property
    def _dict_repr(self) -> dict[str, str]:
        mat_dict = super()._dict_repr
        mat_dict['magnetic_moment'] = f'{self._magnetic_moment.value:.3f} muB/f.u.'
        mat_dict['rho_m'] = f'{self._rho_m.value:.3f}e-6 {self._rho_m.unit}'
        return mat_dict

magnetic_moment property writable

Effective in-plane net moment in Bohr magnetons per formula unit.

rho_m property

Magnetic SLD in 1e-6 / angstrom^2, derived from density, formula and moment (read-only).

__init__(chemical_structure='Fe', density=7.874, magnetic_moment=None, name='EasyMaterialMagnetic', unique_name=None, interface=None)

Constructor.

Parameters:

Name Type Description Default
chemical_structure Union[str, None]

Chemical formula. By default, 'Fe'.

'Fe'
density Union[Parameter, float, None]

Mass density in g/cm^3. By default, 7.874 (iron).

7.874
magnetic_moment Union[Parameter, float, None]

Moment in Bohr magnetons per formula unit. By default, 2.2 (iron). The default fit bounds are [0, 10]; a number outside them widens the bounds to include it rather than being refused, since a signed moment (an imported reversed magnetic SLD) or a formula unit of many atoms is a valid state, not a fitting choice.

None
name str

Identifier. By default, 'EasyMaterialMagnetic'.

'EasyMaterialMagnetic'
unique_name Optional[str]

By default, None.

None
interface

Interface object. By default, None.

None
Source code in src/easyreflectometry/sample/elements/materials/material_magnetic.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
def __init__(
    self,
    chemical_structure: Union[str, None] = 'Fe',
    density: Union[Parameter, float, None] = 7.874,
    magnetic_moment: Union[Parameter, float, None] = None,
    name: str = 'EasyMaterialMagnetic',
    unique_name: Optional[str] = None,
    interface=None,
):
    """Constructor.

    Parameters
    ----------
    chemical_structure : Union[str, None], optional
        Chemical formula. By default, 'Fe'.
    density : Union[Parameter, float, None], optional
        Mass density in g/cm^3. By default, 7.874 (iron).
    magnetic_moment : Union[Parameter, float, None], optional
        Moment in Bohr magnetons per formula unit. By default, 2.2 (iron).
        The default fit bounds are [0, 10]; a number outside them widens
        the bounds to include it rather than being refused, since a
        signed moment (an imported reversed magnetic SLD) or a formula
        unit of many atoms is a valid state, not a fitting choice.
    name : str, optional
        Identifier. By default, 'EasyMaterialMagnetic'.
    unique_name : Optional[str], optional
        By default, None.
    interface :
        Interface object. By default, None.
    """
    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)
    super().__init__(
        chemical_structure=chemical_structure,
        density=density,
        name=name,
        unique_name=unique_name,
        interface=None,
    )
    self._magnetic_moment = get_as_parameter(
        name='magnetic_moment',
        value=magnetic_moment,
        default_dict=_moment_defaults(magnetic_moment),
        unique_name_prefix=f'{unique_name}_MagneticMoment',
    )
    self._rho_m = get_as_parameter(
        name='magnetic_sld',
        value=None,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_RhoM',
    )
    self._magnetic_scattering_length = DescriptorNumber(
        name='magnetic_scattering_length',
        value=MAGNETIC_SCATTERING_LENGTH,
        unit='angstrom',
        unique_name=global_object.generate_unique_name(f'{unique_name}_MagneticScatteringLength'),
    )
    self._setup_magnetic_dependency()

    if interface is not None:
        self.interface = interface

is_orso_file(fname)

Whether fname is an ORSO file: text with the ORSO banner line, or HDF5 (.orb).

Parameters:

Name Type Description Default
fname str

Path to the file.

required

Returns:

Type Description
bool

True when the file starts with the ORSO banner or the HDF5 magic bytes.

Source code in src/easyreflectometry/orso_utils.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
def is_orso_file(fname: str) -> bool:
    """Whether *fname* is an ORSO file: text with the ORSO banner line, or HDF5 (.orb).

    Parameters
    ----------
    fname : str
        Path to the file.

    Returns
    -------
    bool
        True when the file starts with the ORSO banner or the HDF5 magic bytes.
    """
    try:
        with open(fname, 'rb') as f:
            head = f.read(128)
    except OSError:
        return False
    if head.startswith(_HDF5_MAGIC):
        return True
    try:
        first_line = head.decode('utf-8', errors='replace').splitlines()[0]
    except IndexError:
        return False
    return first_line.lstrip().startswith('#') and ORSO_BANNER in first_line

_is_binary_orso(fname)

Whether fname is a binary (HDF5 / .orb) ORSO file.

Source code in src/easyreflectometry/orso_utils.py
144
145
146
147
148
149
150
def _is_binary_orso(fname: str) -> bool:
    """Whether *fname* is a binary (HDF5 / .orb) ORSO file."""
    try:
        with open(fname, 'rb') as f:
            return f.read(8).startswith(_HDF5_MAGIC)
    except OSError:
        return False

_load_orso_any(fname)

Parse an ORSO file, text (.ort) or binary (.orb), into OrsoDataset objects.

Parameters:

Name Type Description Default
fname str

Path to the file.

required

Returns:

Type Description
List[OrsoDataset]

The parsed datasets.

Raises:

Type Description
ValueError :

If the file cannot be parsed as ORSO (the original error is chained).

Source code in src/easyreflectometry/orso_utils.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def _load_orso_any(fname: str) -> List[OrsoDataset]:
    """Parse an ORSO file, text (`.ort`) or binary (`.orb`), into OrsoDataset objects.

    Parameters
    ----------
    fname : str
        Path to the file.

    Returns
    -------
    List[OrsoDataset]
        The parsed datasets.

    Raises
    ------
    ValueError :
        If the file cannot be parsed as ORSO (the original error is chained).
    """
    fname = str(fname)
    try:
        if _is_binary_orso(fname):
            return orso.load_nexus(fname)
        return orso.load_orso(fname)
    except Exception as e:
        raise ValueError(f'Error loading ORSO file {fname!r}: {e}') from e

LoadOrso(orso_data)

Load a model and data from an ORSO file (path or pre-parsed datasets).

Source code in src/easyreflectometry/orso_utils.py
180
181
182
183
184
185
186
def LoadOrso(orso_data):
    """Load a model and data from an ORSO file (path or pre-parsed datasets)."""

    orso_obj = _coerce_orso_object(orso_data)
    sample = load_orso_model(orso_obj)
    data = load_orso_data(orso_obj)
    return sample, data

_coerce_orso_object(orso_input)

Return a parsed ORSO object list from either a path or pre-parsed input.

Source code in src/easyreflectometry/orso_utils.py
189
190
191
192
193
194
195
196
def _coerce_orso_object(orso_input):
    """Return a parsed ORSO object list from either a path or pre-parsed input."""
    try:
        if orso_input and hasattr(orso_input[0], 'info'):
            return orso_input
    except (TypeError, IndexError):
        pass
    return _load_orso_any(orso_input)

load_data_from_orso_file(fname)

Load data from an ORSO file (.ort text or .orb binary).

Parameters:

Name Type Description Default
fname str

Path to the file.

required

Returns:

Type Description
sc.DataGroup

A scipp DataGroup with data, coords, and attrs.

Raises:

Type Description
ValueError :

If the file cannot be parsed as ORSO. Parse failures are not swallowed here; falling back to plain-text loading is the caller's decision and only valid when the file carries no ORSO banner.

Source code in src/easyreflectometry/orso_utils.py
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def load_data_from_orso_file(fname: str) -> sc.DataGroup:
    """Load data from an ORSO file (`.ort` text or `.orb` binary).

    Parameters
    ----------
    fname : str
        Path to the file.

    Returns
    -------
    sc.DataGroup
        A scipp DataGroup with data, coords, and attrs.

    Raises
    ------
    ValueError :
        If the file cannot be parsed as ORSO. Parse failures are **not**
        swallowed here; falling back to plain-text loading is the caller's
        decision and only valid when the file carries no ORSO banner.
    """
    orso_data = _load_orso_any(fname)
    return load_orso_data(orso_data)

_orso_dataset_key(o, index)

The name a dataset is stored under: its data_set label or its index.

Source code in src/easyreflectometry/orso_utils.py
223
224
225
226
227
def _orso_dataset_key(o, index: int) -> Union[str, int]:
    """The name a dataset is stored under: its ``data_set`` label or its index."""
    if o.info.data_set is not None:
        return o.info.data_set
    return index

_validate_columns(columns, dataset_label)

Warn when the leading four columns do not follow the ORSO layout.

The spec fixes the order Qz, R, sR, sQz; data is read by position, so a file with different columns is very likely misread. The names in info.columns are used as validation only.

Source code in src/easyreflectometry/orso_utils.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
def _validate_columns(columns, dataset_label) -> None:
    """Warn when the leading four columns do not follow the ORSO layout.

    The spec fixes the order Qz, R, sR, sQz; data is read by position, so a
    file with different columns is very likely misread. The names in
    ``info.columns`` are used as validation only.
    """
    expected = ('Qz', 'R', 'R', 'Qz')
    for position, expected_name in enumerate(expected):
        if position >= len(columns):
            warnings.warn(
                f'ORSO dataset {dataset_label!r} declares only {len(columns)} columns; the specification '
                f'requires Qz, R, sR, sQz (nan-filled when unknown). Missing error columns are treated as absent.',
                UserWarning,
                stacklevel=3,
            )
            return
        column = columns[position]
        name = getattr(column, 'error_of', None) or getattr(column, 'name', None)
        # Legacy (0.1 standard) files use plain columns named 'sR'/'sQz'.
        if position >= 2 and isinstance(name, str) and name.startswith('s'):
            name = name[1:]
        if name != expected_name:
            warnings.warn(
                f'ORSO dataset {dataset_label!r} columns do not follow the specified order '
                f'(Qz, R, sR, sQz): column {position} is {name!r}. Data is read by position '
                f'and may be misinterpreted.',
                UserWarning,
                stacklevel=3,
            )
            return

_q_unit_scale(column, dataset_label)

Conversion factor from the Qz column unit to the internal 1/angstrom.

Source code in src/easyreflectometry/orso_utils.py
263
264
265
266
267
268
269
270
271
272
273
274
275
def _q_unit_scale(column, dataset_label) -> float:
    """Conversion factor from the Qz column unit to the internal 1/angstrom."""
    unit = getattr(column, 'unit', None)
    try:
        return _Q_UNIT_TO_INV_ANGSTROM[unit]
    except KeyError:
        warnings.warn(
            f'ORSO dataset {dataset_label!r} declares Qz unit {unit!r}; expected 1/angstrom or 1/nm. '
            f'Values are used as-is (assumed 1/angstrom).',
            UserWarning,
            stacklevel=3,
        )
        return 1.0

_error_column_sigma(o, position)

Extract an error column as sigma values, or None when the column is absent.

Honours value_is: FWHM by converting to sigma (dividing by 2.3548...).

Source code in src/easyreflectometry/orso_utils.py
278
279
280
281
282
283
284
285
286
287
288
289
290
def _error_column_sigma(o, position: int) -> Optional[np.ndarray]:
    """Extract an error column as sigma values, or None when the column is absent.

    Honours ``value_is: FWHM`` by converting to sigma (dividing by 2.3548...).
    """
    if o.data.ndim != 2 or o.data.shape[1] <= position:
        return None
    values = np.asarray(o.data[:, position], dtype=float)
    if position < len(o.info.columns):
        value_is = getattr(o.info.columns[position], 'value_is', None)
        if value_is == 'FWHM':
            values = values / SIGMA_TO_FWHM
    return values

_clean_sqz(sqz, dataset_label)

Apply the nan policy to the sQz column.

All-nan (spec-valid "resolution unknown") returns None so that no q-variance is stored and the caller falls back to the default percentage smearing. Partial nan (common at the low/high-Q extremes of real reductions) is filled by interpolating sigma over the valid points -- otherwise a single nan would propagate through np.interp in Pointwise across the whole range.

Source code in src/easyreflectometry/orso_utils.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def _clean_sqz(sqz: Optional[np.ndarray], dataset_label) -> Optional[np.ndarray]:
    """Apply the nan policy to the sQz column.

    All-nan (spec-valid "resolution unknown") returns None so that no q-variance
    is stored and the caller falls back to the default percentage smearing.
    Partial nan (common at the low/high-Q extremes of real reductions) is filled
    by interpolating sigma over the valid points -- otherwise a single nan would
    propagate through ``np.interp`` in ``Pointwise`` across the whole range.
    """
    if sqz is None:
        return None
    nan_mask = np.isnan(sqz)
    if not nan_mask.any():
        return sqz
    if nan_mask.all():
        return None
    warnings.warn(
        f'ORSO dataset {dataset_label!r}: {nan_mask.sum()} of {sqz.size} sQz values are nan; '
        f'they are filled by interpolating the resolution over the valid points.',
        UserWarning,
        stacklevel=3,
    )
    valid = ~nan_mask
    indices = np.arange(sqz.size)
    filled = sqz.copy()
    filled[nan_mask] = np.interp(indices[nan_mask], indices[valid], sqz[valid])
    return filled

_clean_sr(sr, dataset_label)

Apply the nan policy to the sR column.

All-nan returns None (uncertainty unknown). Partial nan is kept as-is -- interpolating measurement uncertainties would fabricate fit weights -- but is warned about, since nan weights degrade fitting.

Source code in src/easyreflectometry/orso_utils.py
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
def _clean_sr(sr: Optional[np.ndarray], dataset_label) -> Optional[np.ndarray]:
    """Apply the nan policy to the sR column.

    All-nan returns None (uncertainty unknown). Partial nan is kept as-is --
    interpolating measurement uncertainties would fabricate fit weights -- but
    is warned about, since nan weights degrade fitting.
    """
    if sr is None:
        return None
    nan_mask = np.isnan(sr)
    if nan_mask.all() and sr.size:
        return None
    if nan_mask.any():
        warnings.warn(
            f'ORSO dataset {dataset_label!r}: {nan_mask.sum()} of {sr.size} sR values are nan; '
            f'these points carry no uncertainty and will degrade fit weighting.',
            UserWarning,
            stacklevel=3,
        )
    return sr

load_orso_data(orso_data)

Convert parsed ORSO dataset objects into a scipp DataGroup.

Q values are converted to 1/angstrom; sR/sQz are converted to sigma when declared as FWHM and stored as variances; nan-filled error columns follow the policy documented on the cleaning helpers.

Parameters:

Name Type Description Default
orso_data list

Parsed ORSO dataset list (as returned by orso.load_orso).

required

Returns:

Type Description
sc.DataGroup

A scipp DataGroup with data, coords, and attrs.

Source code in src/easyreflectometry/orso_utils.py
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
def load_orso_data(orso_data) -> sc.DataGroup:
    """Convert parsed ORSO dataset objects into a scipp DataGroup.

    Q values are converted to 1/angstrom; sR/sQz are converted to sigma when
    declared as FWHM and stored as variances; nan-filled error columns follow
    the policy documented on the cleaning helpers.

    Parameters
    ----------
    orso_data : list
        Parsed ORSO dataset list (as returned by ``orso.load_orso``).

    Returns
    -------
    sc.DataGroup
        A scipp DataGroup with data, coords, and attrs.
    """
    data = {}
    coords = {}
    attrs = {}
    for i, o in enumerate(orso_data):
        name = _orso_dataset_key(o, i)
        _validate_columns(o.info.columns, name)

        q_scale = _q_unit_scale(o.info.columns[0], name)
        qz = np.asarray(o.data[:, 0], dtype=float) * q_scale
        reflectivity = np.asarray(o.data[:, 1], dtype=float)

        sr = _clean_sr(_error_column_sigma(o, 2), name)
        sqz = _clean_sqz(_error_column_sigma(o, 3), name)
        if sqz is not None:
            sqz = sqz * q_scale

        dims = [f'{o.info.columns[0].name}_{name}']
        coords[f'Qz_{name}'] = sc.array(
            dims=dims,
            values=qz,
            variances=np.square(sqz) if sqz is not None else None,
            unit=sc.Unit('1/angstrom'),
        )
        r_unit = getattr(o.info.columns[1], 'unit', None)
        try:
            data[f'R_{name}'] = sc.array(
                dims=dims,
                values=reflectivity,
                variances=np.square(sr) if sr is not None else None,
                unit=sc.Unit(r_unit) if r_unit is not None else None,
            )
        except TypeError:
            data[f'R_{name}'] = sc.array(
                dims=dims,
                values=reflectivity,
                variances=np.square(sr) if sr is not None else None,
            )
        attrs[f'R_{name}'] = {'orso_header': sc.scalar(Header.asdict(o.info))}
    data_group = sc.DataGroup(data=data, coords=coords, attrs=attrs)
    return data_group

_length_to_angstrom(value)

Convert a model-language length (Value with unit, or bare number) to angstrom.

Source code in src/easyreflectometry/orso_utils.py
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
def _length_to_angstrom(value) -> float:
    """Convert a model-language length (Value with unit, or bare number) to angstrom."""
    if value is None:
        return 0.0
    magnitude = getattr(value, 'magnitude', value)
    if magnitude is None:
        return 0.0
    unit = getattr(value, 'unit', None)
    try:
        factor = _LENGTH_UNIT_TO_ANGSTROM[unit]
    except KeyError:
        warnings.warn(
            f'Unknown ORSO length unit {unit!r}; value used as angstrom.',
            UserWarning,
            stacklevel=4,
        )
        factor = 1.0
    return float(magnitude) * factor

load_orso_model(orso_data)

Load a model from an ORSO file and return a Sample object.

The original sample.model object is resolved (keeping globals, materials, sub_stacks and composits), so declared units -- including the model-language default length unit of nm -- are honoured and converted to the internal angstrom. Sub-stacks with repetitions map to :class:RepeatingMultilayer; density-defined materials stay density-defined (:class:MaterialDensity) instead of being flattened to a numeric SLD.

The stack is converted to a proper Sample structure: - First layer -> Superphase assembly (thickness=0, roughness=0, both fixed) - Middle layers -> 'Loaded layer' Multilayer assembly (parameters enabled), with repeated sub-stacks as RepeatingMultilayer assemblies - Last layer -> Subphase assembly (thickness=0 fixed, roughness enabled)

Parameters:

Name Type Description Default
orso_data list

Parsed ORSO dataset list (as returned by orso.load_orso).

required

Raises:

Type Description
ValueError :

If ORSO layers could not be resolved or fewer than 2 layers.

Returns:

Type Description
Sample

An EasyReflectometry Sample object.

Source code in src/easyreflectometry/orso_utils.py
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
def load_orso_model(orso_data) -> Sample:
    """Load a model from an ORSO file and return a Sample object.

    The **original** ``sample.model`` object is resolved (keeping ``globals``,
    ``materials``, ``sub_stacks`` and ``composits``), so declared units --
    including the model-language default length unit of **nm** -- are honoured
    and converted to the internal angstrom.  Sub-stacks with repetitions map to
    :class:`RepeatingMultilayer`; density-defined materials stay density-defined
    (:class:`MaterialDensity`) instead of being flattened to a numeric SLD.

    The stack is converted to a proper Sample structure:
    - First layer -> Superphase assembly (thickness=0, roughness=0, both fixed)
    - Middle layers -> 'Loaded layer' Multilayer assembly (parameters enabled),
      with repeated sub-stacks as RepeatingMultilayer assemblies
    - Last layer -> Subphase assembly (thickness=0 fixed, roughness enabled)

    Parameters
    ----------
    orso_data : list
        Parsed ORSO dataset list (as returned by ``orso.load_orso``).

    Raises
    ------
    ValueError :
        If ORSO layers could not be resolved or fewer than 2 layers.

    Returns
    -------
    Sample
        An EasyReflectometry Sample object.
    """
    sample_model = orso_data[0].info.data_source.sample.model
    if sample_model is None:
        warnings.warn(
            'ORSO file does not contain a sample model definition. Only experimental data can be loaded from this file.',
            UserWarning,
            stacklevel=2,
        )
        return None
    if isinstance(sample_model, model_language.SampleModel):
        # Use the file's model as parsed: rebuilding it from `stack` and
        # `layers` alone (as done previously) silently dropped the
        # `materials` / `sub_stacks` / `composits` definitions, so named
        # materials could not be resolved and their SLDs read as 0.
        orso_sample = sample_model
    else:
        stack_str = sample_model.stack
        layers_dict = sample_model.layers if hasattr(sample_model, 'layers') else None
        orso_sample = model_language.SampleModel(
            stack=stack_str,
            layers=layers_dict,
            materials=getattr(sample_model, 'materials', None),
            sub_stacks=getattr(sample_model, 'sub_stacks', None),
            composits=getattr(sample_model, 'composits', None),
            globals=getattr(sample_model, 'globals', None),
        )

    # Resolve the original model (globals/materials/sub_stacks intact) at the
    # stack level: resolve_stack() keeps SubStack objects (and with them the
    # repetition counts) that resolve_to_layers()/resolve_to_blocks() flatten.
    orso_blocks = orso_sample.resolve_stack()

    # Handle case where layers are not resolved correctly
    if not orso_blocks:
        raise ValueError('Could not resolve ORSO layers.')

    # Plain layers still need their material resolved (what
    # resolve_to_layers() would have done); a failure is warned about instead
    # of silently re-resolving the whole stack a different way.
    for block in orso_blocks:
        if isinstance(block, model_language.Layer):
            _generate_layer_material(block)

    # The ambient (first) and substrate (last) entries must be plain layers;
    # flatten pathological edge sub-stacks.
    if not isinstance(orso_blocks[0], model_language.Layer):
        warnings.warn('First ORSO stack item is a sub-stack; its layers are used directly.', UserWarning, stacklevel=2)
        orso_blocks = list(orso_blocks[0].resolve_to_layers()) + orso_blocks[1:]
    if not isinstance(orso_blocks[-1], model_language.Layer):
        warnings.warn('Last ORSO stack item is a sub-stack; its layers are used directly.', UserWarning, stacklevel=2)
        orso_blocks = orso_blocks[:-1] + list(orso_blocks[-1].resolve_to_layers())

    total_layers = sum(1 if isinstance(block, model_language.Layer) else max(len(block.sequence), 1) for block in orso_blocks)
    if total_layers < 2:
        raise ValueError('ORSO stack must contain at least 2 layers (superphase and subphase).')

    logger.debug(f'Resolved blocks: {orso_blocks}')

    # Create Superphase from first layer (thickness=0, roughness=0, both fixed)
    superphase_layer = _convert_orso_layer_to_erl(orso_blocks[0])
    superphase_layer.thickness.value = 0.0
    superphase_layer.roughness.value = 0.0
    superphase_layer.thickness.fixed = True
    superphase_layer.roughness.fixed = True
    superphase = Multilayer(superphase_layer, name='Superphase')

    # Create Subphase from last layer (thickness=0 fixed, roughness enabled)
    subphase_layer = _convert_orso_layer_to_erl(orso_blocks[-1])
    subphase_layer.thickness.value = 0.0
    subphase_layer.thickness.fixed = True
    subphase_layer.roughness.fixed = False
    subphase = Multilayer(subphase_layer, name='Subphase')

    # Middle blocks: consecutive plain layers group into one Multilayer;
    # sub-stacks become their own (repeating) assemblies.
    middle_assemblies = []
    pending_layers = []

    def flush_pending():
        if pending_layers:
            middle_assemblies.append(Multilayer(list(pending_layers), name='Loaded layer'))
            pending_layers.clear()

    for block in orso_blocks[1:-1]:
        if isinstance(block, model_language.Layer):
            pending_layers.append(_convert_orso_layer_to_erl(block))
        else:
            flush_pending()
            middle_assemblies.append(_convert_orso_substack_to_erl(block))
    flush_pending()

    # Keep the historic single-group name; disambiguate multiple plain groups.
    plain_groups = [a for a in middle_assemblies if a.name == 'Loaded layer']
    if len(plain_groups) > 1:
        for k, assembly in enumerate(plain_groups):
            assembly.name = f'Loaded layer {k}'

    # Create Sample from the file
    sample_info = orso_data[0].info.data_source.sample
    sample_name = sample_info.name if sample_info.name else 'ORSO Sample'

    sample = Sample(superphase, *middle_assemblies, subphase, name=sample_name)
    return sample

_generate_layer_material(orso_layer)

Resolve a model-language layer's material in place (formula -> SLD).

This is the per-layer half of orsopy's resolve_to_layers(); a failure is warned about (the material then imports with its declared values, or an SLD of 0) instead of silently re-resolving the stack a different way.

Source code in src/easyreflectometry/orso_utils.py
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
def _generate_layer_material(orso_layer) -> None:
    """Resolve a model-language layer's material in place (formula -> SLD).

    This is the per-layer half of orsopy's ``resolve_to_layers()``; a failure
    is warned about (the material then imports with its declared values, or an
    SLD of 0) instead of silently re-resolving the stack a different way.
    """
    try:
        if orso_layer.material is None:
            orso_layer.generate_material()
        orso_layer.material.generate_density()
    except Exception as e:
        warnings.warn(
            f'Could not resolve material for ORSO layer {getattr(orso_layer, "original_name", None) or orso_layer!r}: {e}',
            UserWarning,
            stacklevel=3,
        )

_convert_orso_substack_to_erl(block)

Convert an ORSO SubStack block into a (repeating) multilayer assembly.

Source code in src/easyreflectometry/orso_utils.py
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
def _convert_orso_substack_to_erl(block) -> Multilayer:
    """Convert an ORSO SubStack block into a (repeating) multilayer assembly."""
    repetitions = int(getattr(block, 'repetitions', 1) or 1)
    # SubStack.resolve_to_layers() returns layers * repetitions; resolve one
    # period by temporarily neutralizing the repetition count.
    original_repetitions = block.repetitions
    try:
        block.repetitions = 1
        period = block.resolve_to_layers()
    except Exception as e:
        warnings.warn(
            f'Could not fully resolve ORSO sub-stack ({e}); using its raw layer sequence.',
            UserWarning,
            stacklevel=3,
        )
        period = [item for item in block.sequence if isinstance(item, model_language.Layer)]
    finally:
        block.repetitions = original_repetitions
    erl_layers = [_convert_orso_layer_to_erl(orso_layer) for orso_layer in period]
    name = getattr(block, 'original_name', None) or 'Multilayer'
    if repetitions > 1 and any(layer.magnetism is not None for layer in erl_layers):
        warnings.warn(
            f'Sub-stack "{name}" repeats magnetic layers; refl1d cannot calculate magnetism inside a repeating multilayer.',
            UserWarning,
            stacklevel=3,
        )
    if repetitions > 1:
        return RepeatingMultilayer(erl_layers, repetitions=repetitions, name=name)
    return Multilayer(erl_layers, name=name)

_convert_orso_layer_to_erl(layer)

Helper function to convert an ORSO layer to an EasyReflectometry layer.

Source code in src/easyreflectometry/orso_utils.py
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
def _convert_orso_layer_to_erl(layer):
    r"""Helper function to convert an ORSO layer to an EasyReflectometry layer."""
    material = layer.material
    # Prefer original_name for the material name, fall back to the formula; a
    # material defined only by its SLD has neither, so never leave it None.
    formula = getattr(material, 'formula', None)
    m_name = layer.original_name if layer.original_name is not None else formula
    if m_name is None:
        m_name = 'material'

    erl_material, rho_m = _convert_orso_material_to_erl(material, m_name)
    # A moment becomes layer magnetism aligned with an in-plane field (the model
    # language has no direction): coupled to a magnetic material, or a direct
    # magnetic SLD when the material has no formula.
    coupled = isinstance(erl_material, MaterialMagnetic)
    magnetism = LayerMagnetism(rho_m=rho_m) if rho_m is not None else None

    # Create and return ERL layer; lengths honour the declared unit (nm default).
    return Layer(
        material=erl_material,
        thickness=_length_to_angstrom(layer.thickness),
        roughness=_length_to_angstrom(layer.roughness),
        name=layer.original_name if layer.original_name is not None else m_name,
        magnetism=magnetism,
        rho_m_coupled=coupled,
    )

_convert_orso_material_to_erl(material, material_name)

Convert an ORSO material to an ERL material and a magnetic SLD.

A formula with a density (mass density, or number density converted with the formula's weight; the mass density wins when both are given) stays density-defined as :class:MaterialDensity, so formula and density remain editable. With a magnetic moment it becomes a :class:MaterialMagnetic, and an explicit SLD then overrides the formula's nuclear SLD. Without a usable formula the material is an SLD :class:Material, and a moment needs a number density.

Returns:

Type Description
tuple

(material, rho_m): rho_m is a magnetic SLD in 1e-6/angstrom^2 for the layer to carry directly, or None (non-magnetic, or the material carries it).

Source code in src/easyreflectometry/orso_utils.py
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
def _convert_orso_material_to_erl(material, material_name) -> tuple:
    """Convert an ORSO material to an ERL material and a magnetic SLD.

    A formula with a density (mass density, or number density converted with
    the formula's weight; the mass density wins when both are given) stays
    density-defined as :class:`MaterialDensity`, so formula and density remain
    editable. With a magnetic moment it becomes a :class:`MaterialMagnetic`,
    and an explicit SLD then overrides the formula's nuclear SLD. Without a
    usable formula the material is an SLD :class:`Material`, and a moment needs
    a number density.

    Returns
    -------
    tuple
        ``(material, rho_m)``: ``rho_m`` is a magnetic SLD in 1e-6/angstrom^2 for
        the layer to carry directly, or None (non-magnetic, or the material
        carries it).
    """
    name = material_name
    formula = getattr(material, 'formula', None) or name
    mass_density = _orso_quantity(material.mass_density, _MASS_DENSITY_UNIT_TO_G_CM3, 'mass density', name)
    number_density = _orso_quantity(material.number_density, _NUMBER_DENSITY_UNIT_TO_INV_ANGSTROM3, 'number density', name)
    moment = _orso_quantity(material.magnetic_moment, _MAGNETIC_MOMENT_UNIT_TO_MUB, 'magnetic moment', name)
    if material.relative_density not in (None, 1.0):
        warnings.warn(
            f'ORSO relative_density of "{name}" is not applied; its densities are used as given.',
            UserWarning,
            stacklevel=4,
        )

    weight = _formula_weight(formula)
    if weight is not None and number_density is not None:
        from_number_density = mass_density_from_number_density(number_density, weight)
        if mass_density is None:
            mass_density = from_number_density
        elif not math.isclose(mass_density, from_number_density, rel_tol=0.01):
            warnings.warn(
                f'ORSO mass and number density of "{name}" disagree; the mass density is used.',
                UserWarning,
                stacklevel=4,
            )

    if mass_density is not None and weight is not None and (material.sld is None or moment is not None):
        if moment is None:
            erl_material = MaterialDensity(chemical_structure=formula, density=mass_density, name=name)
        else:
            erl_material = MaterialMagnetic(chemical_structure=formula, density=mass_density, magnetic_moment=moment, name=name)
        if material.sld is not None:
            erl_material.sld_coupled = False
            erl_material.sld.value, erl_material.isld.value = _get_sld_values(material, name)
        return erl_material, None

    m_sld, m_isld = _get_sld_values(material, name)
    erl_material = Material(sld=m_sld, isld=m_isld, name=name)
    if moment is None:
        return erl_material, None
    if number_density is None:
        warnings.warn(
            f'Magnetic moment of "{name}" not imported: it needs a number density, or a mass density with a chemical formula.',
            UserWarning,
            stacklevel=4,
        )
        return erl_material, None
    return erl_material, number_density * moment * MAGNETIC_SCATTERING_LENGTH * 1e6

_orso_quantity(value, unit_factors, quantity, material_name)

An ORSO model value in the internal unit; None when absent, or in an unknown unit (warned: a guessed unit can be off by orders of magnitude).

Source code in src/easyreflectometry/orso_utils.py
707
708
709
710
711
712
713
714
715
716
717
718
719
720
def _orso_quantity(value, unit_factors: dict, quantity: str, material_name) -> Optional[float]:
    """An ORSO model value in the internal unit; None when absent, or in an
    unknown unit (warned: a guessed unit can be off by orders of magnitude)."""
    if value is None:
        return None
    unit = getattr(value, 'unit', None)
    if unit not in unit_factors:
        warnings.warn(
            f'Unknown ORSO {quantity} unit {unit!r} for "{material_name}"; the {quantity} is ignored.',
            UserWarning,
            stacklevel=5,
        )
        return None
    return float(getattr(value, 'magnitude', value)) * unit_factors[unit]

_formula_weight(formula)

Molecular weight of a formula; None when it is not a chemical formula.

Source code in src/easyreflectometry/orso_utils.py
723
724
725
726
727
728
729
def _formula_weight(formula) -> Optional[float]:
    """Molecular weight of a formula; None when it is not a chemical formula."""
    try:
        weight = molecular_weight(formula)
    except Exception:
        return None
    return weight or None

_get_sld_values(material, material_name)

Extract the SLD values of a material, 0 when it declares none.

Density-defined materials are converted by _convert_orso_material_to_erl; a density reaching this point could not be used (no formula, or an unknown unit) and is not reinterpreted here.

Note: ORSO stores SLD in absolute units (A^-2), but the internal representation uses 10^-6 A^-2. When reading directly from ORSO, we multiply by 1e6 to convert.

Source code in src/easyreflectometry/orso_utils.py
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
def _get_sld_values(material, material_name):
    """Extract the SLD values of a material, 0 when it declares none.

    Density-defined materials are converted by `_convert_orso_material_to_erl`;
    a density reaching this point could not be used (no formula, or an unknown
    unit) and is not reinterpreted here.

    Note: ORSO stores SLD in absolute units (A^-2), but the internal representation
    uses 10^-6 A^-2. When reading directly from ORSO, we multiply by 1e6 to convert.
    """
    if material.sld is None:
        m_sld = 0.0
        m_isld = 0.0
    else:
        # ORSO stores SLD in absolute units (A^-2, or 1/nm^2 when declared).
        # Convert to internal representation (10^-6 A^-2).
        sld = material.sld
        unit = getattr(sld, 'unit', None)
        try:
            unit_factor = _SLD_UNIT_TO_INV_ANGSTROM_SQUARED[unit]
        except KeyError:
            warnings.warn(
                f'Unknown ORSO SLD unit {unit!r}; value used as 1/angstrom^2.',
                UserWarning,
                stacklevel=4,
            )
            unit_factor = 1.0
        if isinstance(sld, ComplexValue):
            raw_sld = sld.real
            m_sld = raw_sld * unit_factor * 1e6
            m_isld = (sld.imag if sld.imag is not None else 0.0) * unit_factor * 1e6
        else:
            raw_sld = getattr(sld, 'magnitude', sld)
            m_sld = raw_sld * unit_factor * 1e6
            m_isld = 0.0
        if raw_sld != 0.0 and abs(raw_sld) > 1e-2:
            warnings.warn(
                f'ORSO SLD value {raw_sld} for "{material_name}" seems large for '
                f'absolute units (A^-2). Verify the file stores SLD in A^-2, not '
                f'10^-6 A^-2, as the value is multiplied by 1e6 internally.',
                UserWarning,
                stacklevel=3,
            )

    return m_sld, m_isld

_sanitize_model_key(name)

Make a layer name safe for use in the model-language stack string.

Source code in src/easyreflectometry/orso_utils.py
784
785
786
787
def _sanitize_model_key(name: str) -> str:
    """Make a layer name safe for use in the model-language stack string."""
    cleaned = ''.join('_' if character in '|()' else character for character in str(name)).strip()
    return cleaned or 'layer'

_unique_model_key(existing, name)

A stack key not yet present in existing (appends a counter if needed).

Source code in src/easyreflectometry/orso_utils.py
790
791
792
793
794
795
796
797
798
def _unique_model_key(existing: dict, name: str) -> str:
    """A stack key not yet present in *existing* (appends a counter if needed)."""
    key = _sanitize_model_key(name)
    if key not in existing:
        return key
    counter = 2
    while f'{key}_{counter}' in existing:
        counter += 1
    return f'{key}_{counter}'

_layer_material_to_orso(layer)

Convert an ERL layer's material, and its magnetism, to an ORSO material.

The model language stores a moment per formula unit, not a magnetic SLD or a direction, so only part of a magnetic layer can be written; whatever is lost is warned about. A layer whose material has a number density gets the moment matching its magnetic SLD (the material's own moment when the layer is coupled to it); the direction is always written as aligned with an in-plane field.

Source code in src/easyreflectometry/orso_utils.py
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
def _layer_material_to_orso(layer) -> model_language.Material:
    """Convert an ERL layer's material, and its magnetism, to an ORSO material.

    The model language stores a moment per formula unit, not a magnetic SLD or
    a direction, so only part of a magnetic layer can be written; whatever is
    lost is warned about. A layer whose material has a number density gets
    the moment matching its magnetic SLD (the material's own moment when the
    layer is coupled to it); the direction is always written as aligned with
    an in-plane field.
    """
    material = layer.material
    sld = ComplexValue(
        real=float(material.sld.value) * 1e-6,
        imag=float(material.isld.value) * 1e-6,
        unit='1/angstrom^2',
    )
    if not isinstance(material, MaterialDensity):
        orso_material = model_language.Material(sld=sld)
    else:
        orso_material = model_language.Material(
            formula=material.chemical_structure,
            mass_density=Value(float(material.density.value), 'g/cm^3'),
            # A manual SLD is written too; it wins over the formula on reload.
            sld=None if material.sld_coupled else sld,
        )

    magnetism = layer.magnetism
    if magnetism is None:
        return orso_material
    rho_m = float(magnetism.rho_m.value)
    if not isinstance(material, MaterialDensity) or material.number_density <= 0:
        if rho_m != 0.0:
            warnings.warn(
                f'Magnetic SLD of layer "{layer.name}" not written: the ORSO model language stores '
                'moments per formula unit, and its material has no density to convert with.',
                UserWarning,
                stacklevel=3,
            )
        return orso_material
    if layer.rho_m_coupled:
        moment = float(material.magnetic_moment.value)
    else:
        moment = rho_m / (material.number_density * MAGNETIC_SCATTERING_LENGTH * 1e6)
    orso_material.magnetic_moment = Value(moment, 'muB')
    if not math.isclose(math.cos(math.radians(magnetism.theta_m.value - 270.0)), 1.0, abs_tol=1e-9):
        warnings.warn(
            f'Moment direction of layer "{layer.name}" not written (theta_m = {magnetism.theta_m.value}); '
            'the ORSO model language has none, and it reloads aligned with an in-plane field.',
            UserWarning,
            stacklevel=3,
        )
    return orso_material

sample_to_orso_model(sample)

Convert an ERL Sample into an ORSO simple-model SampleModel (slab model).

Lengths are written in angstrom (declared via globals.length_unit), SLDs in 1/angstrom^2. :class:RepeatingMultilayer assemblies are written with the inline repetition syntax N ( layer1 | layer2 ).

Parameters:

Name Type Description Default
sample Sample

The sample to convert.

required

Returns:

Type Description
model_language.SampleModel

The ORSO model-language representation.

Source code in src/easyreflectometry/orso_utils.py
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
def sample_to_orso_model(sample: Sample) -> model_language.SampleModel:
    """Convert an ERL Sample into an ORSO simple-model ``SampleModel`` (slab model).

    Lengths are written in angstrom (declared via ``globals.length_unit``), SLDs
    in 1/angstrom^2.  :class:`RepeatingMultilayer` assemblies are written with
    the inline repetition syntax ``N ( layer1 | layer2 )``.

    Parameters
    ----------
    sample : Sample
        The sample to convert.

    Returns
    -------
    model_language.SampleModel
        The ORSO model-language representation.
    """
    layers = {}
    stack_parts = []

    def add_layer(erl_layer) -> str:
        key = _unique_model_key(layers, erl_layer.name or erl_layer.material.name)
        layers[key] = model_language.Layer(
            thickness=Value(float(erl_layer.thickness.value), 'angstrom'),
            roughness=Value(float(erl_layer.roughness.value), 'angstrom'),
            material=_layer_material_to_orso(erl_layer),
        )
        return key

    for assembly in sample:
        keys = [add_layer(erl_layer) for erl_layer in assembly.layers]
        if isinstance(assembly, RepeatingMultilayer) and int(assembly.repetitions.value) > 1:
            stack_parts.append(f'{int(assembly.repetitions.value)} ( ' + ' | '.join(keys) + ' )')
        else:
            stack_parts.extend(keys)

    return model_language.SampleModel(
        stack=' | '.join(stack_parts),
        layers=layers,
        globals=model_language.ModelParameters(length_unit='angstrom', sld_unit='1/angstrom^2'),
    )

_orso_header_for_dataset(dataset, model=None)

Build an Orso header for one dataset, reusing a preserved header when present.

A header captured at load time (dataset.orso_header) keeps data_source/reduction provenance; otherwise a minimal header is synthesized. Columns are always (re)set to the standard four with the sigma convention, matching the exported data.

Source code in src/easyreflectometry/orso_utils.py
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
def _orso_header_for_dataset(dataset: DataSet1D, model=None) -> Orso:
    """Build an Orso header for one dataset, reusing a preserved header when present.

    A header captured at load time (``dataset.orso_header``) keeps
    ``data_source``/``reduction`` provenance; otherwise a minimal header is
    synthesized.  Columns are always (re)set to the standard four with the
    sigma convention, matching the exported data.
    """
    import copy as _copy

    header_dict = getattr(dataset, 'orso_header', None)
    if header_dict:
        try:
            info = Orso.from_dict(_copy.deepcopy(dict(header_dict)))
        except Exception:
            info = Orso.empty()
    else:
        info = Orso.empty()
    info.columns = _copy.deepcopy(_STANDARD_COLUMNS)
    if model is not None and getattr(model, 'sample', None) is not None:
        info.data_source.sample.model = sample_to_orso_model(model.sample)
        if info.data_source.sample.name is None:
            info.data_source.sample.name = model.sample.name
    return info

_dataset_to_data_array(dataset)

Assemble the Qz/R/sR/sQz array for export.

DataSet1D stores variances in ye/xe; ORSO columns are sigma, so the square root is taken here. Absent errors are written as nan (the spec's "unknown" marker), never as zeros.

Source code in src/easyreflectometry/orso_utils.py
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
def _dataset_to_data_array(dataset: DataSet1D) -> np.ndarray:
    """Assemble the Qz/R/sR/sQz array for export.

    ``DataSet1D`` stores **variances** in ``ye``/``xe``; ORSO columns are sigma,
    so the square root is taken here.  Absent errors are written as nan
    (the spec's "unknown" marker), never as zeros.
    """
    x = np.asarray(dataset.x, dtype=float)
    y = np.asarray(dataset.y, dtype=float)

    def sigma_column(variances) -> np.ndarray:
        if variances is None:
            return np.full_like(x, np.nan)
        variances = np.asarray(variances, dtype=float)
        if variances.size != x.size or not np.any(np.nan_to_num(variances) > 0):
            return np.full_like(x, np.nan)
        return np.sqrt(variances)

    return np.column_stack([x, y, sigma_column(dataset.ye), sigma_column(dataset.xe)])

orso_datasets_from_experiment(experiment, model=None)

Convert an experiment into a list of OrsoDataset objects.

A plain :class:DataSet1D becomes one dataset. A PolarizedDataSet becomes one dataset per spin channel in a single multi-dataset file -- the format's intended packing for spin states -- with data_set labels and per-dataset instrument_settings.polarization set to the channel.

Parameters:

Name Type Description Default
experiment

A DataSet1D or PolarizedDataSet.

required
model optional

The model whose sample is written into sample.model. Defaults to experiment.model.

None

Returns:

Type Description
List[OrsoDataset]

The datasets ready for save_orso/save_nexus.

Source code in src/easyreflectometry/orso_utils.py
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
def orso_datasets_from_experiment(experiment, model=None) -> List[OrsoDataset]:
    """Convert an experiment into a list of ``OrsoDataset`` objects.

    A plain :class:`DataSet1D` becomes one dataset.  A ``PolarizedDataSet``
    becomes one dataset per spin channel in a single multi-dataset file --
    the format's intended packing for spin states -- with ``data_set`` labels
    and per-dataset ``instrument_settings.polarization`` set to the channel.

    Parameters
    ----------
    experiment :
        A DataSet1D or PolarizedDataSet.
    model : optional
        The model whose sample is written into ``sample.model``. Defaults to
        ``experiment.model``.

    Returns
    -------
    List[OrsoDataset]
        The datasets ready for ``save_orso``/``save_nexus``.
    """
    from orsopy.fileio.data_source import InstrumentSettings
    from orsopy.fileio.data_source import Polarization

    from easyreflectometry.data.polarized import PolarizedDataSet

    if model is None:
        model = getattr(experiment, 'model', None)

    datasets = []
    if isinstance(experiment, PolarizedDataSet):
        for channel, channel_dataset in experiment.channels.items():
            info = _orso_header_for_dataset(channel_dataset, model)
            info.data_set = channel.value
            measurement = info.data_source.measurement
            if measurement.instrument_settings is None:
                measurement.instrument_settings = InstrumentSettings(incident_angle=None, wavelength=None)
            measurement.instrument_settings.polarization = Polarization(channel.value)
            datasets.append(OrsoDataset(info=info, data=_dataset_to_data_array(channel_dataset)))
    else:
        info = _orso_header_for_dataset(experiment, model)
        if info.data_set is None:
            info.data_set = 0
        datasets.append(OrsoDataset(info=info, data=_dataset_to_data_array(experiment)))
    return datasets

save_orso_experiment(experiment, fname, model=None)

Write an experiment to an ORSO file (.ort text, or .orb binary).

Parameters:

Name Type Description Default
experiment

A DataSet1D or PolarizedDataSet (the latter is written as one file with one data_set block per spin channel).

required
fname str

Destination path; a .orb extension selects the binary (NeXus/HDF5) representation, anything else the text one.

required
model optional

The model whose sample is exported as the ORSO sample.model. Defaults to the experiment's model.

None
Source code in src/easyreflectometry/orso_utils.py
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
def save_orso_experiment(experiment, fname: str, model=None) -> None:
    """Write an experiment to an ORSO file (`.ort` text, or `.orb` binary).

    Parameters
    ----------
    experiment :
        A DataSet1D or PolarizedDataSet (the latter is written as one file with
        one ``data_set`` block per spin channel).
    fname : str
        Destination path; a ``.orb`` extension selects the binary (NeXus/HDF5)
        representation, anything else the text one.
    model : optional
        The model whose sample is exported as the ORSO ``sample.model``.
        Defaults to the experiment's model.
    """
    fname = str(fname)
    datasets = orso_datasets_from_experiment(experiment, model=model)
    if fname.lower().endswith('.orb'):
        orso.save_nexus(datasets, fname)
    else:
        orso.save_orso(datasets, fname)