Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
# Unreleased

Migrated sample / model classes off the deprecated `easyscience.ObjBase`
and `easyscience.CollectionBase` pipeline.

- `BaseCore` is now built on `ModelBase`; `BaseCollection` on
`EasyList`. `Model`, `Material`, `Layer`, `MaterialMixture`,
`MaterialSolvated`, `LayerAreaPerMolecule`, `Multilayer`,
`RepeatingMultilayer`, `GradientLayer`, `Bilayer`, `SurfactantLayer`,
`BaseAssembly`, `LayerCollection`, `MaterialCollection`, `Sample`, and
`ModelCollection` were all rewritten to use the new bases.
- Properties returning a `Parameter` (`Material.sld`-style) now expose
the `Parameter` object directly across all sample classes, replacing
the inconsistent legacy behaviour where `MaterialMixture.fraction`,
`MaterialSolvated.solvent_fraction`,
`LayerAreaPerMolecule.area_per_molecule`, and
`LayerAreaPerMolecule.solvent_fraction` returned `float`. Read the
value via `.value` (e.g. `material_mixture.fraction.value`). Setters
still accept a float. `MaterialMixture.sld` / `MaterialMixture.isld`
remain `float` — they are derived via constraints, not constructor
arguments.
- `BaseCollection.remove(index)` (the legacy index-based helper) renamed
to `remove_at(index)`. The standard `MutableSequence.remove(value)` is
now inherited unmodified.
- Project files saved by previous versions cannot be read.
`Project.as_dict` writes `file_format=2`; `Project.from_dict` raises a
clear `ValueError` on missing or unsupported markers.
- `model.get_parameters()` / `collection.get_parameters()` still work
(kept as compatibility shims) but new code should use
`get_all_parameters()`.
- No more `DeprecationWarning` from `easyscience.ObjBase` /
`CollectionBase` on construction of any sample / model object.

# Version 1.6.0 (1 May 2026)

Add Mighell-based handling of non-positive-variance points in fitting
Expand Down
12 changes: 6 additions & 6 deletions docs/docs/tutorials/simulation/bilayer.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@
"# Access key structural parameters\n",
"print(f'Head thickness: {bilayer.front_head_layer.thickness.value:.2f} Å')\n",
"print(f'Tail thickness: {bilayer.front_tail_layer.thickness.value:.2f} Å')\n",
"print(f'Area per molecule: {bilayer.front_head_layer.area_per_molecule:.2f} Ų')"
"print(f'Area per molecule: {bilayer.front_head_layer.area_per_molecule.value:.2f} Ų')"
]
},
{
Expand All @@ -224,14 +224,14 @@
"source": [
"# Head layers share thickness and area per molecule via constrain_heads=True,\n",
"# but solvent fraction is independent and can be set separately for each side.\n",
"print(f'Front head solvent fraction: {bilayer.front_head_layer.solvent_fraction:.2f}')\n",
"print(f'Back head solvent fraction: {bilayer.back_head_layer.solvent_fraction:.2f}')\n",
"print(f'Front head solvent fraction: {bilayer.front_head_layer.solvent_fraction.value:.2f}')\n",
"print(f'Back head solvent fraction: {bilayer.back_head_layer.solvent_fraction.value:.2f}')\n",
"\n",
"# We can set them independently\n",
"bilayer.back_head_layer.solvent_fraction = 0.5\n",
"print('\\nAfter setting back head solvent fraction to 0.5:')\n",
"print(f'Front head solvent fraction: {bilayer.front_head_layer.solvent_fraction:.2f}')\n",
"print(f'Back head solvent fraction: {bilayer.back_head_layer.solvent_fraction:.2f}')"
"print(f'Front head solvent fraction: {bilayer.front_head_layer.solvent_fraction.value:.2f}')\n",
"print(f'Back head solvent fraction: {bilayer.back_head_layer.solvent_fraction.value:.2f}')"
]
},
{
Expand Down Expand Up @@ -685,7 +685,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.12"
"version": "3.12.11"
}
},
"nbformat": 4,
Expand Down
126 changes: 66 additions & 60 deletions src/easyreflectometry/model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@
from typing import Union

