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

_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
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
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,
    ):
        """Init function."""
        self._model = model
        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)

Init function.

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
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,
):
    """Init function."""
    self._model = model
    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
85
86
87
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
89
90
91
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
 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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
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]]
        args :
            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):
        """Add an assembly to the sample.

        Parameters
        ----------
        index : int
        assembly :
            Assembly to add.
        """
        # 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

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]]
()
args

The assemblies in the sample.

required
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
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
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]]
    args :
        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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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)

Add an assembly to the sample.

Parameters:

Name Type Description Default
index int
required
assembly

Assembly to add.

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

    Parameters
    ----------
    index : int
    assembly :
        Assembly to add.
    """
    # 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
118
119
120
121
122
123
124
125
126
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
128
129
130
131
132
133
134
135
136
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
138
139
140
141
142
143
144
145
146
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)

Layer

Bases: BaseCore

Source code in src/easyreflectometry/sample/elements/layers/layer.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
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
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,
    ):
        """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.
        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

        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:
        self._material = value

    @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).
        """
        if value is None and self._magnetism is not None:
            if self.interface is not None:
                self.interface().remove_layer_magnetism(self.unique_name)
            # Detach the calculator callbacks of the removed parameters so later
            # value changes on the detached object 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.
            self._magnetism.rho_m._callback = property()
            self._magnetism.theta_m._callback = property()
        self._magnetism = value
        if value is not None and self.interface is not None:
            self._enable_calculator_magnetism()
            self.generate_bindings()

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

        Parameters
        ----------
        material : Material
            The material to assign to the layer.
        """
        self._material = material
        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

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

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
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
 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
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,
):
    """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.
    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

    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
106
107
108
109
110
111
112
113
@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
175
176
177
178
179
180
181
182
183
184
185
def assign_material(self, material: Material) -> None:
    """Assign a material to the layer interface.

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

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
 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
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
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'),
        )
        scattering_length_real = get_as_parameter(
            name='scattering_length_real',
            value=scattering_length.real,
            default_dict=DEFAULTS['sld'],
            unique_name_prefix=f'{unique_name}_ScatteringLengthReal',
        )
        scattering_length_imag = get_as_parameter(
            name='scattering_length_imag',
            value=scattering_length.imag,
            default_dict=DEFAULTS['isld'],
            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',
        )

        dependency_expression = '1e-23*(0.602214076e6 * d * sl) / mw'
        dependency_map = {'d': density, 'sl': scattering_length_real, 'mw': mw}
        sld.make_dependent_on(dependency_expression=dependency_expression, dependency_map=dependency_map)

        dependency_map = {'d': density, 'sl': scattering_length_imag, 'mw': mw}
        isld.make_dependent_on(dependency_expression=dependency_expression, dependency_map=dependency_map)

        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._density = density
        self._chemical_structure = chemical_structure

        if interface is not None:
            self.interface = interface

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

        Idempotent — invoked once from `__init__` and again from `from_dict`
        after :class:`ModelBase` has swapped in the saved Parameter objects.
        """
        for derived in (self._sld, self._isld):
            if not derived.independent:
                derived.make_independent()

        dependency_expression = '1e-23*(0.602214076e6 * d * sl) / mw'
        self._sld.make_dependent_on(
            dependency_expression=dependency_expression,
            dependency_map={
                'd': self._density,
                'sl': self._scattering_length_real,
                'mw': self._molecular_weight,
            },
        )
        self._isld.make_dependent_on(
            dependency_expression=dependency_expression,
            dependency_map={
                'd': self._density,
                'sl': self._scattering_length_imag,
                'mw': self._molecular_weight,
            },
        )

    @property
    def sld_coupled(self) -> bool:
        """Whether ``sld``/``isld`` are derived from formula & density (True,
        the default) or independent, directly editable/fittable parameters
        (False). The dependency state itself is the source of truth."""
        return not self._sld.independent

    @sld_coupled.setter
    def sld_coupled(self, couple: bool) -> None:
        if couple == self.sld_coupled:
            return
        if couple:
            # Recomputes sld/isld from the current density/scattering
            # length/molecular weight — manually set values 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()

    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 d

    @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 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). The dependency state itself is the source of truth.

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.

__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
 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
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'),
    )
    scattering_length_real = get_as_parameter(
        name='scattering_length_real',
        value=scattering_length.real,
        default_dict=DEFAULTS['sld'],
        unique_name_prefix=f'{unique_name}_ScatteringLengthReal',
    )
    scattering_length_imag = get_as_parameter(
        name='scattering_length_imag',
        value=scattering_length.imag,
        default_dict=DEFAULTS['isld'],
        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',
    )

    dependency_expression = '1e-23*(0.602214076e6 * d * sl) / mw'
    dependency_map = {'d': density, 'sl': scattering_length_real, 'mw': mw}
    sld.make_dependent_on(dependency_expression=dependency_expression, dependency_map=dependency_map)

    dependency_map = {'d': density, 'sl': scattering_length_imag, 'mw': mw}
    isld.make_dependent_on(dependency_expression=dependency_expression, dependency_map=dependency_map)

    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._density = density
    self._chemical_structure = chemical_structure

    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
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
@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

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
 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
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
122
123
124
125
126
127
128
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
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
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
158
159
160
161
162
163
164
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
167
168
169
170
171
172
173
174
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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
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
201
202
203
204
205
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
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
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
241
242
243
244
245
246
247
248
249
250
251
252
253
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
256
257
258
259
260
261
262
263
264
265
266
267
268
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
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
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
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
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
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
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
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
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
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
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
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
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
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
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
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:
        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
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
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 = _convert_orso_material_to_erl(material, m_name)

    # 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,
    )

_convert_orso_material_to_erl(material, material_name)

Convert an ORSO material to an ERL material.

Density-defined materials (formula + mass density, no SLD) stay density-defined as :class:MaterialDensity, so formula and density remain editable/recoverable instead of being flattened to a numeric SLD.

Source code in src/easyreflectometry/orso_utils.py
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
def _convert_orso_material_to_erl(material, material_name):
    """Convert an ORSO material to an ERL material.

    Density-defined materials (formula + mass density, no SLD) stay
    density-defined as :class:`MaterialDensity`, so formula and density remain
    editable/recoverable instead of being flattened to a numeric SLD.
    """
    formula = getattr(material, 'formula', None)
    mass_density = getattr(material, 'mass_density', None)
    if material.sld is None and mass_density is not None and (formula or material_name):
        magnitude = getattr(mass_density, 'magnitude', mass_density)
        unit = getattr(mass_density, 'unit', None)
        try:
            factor = _MASS_DENSITY_UNIT_TO_G_CM3[unit]
        except KeyError:
            warnings.warn(
                f'Unknown ORSO mass density unit {unit!r}; value used as g/cm^3.',
                UserWarning,
                stacklevel=4,
            )
            factor = 1.0
        return MaterialDensity(
            chemical_structure=formula if formula is not None else material_name,
            density=float(magnitude) * factor,
            name=material_name if material_name is not None else formula,
        )
    m_sld, m_isld = _get_sld_values(material, material_name)
    return Material(sld=m_sld, isld=m_isld, name=material_name)

_get_sld_values(material, material_name)

Extract SLD values from material, calculating from density if needed

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. When calculating from mass density, MaterialDensity already returns the correct units..

Source code in src/easyreflectometry/orso_utils.py
636
637
638
639
640
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
def _get_sld_values(material, material_name):
    """Extract SLD values from material, calculating from density if needed

    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.
    When calculating from mass density, MaterialDensity already returns the correct units..
    """
    if material.sld is None and material.mass_density is not None:
        # Calculate SLD from mass density
        # MaterialDensity already returns values in 10^-6 A^-2 units
        m_density = getattr(material.mass_density, 'magnitude', material.mass_density)
        density = MaterialDensity(chemical_structure=material_name, density=m_density)
        m_sld = density.sld.value
        m_isld = density.isld.value
    elif material.sld is None:
        # No SLD and no mass density available, default to 0.0
        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
693
694
695
696
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
699
700
701
702
703
704
705
706
707
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}'

_material_to_orso(material)

Convert an ERL material to an ORSO model-language material.

Source code in src/easyreflectometry/orso_utils.py
710
711
712
713
714
715
716
717
718
719
720
721
722
723
def _material_to_orso(material) -> model_language.Material:
    """Convert an ERL material to an ORSO model-language material."""
    if isinstance(material, MaterialDensity):
        return model_language.Material(
            formula=material.chemical_structure,
            mass_density=Value(float(material.density.value), 'g/cm^3'),
        )
    return model_language.Material(
        sld=ComplexValue(
            real=float(material.sld.value) * 1e-6,
            imag=float(material.isld.value) * 1e-6,
            unit='1/angstrom^2',
        )
    )

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
726
727
728
729
730
731
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
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=_material_to_orso(erl_layer.material),
        )
        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
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
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
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
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
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
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
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
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
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)