Skip to content

Contrasts

A contrast is one structure measured under another SLD condition: the same film in D2O and in H2O, or a monolayer with deuterated and hydrogenated tails. Each contrast is an ordinary Model, with its own scale, background and resolution; what the contrasts have in common is shared in one of two ways:

  • by identity: an assembly or material is one object used by several models, so it has one set of parameters;
  • by equality: a parameter of one model follows the corresponding parameter of another, where the objects must differ (a hydrated layer whose solvent differs between contrasts).

The Project offers two routes to that state. Adding a contrast derives a new model from a reference with derive_contrast: assemblies the substitutions do not touch are shared, touched ones are copied with every parameter tied to the reference's except those of the replacement.

from easyreflectometry.contrasts import ReplaceFormula
from easyreflectometry.contrasts import ReplaceMaterial

h2o_index = project.add_contrast(0, 'Film in H2O', [ReplaceMaterial(d2o, h2o)])
h_tails = project.add_contrast(0, 'h-tails', [ReplaceFormula(tail_layer, 'C32H64')])
project.parameter_models(film_layer.thickness)  # [0, 1, 2]: one parameter of three models

Linking contrasts ties two models that exist already. Parameters correspond by their path within the model, so the models need the same layout: the same assemblies and layers, with materials of the same kind (a MaterialDensity and a plain Material do not correspond); plan_link raises LayoutMismatch otherwise. The plan changes nothing: each LinkRow says what linking would do with one pair of parameters ('tie', 'skip', 'already_tied', 'conflict'), and with the reason. Scale and background always stay per contrast. A material's chemistry (CHEMISTRY: SLD, density, scattering lengths) is 'undecided' until materials='tie' or materials='skip' is given, since contrasts usually differ in it.

plan = project.plan_link(follower_index, reference_index, materials='skip')
[(row.path, row.action, row.reason) for row in plan.rows]
record = project.apply_link(plan)  # ties the 'tie' rows, all or none
project.unlink(record)  # restores each follower's value and bounds from before the link

apply_link refuses a plan with undecided rows, or one the project has outdated since it was made. Project.detach frees a shared parameter in one model, which then gets its own copy of the assembly. The derivation of each contrast, the links and the sharing are saved with the project. See the contrasts tutorial for a worked example.

contrasts

Contrasts: one structure measured under several SLD conditions.

Each contrast is an ordinary :class:~easyreflectometry.model.Model with its own scale, background and resolution. What the contrasts have in common is shared in one of two ways:

  • by identity: an assembly or material is one object used by several models, so it has one set of parameters;
  • by equality: a parameter of one model follows the corresponding parameter of another (constrain_equal), where the objects must differ, e.g. a hydrated layer whose solvent differs between contrasts.

:func:derive_contrast builds a new contrast from a reference model and a list of substitutions; :func:plan_link lays out the equalities that would tie the parameters of two existing models. :class:~easyreflectometry.project.Project wraps both (add_contrast, plan_link/apply_link/unlink, detach).

CHEMISTRY = frozenset({'sld', 'isld', 'density', 'scattering_length_real', 'scattering_length_imag'}) module-attribute

ReplaceMaterial dataclass