import numpy as np
from easyscience import ObjBase as BaseObj
from easyscience import global_object
from easyscience.variable import Parameter

from easyreflectometry.limits import apply_default_limits
from easyreflectometry.sample import BaseAssembly
from easyreflectometry.sample import Sample
from easyreflectometry.sample.base_core import BaseCore

Check warning on line 18 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L18

Added line #L18 was not covered by tests
from easyreflectometry.utils import get_as_parameter
from easyreflectometry.utils import yaml_dump

from .resolution_functions import PercentageFwhm
from .resolution_functions import ResolutionFunction
Expand Down Expand Up @@ -58,18 +57,12 @@
]


class Model(BaseObj):
class Model(BaseCore):

Check warning on line 60 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L60

Added line #L60 was not covered by tests
"""Model is the class that represents the experiment.

It is used to store the information about the experiment and to perform the calculations.
"""

# Added in super().__init__
name: str
sample: Sample
scale: Parameter
background: Parameter

def __init__(
self,
sample: Union[Sample, None] = None,
Expand Down Expand Up @@ -115,18 +108,45 @@
background = get_as_parameter('background', background, DEFAULTS)
self.color = color
self._is_default = False
self._resolution_function = resolution_function

Check warning on line 111 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L111

Added line #L111 was not covered by tests

super().__init__(name=name, unique_name=unique_name)
self._sample = sample
self._scale = scale
self._background = background

Check warning on line 116 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L113-L116

Added lines #L113 - L116 were not covered by tests

# Set interface last — propagates to children via BaseCore.generate_bindings
# and then sets the resolution function on the calculator (see setter).
if interface is not None:
self.interface = interface

Check warning on line 121 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L120-L121

Added lines #L120 - L121 were not covered by tests

# ----- @property accessors for serialization round-trip -----

@property
def sample(self) -> Sample:
return self._sample

Check warning on line 127 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L125-L127

Added lines #L125 - L127 were not covered by tests

@sample.setter
def sample(self, value: Sample) -> None:
self._sample = value

Check warning on line 131 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L129-L131

Added lines #L129 - L131 were not covered by tests

super().__init__(
name=name,
unique_name=unique_name,
sample=sample,
scale=scale,
background=background,
)
self.resolution_function = resolution_function
@property
def scale(self) -> Parameter:
return self._scale

Check warning on line 135 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L133-L135

Added lines #L133 - L135 were not covered by tests

@scale.setter
def scale(self, value: float) -> None:
self._scale.value = value

Check warning on line 139 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L137-L139

Added lines #L137 - L139 were not covered by tests

@property
def background(self) -> Parameter:
return self._background

Check warning on line 143 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L141-L143

Added lines #L141 - L143 were not covered by tests

# Must be set after resolution function
self.interface = interface
@background.setter
def background(self, value: float) -> None:
self._background.value = value

Check warning on line 147 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L145-L147

Added lines #L145 - L147 were not covered by tests

# ----- assembly management -----

def add_assemblies(self, *assemblies: list[BaseAssembly]) -> None:
"""Add assemblies to the model sample.
Expand Down Expand Up @@ -183,15 +203,11 @@

@is_default.setter
def is_default(self, value: bool) -> None:
"""Set whether this model is a default placeholder.

Parameters
----------
value : bool
True if the model is a default placeholder.
"""
"""Set whether this model is a default placeholder."""
self._is_default = value

# ----- resolution function -----

@property
def resolution_function(self) -> ResolutionFunction:
"""Return the resolution function."""
Expand All @@ -204,21 +220,20 @@
if self.interface is not None:
self.interface().set_resolution_function(self._resolution_function)

@property
def interface(self):
"""Get the current interface of the object."""
return self._interface
# ----- interface (override BaseCore's to add resolution-function side effect) -----

@interface.setter
@BaseCore.interface.setter

Check warning on line 225 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L225

Added line #L225 was not covered by tests
def interface(self, new_interface) -> None:
"""Set the interface for the model."""
# From super class
self._interface = new_interface
"""Set the interface; runs `generate_bindings` and then refreshes the
calculator's resolution function.
"""
# Call BaseCore.interface.setter for the binding propagation.
BaseCore.interface.fset(self, new_interface)

Check warning on line 231 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L231

Added line #L231 was not covered by tests
if new_interface is not None:
self.generate_bindings()
self._interface().set_resolution_function(self._resolution_function)
new_interface().set_resolution_function(self._resolution_function)

Check warning on line 233 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L233

Added line #L233 was not covered by tests

# ----- representation -----

# Representation
@property
def _dict_repr(self) -> dict[str, dict[str, str]]:
"""A simplified dict representation."""
Expand All @@ -238,24 +253,15 @@
}
}

def __repr__(self) -> str:
"""String representation of the layer."""
return yaml_dump(self._dict_repr)

def as_dict(self, skip: Optional[list[str]] = None) -> dict:
"""Produces a cleaned dict using a custom as_dict method to skip necessary things.

The resulting dict matches the parameters in __init__
# ----- serialization (custom because resolution_function + interface need special handling) -----

Parameters
----------
skip : Optional[list[str]], optional
List of keys to skip. By default, None.
"""
def to_dict(self, skip: Optional[list[str]] = None) -> dict:

Check warning on line 258 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L258

Added line #L258 was not covered by tests
"""Serialize the model, encoding the resolution function and interface name."""
if skip is None:
skip = []
skip.extend(['sample', 'resolution_function', 'interface'])
this_dict = super().as_dict(skip=skip)
# Sample/resolution_function/interface get bespoke encoding below.
skip_for_super = list(skip) + ['sample', 'resolution_function', 'interface']
this_dict = super().to_dict(skip=skip_for_super)

Check warning on line 264 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L263-L264

Added lines #L263 - L264 were not covered by tests
this_dict['sample'] = self.sample.as_dict(skip=skip)
this_dict['resolution_function'] = self.resolution_function.as_dict(skip=skip)
if self.interface is None:
Expand All @@ -264,23 +270,23 @@
this_dict['interface'] = self.interface().name
return this_dict

def as_dict(self, skip: Optional[list[str]] = None) -> dict:

Check warning on line 273 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L273

Added line #L273 was not covered by tests
"""Compatibility alias for :meth:`to_dict`."""
return self.to_dict(skip=skip)

Check warning on line 275 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L275

Added line #L275 was not covered by tests

def as_orso(self) -> dict:
"""Convert the model to a dictionary suitable for ORSO."""
this_dict = self.as_dict()

return this_dict
return self.as_dict()

Check warning on line 279 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L279

Added line #L279 was not covered by tests

@classmethod
def from_dict(cls, passed_dict: dict) -> Model:
"""Create a Model from a dictionary."""
# Causes circular import if imported at the top
# Circular import if hoisted to module-top.
from easyreflectometry.calculators import CalculatorFactory

this_dict = copy.deepcopy(passed_dict)
resolution_function = ResolutionFunction.from_dict(this_dict['resolution_function'])
del this_dict['resolution_function']
interface_name = this_dict['interface']
del this_dict['interface']
resolution_function = ResolutionFunction.from_dict(this_dict.pop('resolution_function'))
interface_name = this_dict.pop('interface')

Check warning on line 289 in src/easyreflectometry/model/model.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model.py#L288-L289

Added lines #L288 - L289 were not covered by tests
if interface_name is not None:
interface = CalculatorFactory()
interface.switch(interface_name)
Expand Down
57 changes: 36 additions & 21 deletions src/easyreflectometry/model/model_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

from __future__ import annotations

from typing import List
from typing import Optional
from typing import Tuple

Expand Down Expand Up @@ -36,20 +35,33 @@
models = DEFAULT_ELEMENTS(interface)
else:
models = []
# 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

# `_next_color_index` must exist before super().__init__ because each
# `append` during construction routes through `_append_internal` →
# `_advance_color_index`, which reads the attribute.
self._next_color_index = next_color_index

super().__init__(name, interface, *models, unique_name=unique_name, **kwargs)
super().__init__(

Check warning on line 44 in src/easyreflectometry/model/model_collection.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model_collection.py#L44

Added line #L44 was not covered by tests
name,
interface,
*models,
unique_name=unique_name,
populate_if_none=False,
**kwargs,
)

color_count = len(COLORS)
if color_count == 0:
self._next_color_index = 0
elif self._next_color_index is None:
elif next_color_index is None:

Check warning on line 56 in src/easyreflectometry/model/model_collection.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model_collection.py#L56

Added line #L56 was not covered by tests
self._next_color_index = len(self) % color_count
else:
self._next_color_index %= color_count
self._next_color_index = next_color_index % color_count

Check warning on line 59 in src/easyreflectometry/model/model_collection.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model_collection.py#L59

Added line #L59 was not covered by tests

@property
def next_color_index(self) -> Optional[int]:

Check warning on line 62 in src/easyreflectometry/model/model_collection.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model_collection.py#L61-L62

Added lines #L61 - L62 were not covered by tests
"""Index of the next colour to assign — kept around so it round-trips."""
return self._next_color_index

Check warning on line 64 in src/easyreflectometry/model/model_collection.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model_collection.py#L64

Added line #L64 was not covered by tests

def add_model(self, model: Optional[Model] = None):
"""Add a model to the collection.
Expand All @@ -76,28 +88,24 @@
duplicate.name = duplicate.name + ' duplicate'
self.append(duplicate)

def as_dict(self, skip: List[str] | None = None) -> dict:
"""As dict."""
this_dict = super().as_dict(skip=skip)
this_dict['populate_if_none'] = self.populate_if_none
this_dict['next_color_index'] = self._next_color_index
return this_dict

@classmethod
def from_dict(cls, this_dict: dict) -> ModelCollection:
"""Create an instance of a collection from a dictionary."""
collection_dict = this_dict.copy()
# We need to call from_dict on the base class to get the models
dict_data = collection_dict.pop('data')
collection_dict = dict(this_dict)
dict_data = collection_dict.pop('data', [])

Check warning on line 95 in src/easyreflectometry/model/model_collection.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model_collection.py#L94-L95

Added lines #L94 - L95 were not covered by tests
next_color_index = collection_dict.pop('next_color_index', None)

collection = super().from_dict(collection_dict) # type: ModelCollection
# Reconstruct empty collection via EasyList.from_dict (handles
# protected_types and assigns name/unique_name/populate_if_none).
collection = super().from_dict(collection_dict)

Check warning on line 100 in src/easyreflectometry/model/model_collection.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model_collection.py#L100

Added line #L100 was not covered by tests

# Append each model without advancing the colour index — the saved
# `next_color_index` below is the source of truth.
for model_data in dict_data:
collection._append_internal(Model.from_dict(model_data), advance=False)

if len(collection) != len(this_dict['data']):
raise ValueError(f'Expected {len(collection)} models, got {len(this_dict["data"])}')
if len(collection) != len(dict_data):
raise ValueError(f'Expected {len(dict_data)} models, got {len(collection)}')

Check warning on line 108 in src/easyreflectometry/model/model_collection.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model_collection.py#L107-L108

Added lines #L107 - L108 were not covered by tests

color_count = len(COLORS)
if color_count == 0:
Expand All @@ -115,7 +123,14 @@

def _append_internal(self, model: Model, advance: bool) -> None:
"""Append internal."""
super().append(model)
# Bypass our own `append` override and go straight to EasyList's
# `MutableSequence.append` → `insert` path. Calling `super().append`
# would dispatch back to `ModelCollection.append` because Python
# resolves `append` via MRO from MutableSequence which doesn't
# define it on a class higher than ModelCollection.
from collections.abc import MutableSequence

Check warning on line 131 in src/easyreflectometry/model/model_collection.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model_collection.py#L131

Added line #L131 was not covered by tests

MutableSequence.append(self, model)

Check warning on line 133 in src/easyreflectometry/model/model_collection.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/model/model_collection.py#L133

Added line #L133 was not covered by tests
if advance:
self._advance_color_index()

Expand Down
Loading
Loading