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
9 changes: 9 additions & 0 deletions docs/src/api/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ Sample is build from assemblies.

sample

Project
=======
Project provides a higher-level interface for managing models, experiments, and ORSO import.

.. toctree::
:maxdepth: 1

project

Assemblies
==========
Assemblies are collections of layers that are used to represent a specific physical setup.
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ classifiers = [
requires-python = ">=3.11,<3.13"

dependencies = [
#"easyscience @ git+https://github.com/easyscience/corelib.git@dict_size_changed_bug",
"easyscience",
"easyscience @ git+https://github.com/easyscience/corelib.git@develop",
#"easyscience",
"scipp",
"refnx",
"refl1d>=1.0.0rc0",
"refl1d>=1.0.0",
"orsopy",
"svglib<1.6 ; platform_system=='Linux'",
"xhtml2pdf",
Expand Down
2 changes: 1 addition & 1 deletion src/easyreflectometry/calculators/calculator_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from easyscience.fitting.calculators.interface_factory import ItemContainer
from easyscience.io import SerializerComponent

#if TYPE_CHECKING:
# if TYPE_CHECKING:
from easyreflectometry.model import Model
from easyreflectometry.sample import BaseAssembly
from easyreflectometry.sample import Layer
Expand Down
4 changes: 2 additions & 2 deletions src/easyreflectometry/data/data_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def __init__(
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)
model: Optional['Model'] = None, # delay type checking until runtime (quotes)
x_label: str = 'x',
y_label: str = 'y',
):
Expand Down Expand Up @@ -117,7 +117,7 @@ def __init__(
self._color = None

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

@model.setter
Expand Down
6 changes: 3 additions & 3 deletions src/easyreflectometry/fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ def fit(self, data: sc.DataGroup, id: int = 0) -> sc.DataGroup:
variances = data['data'][f'R_{i}'].variances

# Find points with non-zero variance
zero_variance_mask = (variances == 0.0)
zero_variance_mask = variances == 0.0
num_zero_variance = np.sum(zero_variance_mask)

if num_zero_variance > 0:
warnings.warn(
f"Masked {num_zero_variance} data point(s) in reflectivity {i} due to zero variance during fitting.",
UserWarning
f'Masked {num_zero_variance} data point(s) in reflectivity {i} due to zero variance during fitting.',
UserWarning,
)

# Keep only points with non-zero variances
Expand Down
69 changes: 51 additions & 18 deletions src/easyreflectometry/orso_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,16 @@

return sample, data


def load_data_from_orso_file(fname: str) -> sc.DataGroup:
"""Load data from an ORSO file."""
try:
orso_data = orso.load_orso(fname)
except Exception as e:
raise ValueError(f"Error loading ORSO file: {e}")
raise ValueError(f'Error loading ORSO file: {e}')
return load_orso_data(orso_data)


def load_orso_model(orso_str: str) -> Sample:
"""
Load a model from an ORSO file and return a Sample object.
Expand All @@ -43,18 +45,25 @@
as a simple "stack" string, e.g. 'air | m1 | SiO2 | Si'.
This gets parsed by the ORSO library and converted into an ORSO Dataset object.

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)
- Last layer -> Subphase assembly (thickness=0 fixed, roughness enabled)

Args:
orso_str: The ORSO file content as a string

Returns:
Sample: An EasyReflectometry Sample object

Raises:
ValueError: If ORSO layers could not be resolved
ValueError: If ORSO layers could not be resolved or fewer than 2 layers
"""
# Extract stack string and create ORSO sample model
stack_str = orso_str[0].info.data_source.sample.model.stack
orso_sample = model_language.SampleModel(stack=stack_str)
# Extract stack string and layer definitions from ORSO sample model
sample_model = orso_str[0].info.data_source.sample.model
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)

# Try to resolve layers using different methods
try:
Expand All @@ -64,41 +73,67 @@

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

Check warning on line 76 in src/easyreflectometry/orso_utils.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/orso_utils.py#L76

Added line #L76 was not covered by tests

if len(orso_layers) < 2:
raise ValueError('ORSO stack must contain at least 2 layers (superphase and subphase).')

Check warning on line 79 in src/easyreflectometry/orso_utils.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/orso_utils.py#L79

Added line #L79 was not covered by tests

logger.debug(f"Resolved layers: {orso_layers}")
logger.debug(f'Resolved layers: {orso_layers}')

# Convert ORSO layers to EasyReflectometry layers
erl_layers = []
for layer in orso_layers:
erl_layer = _convert_orso_layer_to_erl(layer)
erl_layers.append(erl_layer)

# Create a Multilayer object with the extracted layers
multilayer = Multilayer(erl_layers, name='Multi Layer Sample from ORSO')
# Create Superphase from first layer (thickness=0, roughness=0, both fixed)
superphase_layer = erl_layers[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 = erl_layers[-1]
subphase_layer.thickness.value = 0.0
subphase_layer.thickness.fixed = True
subphase_layer.roughness.fixed = False
subphase = Multilayer(subphase_layer, name='Subphase')

# Create Sample from the file
sample_info = orso_str[0].info.data_source.sample
sample_name = sample_info.name if sample_info.name else 'ORSO Sample'
sample = Sample(multilayer, name=sample_name)

# Build Sample based on number of layers
if len(erl_layers) == 2:
# Only superphase and subphase, no middle layers
sample = Sample(superphase, subphase, name=sample_name)

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

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/orso_utils.py#L111

Added line #L111 was not covered by tests
else:
# Create middle layer assembly from layers between first and last
middle_layers = erl_layers[1:-1]
loaded_layer = Multilayer(middle_layers, name='Loaded layer')
sample = Sample(superphase, loaded_layer, subphase, name=sample_name)

return sample


def _convert_orso_layer_to_erl(layer):
"""Helper function to convert an ORSO layer to an EasyReflectometry layer"""
material = layer.material
m_name = material.formula if material.formula is not None else layer.name
# Prefer original_name for material name, fall back to formula if available
m_name = layer.original_name if layer.original_name is not None else material.formula

# Get SLD values
m_sld, m_isld = _get_sld_values(material, m_name)
# Get SLD values (use formula for density calculation if available)
formula_for_calc = material.formula if material.formula is not None else m_name
m_sld, m_isld = _get_sld_values(material, formula_for_calc)

# Create and return ERL layer
return Layer(
material=Material(sld=m_sld, isld=m_isld, name=m_name),
thickness=layer.thickness.magnitude if layer.thickness is not None else 0.0,
roughness=layer.roughness.magnitude if layer.roughness is not None else 0.0,
name=layer.original_name if layer.original_name is not None else m_name
name=layer.original_name if layer.original_name is not None else m_name,
)


Expand All @@ -107,10 +142,7 @@
if material.sld is None and material.mass_density is not None:
# Calculate SLD from mass density
m_density = material.mass_density.magnitude
density = MaterialDensity(
chemical_structure=material_name,
density=m_density
)
density = MaterialDensity(chemical_structure=material_name, density=m_density)
m_sld = density.sld.value
m_isld = density.isld.value
else:
Expand All @@ -123,6 +155,7 @@

return m_sld, m_isld


def load_orso_data(orso_str: str) -> DataSet1D:
data = {}
coords = {}
Expand Down
121 changes: 120 additions & 1 deletion src/easyreflectometry/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from easyreflectometry.data import DataSet1D
from easyreflectometry.data import load_as_dataset
from easyreflectometry.fitting import MultiFitter

# from easyreflectometry.model import LinearSpline
from easyreflectometry.model import Model
from easyreflectometry.model import ModelCollection
from easyreflectometry.model import PercentageFwhm
Expand Down Expand Up @@ -268,10 +270,53 @@
self._with_experiments = True
pass

def set_sample_from_orso(self, sample) -> None:
def set_sample_from_orso(self, sample: Sample) -> None:
"""Replace the current project model collection with a single model built from an ORSO-parsed sample.

This is a convenience helper for the ORSO import pipeline where a complete
:class:`~easyreflectometry.sample.Sample` is constructed elsewhere.

:param sample: Sample to set as the project's (single) model.
:type sample: easyreflectometry.sample.Sample
:return: ``None``.
:rtype: None
"""
model = Model(sample=sample)
self.models = ModelCollection([model])

def add_sample_from_orso(self, sample: Sample) -> None:
"""Add a new model with the given sample to the existing model collection.

The created model is appended to :attr:`models`, its calculator interface is
set to the project's current calculator, and any materials referenced in the
sample are added to the project's material collection.

After adding the model, :attr:`current_model_index` is updated to point to
the newly added model.

:param sample: Sample to add as a new model.
:type sample: easyreflectometry.sample.Sample
:return: ``None``.
:rtype: None
"""
model = Model(sample=sample)
self.models.add_model(model)
# Set interface after adding to collection
model.interface = self._calculator
# Extract materials from the new model and add to project materials
self._materials.extend(self._get_materials_from_model(model))
# Switch to the newly added model so its data is visible in the UI
self.current_model_index = len(self._models) - 1

def _get_materials_from_model(self, model: Model) -> 'MaterialCollection':
"""Get all materials from a single model's sample."""
materials_in_model = MaterialCollection(populate_if_none=False)
for assembly in model.sample:
for layer in assembly.layers:
if layer.material not in materials_in_model:
materials_in_model.append(layer.material)
return materials_in_model

def load_new_experiment(self, path: Union[Path, str]) -> None:
new_experiment = load_as_dataset(str(path))
new_index = len(self._experiments)
Expand All @@ -291,6 +336,10 @@
q_error = new_experiment.xe
# TODO: set resolution function based on value of control in GUI
resolution_function = Pointwise(q_data_points=[q, reflectivity, q_error])
# resolution_function = LinearSpline(
# q_data_points=self._experiments[new_index].y,
# fwhm_values=np.sqrt(self._experiments[new_index].ye),
# )
self.models[model_index].resolution_function = resolution_function

def load_experiment_for_model_at_index(self, path: Union[Path, str], index: Optional[int] = 0) -> None:
Expand Down Expand Up @@ -363,6 +412,76 @@
model = Model(sample=sample, interface=self._calculator)
self.models = ModelCollection([model])

def is_default_model(self, index: int) -> bool:
"""Check if the model at the given index is a default model.

A default model has exactly 3 assemblies named 'Superphase', 'D2O', and 'Subphase',
each containing a single layer with specific names.

:param index: Index of the model to check.
:return: True if the model matches the default model structure.
"""
if index < 0 or index >= len(self._models):
return False

model = self._models[index]
sample = model.sample

# Check for exactly 3 assemblies with expected names
if len(sample) != 3:
return False

expected_assembly_names = ['Superphase', 'D2O', 'Subphase']
expected_layer_names = ['Vacuum Layer', 'D2O Layer', 'Si Layer']

for assembly, expected_assembly_name, expected_layer_name in zip(
sample, expected_assembly_names, expected_layer_names
):
if assembly.name != expected_assembly_name:
return False
if len(assembly.layers) != 1:
return False
if assembly.layers[0].name != expected_layer_name:
return False

Check warning on line 445 in src/easyreflectometry/project.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/project.py#L445

Added line #L445 was not covered by tests

return True

def remove_model_at_index(self, index: int) -> None:
"""Remove the model at the given index.

Removes the model from the model collection and any associated experiment data.
Adjusts the current model index if necessary.

:param index: Index of the model to remove.
:type index: int
:raises IndexError: If the index is out of range.
:raises ValueError: If trying to remove the last remaining model.
"""
if index < 0 or index >= len(self._models):
raise IndexError(f'Model index {index} out of range')

if len(self._models) <= 1:
raise ValueError('Cannot remove the last model from the project')

# Remove the model from the collection
self._models.pop(index)

# Remove the link between any experiment and the removed model
# (do not delete the experiment itself, just unlink it)
# Use _model directly to bypass the setter which expects a non-None model
if index in self._experiments:
self._experiments[index]._model = None

# Adjust current model index if necessary
if self._current_model_index >= len(self._models):
self._current_model_index = len(self._models) - 1
elif self._current_model_index > index:
self._current_model_index -= 1

Check warning on line 479 in src/easyreflectometry/project.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/project.py#L479

Added line #L479 was not covered by tests

# Reset assembly and layer indices for the new current model
self._current_assembly_index = 0
self._current_layer_index = 0

def add_material(self, material: MaterialCollection) -> None:
if material in self._materials:
print(f'WARNING: Material {material} is already in material collection')
Expand Down
4 changes: 2 additions & 2 deletions tests/calculators/refl1d/test_refl1d_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def test_reflectity_profile(self):
5.7605e-07,
2.3775e-07,
1.3093e-07,
1.0520e-07
1.0520e-07,
]
assert_almost_equal(p.reflectity_profile(q, 'MyModel'), expected, decimal=4)

Expand Down Expand Up @@ -106,7 +106,7 @@ def test_calculate2(self):
1.0968e-06,
4.5635e-07,
3.4120e-07,
2.7505e-07
2.7505e-07,
]
assert_almost_equal(actual, expected, decimal=4)

Expand Down
Loading
Loading