Use new wherever the reference uses old (a material, or a mixture's component).

Source code in src/easyreflectometry/contrasts.py
62
63
64
65
66
67
@dataclass(frozen=True)
class ReplaceMaterial:
    """Use `new` wherever the reference uses `old` (a material, or a mixture's component)."""

    old: Union[Material, MaterialMixture]
    new: Union[Material, MaterialMixture]

ReplaceFormula dataclass

Give the contrast's copy of target another chemical formula (isotopic substitution).

Source code in src/easyreflectometry/contrasts.py
70
71
72
73
74
75
@dataclass(frozen=True)
class ReplaceFormula:
    """Give the contrast's copy of `target` another chemical formula (isotopic substitution)."""

    target: Union[LayerAreaPerMolecule, MaterialDensity]
    formula: str

UnsupportedSubstitution

Bases: ValueError

A substitution :func:derive_contrast cannot carry out; nothing was built.

Source code in src/easyreflectometry/contrasts.py
54
55
class UnsupportedSubstitution(ValueError):
    """A substitution :func:`derive_contrast` cannot carry out; nothing was built."""

LinkPlan dataclass

Source code in src/easyreflectometry/contrasts.py
228
229
230
231
232
233
234
235
236
237
238
@dataclass
class LinkPlan:
    follower: Model
    reference: Model
    rows: list[LinkRow]

    def outdated(self, current: 'LinkPlan') -> list[str]:
        """Paths of the rows to tie that `current` (the same link, planned now) no longer allows:
        the parameters changed, or the row is already tied, shared with another model, ..."""
        rows = {row.path: row for row in current.rows}
        return [row.path for row in self.rows if row.action == 'tie' and not _tieable_now(row, rows.get(row.path))]

outdated(current)

Paths of the rows to tie that current (the same link, planned now) no longer allows: the parameters changed, or the row is already tied, shared with another model, ...

Source code in src/easyreflectometry/contrasts.py
234
235
236
237
238
def outdated(self, current: 'LinkPlan') -> list[str]:
    """Paths of the rows to tie that `current` (the same link, planned now) no longer allows:
    the parameters changed, or the row is already tied, shared with another model, ..."""
    rows = {row.path: row for row in current.rows}
    return [row.path for row in self.rows if row.action == 'tie' and not _tieable_now(row, rows.get(row.path))]

LinkRow dataclass

One pair of corresponding parameters of two models and what linking does with it.

action is 'tie', 'skip', 'already_tied', 'conflict' or 'undecided' (a chemistry parameter: set it to 'tie' or 'skip').

Source code in src/easyreflectometry/contrasts.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
@dataclass
class LinkRow:
    """One pair of corresponding parameters of two models and what linking does with it.

    `action` is ``'tie'``, ``'skip'``, ``'already_tied'``, ``'conflict'`` or
    ``'undecided'`` (a chemistry parameter: set it to ``'tie'`` or ``'skip'``).
    """

    path: str
    follower: Parameter
    reference: Parameter
    action: str
    reason: str = ''
    #: Indices of the other models the follower parameter belongs to as well.
    shared_with: list[int] = field(default_factory=list)

LinkRecord dataclass

The equalities one :meth:Project.apply_link made, so they can be undone together.

Source code in src/easyreflectometry/contrasts.py
250
251
252
253
254
255
256
257
@dataclass
class LinkRecord:
    """The equalities one :meth:`Project.apply_link` made, so they can be undone together."""

    follower: Model
    reference: Model
    #: (follower parameter, reference parameter, its :func:`snapshot` from before the tie)
    pairs: list[tuple[Parameter, Parameter, dict]]

LayoutMismatch

Bases: ValueError

Two models whose parameters do not correspond one to one.

Source code in src/easyreflectometry/contrasts.py
58
59
class LayoutMismatch(ValueError):
    """Two models whose parameters do not correspond one to one."""

derive_contrast(reference, *, name, substitutions=None)

A new contrast of reference's structure; reference is not changed.

Assemblies the substitutions do not touch are shared with reference. An assembly they touch is copied: its materials are shared except the replaced ones, its layers are new, and every independent parameter of the copy is tied to the reference's except those of the replacements (and the scattering lengths of a re-formulated material). So a hydrated layer in another solvent keeps the reference's thickness, roughness and solvent fraction, and its effective SLD follows the dry material it still shares. Scale, background and resolution are copied by value and stay independent.

Raises:

Type Description
UnsupportedSubstitution

A target is not part of reference, or a formula target is neither a :class:LayerAreaPerMolecule nor a :class:MaterialDensity.

Source code in src/easyreflectometry/contrasts.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
def derive_contrast(
    reference: Model,
    *,
    name: str,
    substitutions: Optional[Sequence[Union[ReplaceMaterial, ReplaceFormula]]] = None,
) -> Model:
    """A new contrast of `reference`'s structure; `reference` is not changed.

    Assemblies the substitutions do not touch are shared with `reference`.
    An assembly they touch is copied: its materials are shared except the
    replaced ones, its layers are new, and every independent parameter of the
    copy is tied to the reference's except those of the replacements (and the
    scattering lengths of a re-formulated material). So a hydrated layer in
    another solvent keeps the reference's thickness, roughness and solvent
    fraction, and its effective SLD follows the dry material it still shares.
    Scale, background and resolution are copied by value and stay independent.

    Raises
    ------
    UnsupportedSubstitution
        A target is not part of `reference`, or a formula target is neither a
        :class:`LayerAreaPerMolecule` nor a :class:`MaterialDensity`.
    """
    substitutions = list(substitutions or [])
    swaps = {id(item.old): item.new for item in substitutions if isinstance(item, ReplaceMaterial)}
    formulas = {id(item.target): item for item in substitutions if isinstance(item, ReplaceFormula)}
    targets = set(swaps) | set(formulas)
    # What may be substituted is decided once, in substitution_candidates: a material a
    # LayerAreaPerMolecule builds for itself is rebuilt from the formula, so replacing it
    # would do nothing.
    materials, formula_targets = substitution_candidates(reference)
    replaceable = {id(material) for material in materials}
    reformulable = {id(target) for target in formula_targets}
    in_reference = {id(obj) for obj in _objects(reference.sample)}
    for item in substitutions:
        target = item.old if isinstance(item, ReplaceMaterial) else item.target
        if id(target) not in in_reference:
            raise UnsupportedSubstitution(f"'{getattr(target, 'name', target)}' is not part of '{reference.name}'.")
        if isinstance(item, ReplaceMaterial):
            if id(target) not in replaceable:
                raise UnsupportedSubstitution(
                    f"'{target.name}' is built by a layer from its chemical formula; use ReplaceFormula on that layer."
                )
        else:
            if id(target) not in reformulable:
                raise UnsupportedSubstitution(f"'{target.name}' has no chemical formula to replace.")
            _check_formula(item.formula)

    assemblies = []
    for assembly in reference.sample:
        if not any(id(obj) in targets for obj in _objects(assembly)):
            assemblies.append(assembly)
            continue
        copy = references.rebuild(assembly, kept_children(assembly, swaps, targets))
        corresponding = _corresponding_objects(assembly, copy)
        excluded = {id(parameter) for new in swaps.values() for parameter in new.get_all_parameters()}
        for target_id, item in formulas.items():
            if target_id in corresponding:
                target_copy = corresponding[target_id]
                if isinstance(target_copy, LayerAreaPerMolecule):
                    target_copy.molecular_formula = item.formula
                else:
                    target_copy.chemical_structure = item.formula
                    excluded |= {id(target_copy.scattering_length_real), id(target_copy.scattering_length_imag)}
        tie_corresponding(copy, assembly, excluded)
        assemblies.append(copy)

    model = Model(
        sample=Sample(*assemblies),
        name=name,
        resolution_function=ResolutionFunction.from_dict(reference.resolution_function.as_dict()),
    )
    for attribute in ('scale', 'background', 'guide_field_angle', 'applied_field'):
        restore(getattr(model, attribute), snapshot(getattr(reference, attribute)))
    return model

substitution_candidates(model)

What :func:derive_contrast can substitute in model, each once, in sample order.

Returns:

Type Description
tuple[list, list]

The materials and mixture components a :class:ReplaceMaterial can replace (not those a :class:LayerAreaPerMolecule builds for itself), and the layers and materials a :class:ReplaceFormula can re-formulate.

Source code in src/easyreflectometry/contrasts.py
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
def substitution_candidates(model: Model) -> tuple[list[Any], list[Any]]:
    """What :func:`derive_contrast` can substitute in `model`, each once, in sample order.

    Returns
    -------
    tuple[list, list]
        The materials and mixture components a :class:`ReplaceMaterial` can
        replace (not those a :class:`LayerAreaPerMolecule` builds for itself),
        and the layers and materials a :class:`ReplaceFormula` can re-formulate.
    """
    materials: list[Any] = []
    formulas: list[Any] = []
    seen: set[int] = set()

    def visit(node: Any, owned: bool) -> None:
        if id(node) in seen or isinstance(node, DescriptorNumber):
            return
        seen.add(id(node))
        if isinstance(node, (LayerAreaPerMolecule, MaterialDensity)):
            formulas.append(node)
        if isinstance(node, (Material, MaterialMixture)) and not owned:
            materials.append(node)
        for _, child in paths.children(node):
            internal = isinstance(node, LayerAreaPerMolecule) and child is node.material
            visit(child, internal or (owned and child is not getattr(node, 'solvent', None)))

    visit(model.sample, False)
    return materials, formulas

The equalities that would tie follower's parameters to reference's; nothing is changed.

Parameters correspond by their path within the model, so the two layouts must match. Scale and background stay per contrast; parameters that are one object, derived, disabled or followed by something else are skipped with a reason; a parameter the follower shares with a third model is skipped as well (tying it would move that model too). Chemistry parameters (:data:CHEMISTRY) are 'undecided' unless materials is 'tie' or 'skip'.

Raises:

Type Description
LayoutMismatch

The models' parameters do not correspond one to one.

Source code in src/easyreflectometry/contrasts.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def plan_link(
    follower: Model,
    reference: Model,
    *,
    owners: Callable[[Parameter], list[int]],
    follower_index: int,
    materials: Optional[str] = None,
) -> LinkPlan:
    """The equalities that would tie `follower`'s parameters to `reference`'s; nothing is changed.

    Parameters correspond by their path within the model, so the two layouts
    must match. Scale and background stay per contrast; parameters that are
    one object, derived, disabled or followed by something else are skipped
    with a reason; a parameter the follower shares with a third model is
    skipped as well (tying it would move that model too). Chemistry
    parameters (:data:`CHEMISTRY`) are ``'undecided'`` unless `materials` is
    ``'tie'`` or ``'skip'``.

    Raises
    ------
    LayoutMismatch
        The models' parameters do not correspond one to one.
    """
    # Every path: a material used by two layers of one model and by two distinct
    # objects in the other still corresponds layer by layer.
    follower_parameters = _parameters(follower.sample, ['sample'], every_path=True)
    reference_parameters = _parameters(reference.sample, ['sample'], every_path=True)
    if follower_parameters.keys() != reference_parameters.keys():
        mismatch = sorted(follower_parameters.keys() ^ reference_parameters.keys())[0]
        raise LayoutMismatch(f"'{follower.name}' and '{reference.name}' differ in layout at {mismatch}.")
    rows = [
        LinkRow(name, getattr(follower, name), getattr(reference, name), 'skip', 'per contrast')
        for name in ('scale', 'background')
    ]
    planned: dict[int, LinkRow] = {}
    for path, parameter in follower_parameters.items():
        row = LinkRow(path, parameter, reference_parameters[path], 'tie')
        first = planned.get(id(parameter))
        if first is None:
            planned[id(parameter)] = row
            _classify(row, follower_index, owners, materials)
        elif first.reference is row.reference or first.action not in ('tie', 'undecided'):
            row.action, row.reason = 'skip', f'the same parameter as {first.path}'
        else:
            # The follower uses one parameter where the reference uses two: no silent choice.
            row.action = first.action = 'conflict'
            row.reason = first.reason = f'one parameter at {first.path} and {path}, two different reference parameters'
        rows.append(row)
    return LinkPlan(follower, reference, rows)

follows(parameter, leader)

Whether parameter is a plain equality tie to leader.

Source code in src/easyreflectometry/contrasts.py
355
356
357
358
359
360
361
362
363
def follows(parameter: Parameter, leader: Parameter) -> bool:
    """Whether `parameter` is a plain equality tie to `leader`."""
    dependency_map = parameter.dependency_map or {}
    return (
        not parameter.independent
        and len(dependency_map) == 1
        and next(iter(dependency_map.values())) is leader
        and parameter._clean_dependency_string.strip() == next(iter(dependency_map))
    )

tie_corresponding(copy, reference, excluded=frozenset())

Tie every independent parameter of copy to the reference parameter at the same path.

Parameters that are the same object, have no counterpart or are listed in excluded (by id) are left alone. Returns the parameters tied.

Source code in src/easyreflectometry/contrasts.py
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def tie_corresponding(copy: Any, reference: Any, excluded: set[int] = frozenset()) -> list[Parameter]:
    """Tie every independent parameter of `copy` to the reference parameter at the same path.

    Parameters that are the same object, have no counterpart or are listed in
    `excluded` (by id) are left alone. Returns the parameters tied.
    """
    leaders = _parameters(reference, [])
    tied = []
    for path, parameter in _parameters(copy, []).items():
        leader = leaders.get(path)
        if leader is None or leader is parameter or id(parameter) in excluded or not parameter.independent:
            continue
        constrain_equal(parameter, leader)
        tied.append(parameter)
    return tied

snapshot(parameter)

The state :func:restore gives back to parameter (a tie replaces its value, bounds and uncertainty).

Source code in src/easyreflectometry/contrasts.py
260
261
262
263
264
265
266
267
268
def snapshot(parameter: Parameter) -> dict:
    """The state :func:`restore` gives back to `parameter` (a tie replaces its value, bounds and uncertainty)."""
    return {
        'value': parameter.value,
        'min': parameter.min,
        'max': parameter.max,
        'fixed': parameter.fixed,
        'variance': parameter.variance,
    }

restore(parameter, state)

Source code in src/easyreflectometry/contrasts.py
271
272
273
274
275
276
277
def restore(parameter: Parameter, state: dict) -> None:
    parameter.min, parameter.max = -float('inf'), float('inf')
    parameter.value = state['value']
    parameter.min, parameter.max = state['min'], state['max']
    parameter.fixed = state['fixed']
    if 'variance' in state:  # links saved before the uncertainty was kept have none
        parameter.variance = state['variance']