Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docs/src/api/project.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.. automodule:: easyreflectometry.project
:members:
:undoc-members:
:show-inheritance:
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
15 changes: 14 additions & 1 deletion src/easyreflectometry/data/measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,25 @@
coords_name = 'Qz_' + basename
coords_name = list(data_group['coords'].keys())[0] if coords_name not in data_group['coords'] else coords_name
data_name = list(data_group['data'].keys())[0] if data_name not in data_group['data'] else data_name
return DataSet1D(
dataset = DataSet1D(
x=data_group['coords'][coords_name].values,
y=data_group['data'][data_name].values,
ye=data_group['data'][data_name].variances,
xe=data_group['coords'][coords_name].variances,
)
return dataset


def extract_orso_title(data_group: sc.DataGroup, data_name: str) -> str | None:
try:
header = data_group['attrs'][data_name]['orso_header']
title = header.values.get('data_source', {}).get('experiment', {}).get('title')
except (AttributeError, KeyError, TypeError):
return None

Check warning on line 47 in src/easyreflectometry/data/measurement.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/data/measurement.py#L46-L47

Added lines #L46 - L47 were not covered by tests
if title is None:
return None

Check warning on line 49 in src/easyreflectometry/data/measurement.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/data/measurement.py#L49

Added line #L49 was not covered by tests
title_str = str(title).strip()
return title_str or None


def _load_txt(fname: Union[TextIO, str]) -> sc.DataGroup:
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
15 changes: 15 additions & 0 deletions src/easyreflectometry/model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ def __init__(
scale = get_as_parameter('scale', scale, DEFAULTS)
background = get_as_parameter('background', background, DEFAULTS)
self.color = color
self._is_default = False

super().__init__(
name=name,
Expand Down Expand Up @@ -138,6 +139,20 @@ def remove_assembly(self, index: int) -> None:
if self.interface is not None:
self.interface().remove_item_from_model(assembly_unique_name, self.unique_name)

@property
def is_default(self) -> bool:
"""Whether this model was created as a default placeholder."""
return self._is_default

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

:param value: True if the model is a default placeholder.
:type value: bool
"""
self._is_default = value

@property
def resolution_function(self) -> ResolutionFunction:
"""Return the resolution function."""
Expand Down
149 changes: 113 additions & 36 deletions src/easyreflectometry/orso_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import warnings

import numpy as np
import scipp as sc
Expand All @@ -19,42 +20,65 @@
logger = logging.getLogger(__name__)


def LoadOrso(orso_str: str):
def LoadOrso(orso_data):
"""Load a model from an ORSO file."""

sample = load_orso_model(orso_str)
data = load_orso_data(orso_str)

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


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

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

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/orso_utils.py#L37-L38

Added lines #L37 - L38 were not covered by tests
return orso.load_orso(orso_input)


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:

def load_orso_model(orso_data) -> Sample:
"""
Load a model from an ORSO file and return a Sample object.

The ORSO file .ort contains information about the sample, saved
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.

Args:
orso_str: The ORSO file content as a string

Returns:
Sample: An EasyReflectometry Sample 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)

Raises:
ValueError: If ORSO layers could not be resolved
:param orso_data: Parsed ORSO dataset list (as returned by ``orso.load_orso``).
:type orso_data: list
:return: An EasyReflectometry Sample object.
:rtype: Sample
:raises 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_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
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,70 +88,123 @@

# 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 91 in src/easyreflectometry/orso_utils.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/orso_utils.py#L91

Added line #L91 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 94 in src/easyreflectometry/orso_utils.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/orso_utils.py#L94

Added line #L94 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_info = orso_data[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 126 in src/easyreflectometry/orso_utils.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/orso_utils.py#L126

Added line #L126 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,
)


def _get_sld_values(material, material_name):
"""Extract SLD values from material, calculating from density if needed"""
"""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 = 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
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)
# Convert to internal representation (10^-6 A^-2) by multiplying by 1e6
if isinstance(material.sld, ComplexValue):
m_sld = material.sld.real
m_isld = material.sld.imag
raw_sld = material.sld.real
m_sld = raw_sld * 1e6
m_isld = material.sld.imag * 1e6
else:
m_sld = material.sld
raw_sld = material.sld
m_sld = raw_sld * 1e6

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

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/orso_utils.py#L181-L182

Added lines #L181 - L182 were not covered by tests
m_isld = 0.0
if raw_sld != 0.0 and abs(raw_sld) > 1e-2:
warnings.warn(

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

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/orso_utils.py#L185

Added line #L185 was not covered by tests
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

def load_orso_data(orso_str: str) -> DataSet1D:

def load_orso_data(orso_data) -> DataSet1D:
"""Convert parsed ORSO dataset objects into a scipp DataGroup.

:param orso_data: Parsed ORSO dataset list (as returned by ``orso.load_orso``).
:type orso_data: list
:return: A scipp DataGroup with data, coords, and attrs.
:rtype: sc.DataGroup
"""
data = {}
coords = {}
attrs = {}
for i, o in enumerate(orso_str):
for i, o in enumerate(orso_data):
name = i
if o.info.data_set is not None:
name = o.info.data_set
Expand Down
Loading
Loading