diff --git a/refl1d/probe/data_loaders/load4.py b/refl1d/probe/data_loaders/load4.py index f8e99e77..722b952e 100644 --- a/refl1d/probe/data_loaders/load4.py +++ b/refl1d/probe/data_loaders/load4.py @@ -4,7 +4,6 @@ # third party imports from bumps.data import parse_multi, strip_quotes import numpy as np -from orsopy.fileio.orso import load_nexus, load_orso # refl1d imports from refl1d.probe import ( @@ -18,117 +17,7 @@ from refl1d.probe.resolution import QL2T, QT2L, FWHM2sigma, dQdL2dT, dQdT2dLoL, sigma2FWHM from refl1d.sample.reflectivity import BASE_GUIDE_ANGLE - -def parse_orso(filename): - """ - Load an ORSO text (.ort) or binary (.orb) file containing one or more datasets - - Parameters - ---------- - filename : str - The path to the ORSO file to be loaded. - - Returns - ------- - list of tuple - A list of tuples, each containing a header dictionary and a data array derived from each loaded dataset. - The header dictionary contains metadata about the measurement, - and the data array contains the measurement data. - - Notes - ----- - The function supports both ORSO text (.ort) and binary (.orb) files. - The polarization information is converted using a predefined mapping. - The header dictionary includes keys for polarization, angle, angular resolution, - wavelength, and wavelength resolution. - """ - if filename.endswith(".ort"): - entries = load_orso(filename) - elif filename.endswith(".orb"): - entries = load_nexus(filename) - - POL_CONVERSION = { - "po": "++", - "mo": "--", - "mm": "--", - "mp": "-+", - "pm": "+-", - "pp": "++", - } - - entries_out = [] - for entry in entries: - header = entry.info - data = entry.data - settings = header.data_source.measurement.instrument_settings - columns = header.columns - polarization = POL_CONVERSION.get(settings.polarization, "unpolarized") - header_out = {"polarization": polarization} - - def get_key(orso_name, refl1d_name, refl1d_resolution_name): - """ - Extract value and error from one of the ORSO columns. If no column corresponding - to entry `orso_name` is found, search in the instrument settings. - - Parameters - ---------- - orso_name : str - The name of the ORSO column or instrument setting to extract. - refl1d_name : str - The corresponding refl1d name for the value of entry `orso_name` - refl1d_resolution_name : str - The corresponding refl1d error name the error of entry `orso_name` - - Notes - ----- - This function requires the instrument setting `orso_name` to have a "magnitue" and "error" attribute. - """ - column_index = next( - (i for i, c in enumerate(columns) if getattr(c, "physical_quantity", None) == orso_name), - None, - ) - resolution_index = None - if column_index is not None: - # NOTE: this is based on column being second index (under debate in ORSO) - header_out[refl1d_name] = data[:, column_index] - cname = columns[column_index].name - resolution_index, resolution_column = next( - ((i, c) for i, c in enumerate(columns) if getattr(c, "error_of", None) == cname), - (None, None), - ) - if resolution_index is not None: - header_out[refl1d_resolution_name] = data[:, resolution_index] - if resolution_column.value_is == "FWHM": - header_out[refl1d_resolution_name] = FWHM2sigma(header_out[refl1d_resolution_name]) - - # Fall back to instrument_settings if no column found - v = getattr(settings, orso_name, None) - if hasattr(v, "magnitude") and column_index is None: - # only set if not already set from column - header_out[refl1d_name] = v.magnitude - if hasattr(v, "error") and resolution_index is None: - header_out[refl1d_resolution_name] = v.error.error_value - if v.error.value_is == "FWHM": - header_out[refl1d_resolution_name] = float(FWHM2sigma(header_out[refl1d_resolution_name])) - - get_key("incident_angle", "angle", "angular_resolution") - get_key("wavelength", "wavelength", "wavelength_resolution") - - # convert error columns from FWHM to sigma if they are present - # and need conversion - if len(columns) >= 3: - # third column is always uncertainty on reflectivity - dR_col = columns[2] - if dR_col.value_is == "FWHM": - data[:, 2] = FWHM2sigma(data[:, 2]) - if len(columns) >= 4: - # fourth column is always uncertainty on Q - dQ_col = columns[3] - if dQ_col.value_is == "FWHM": - data[:, 3] = FWHM2sigma(data[:, 3]) - - entries_out.append((header_out, np.array(data).T)) - return entries_out +from .orso import parse_orso def load4( diff --git a/refl1d/probe/data_loaders/orso.py b/refl1d/probe/data_loaders/orso.py new file mode 100644 index 00000000..9327eddf --- /dev/null +++ b/refl1d/probe/data_loaders/orso.py @@ -0,0 +1,210 @@ +import numpy as np +from orsopy.fileio.orso import load_nexus, load_orso +import orsopy.fileio.model_language as orsopy_model +from orsopy.fileio.model_language import Layer as ORSOLayer, SampleModel as ORSOSample, Material as ORSOMaterial +from orsopy.utils.resolver_slddb import ResolverSLDDB +from refl1d.sample.layers import Stack, Slab +from refl1d.sample.material import Compound, Mixture, BulkDensityMaterial, NumberDensityMaterial, SLD, Vacuum +from refl1d.probe.resolution import FWHM2sigma + + +def parse_orso(filename): + """ + Load an ORSO text (.ort) or binary (.orb) file containing one or more datasets + + Parameters + ---------- + filename : str + The path to the ORSO file to be loaded. + + Returns + ------- + list of tuple + A list of tuples, each containing a header dictionary and a data array derived from each loaded dataset. + The header dictionary contains metadata about the measurement, + and the data array contains the measurement data. + + Notes + ----- + The function supports both ORSO text (.ort) and binary (.orb) files. + The polarization information is converted using a predefined mapping. + The header dictionary includes keys for polarization, angle, angular resolution, + wavelength, and wavelength resolution. + """ + if filename.endswith(".ort"): + entries = load_orso(filename) + elif filename.endswith(".orb"): + entries = load_nexus(filename) + + POL_CONVERSION = { + "po": "++", + "mo": "--", + "mm": "--", + "mp": "-+", + "pm": "+-", + "pp": "++", + } + + entries_out = [] + for entry in entries: + header = entry.info + data = entry.data + settings = header.data_source.measurement.instrument_settings + columns = header.columns + polarization = POL_CONVERSION.get(settings.polarization, "unpolarized") + header_out = {"polarization": polarization} + + def get_key(orso_name, refl1d_name, refl1d_resolution_name): + """ + Extract value and error from one of the ORSO columns. If no column corresponding + to entry `orso_name` is found, search in the instrument settings. + + Parameters + ---------- + orso_name : str + The name of the ORSO column or instrument setting to extract. + refl1d_name : str + The corresponding refl1d name for the value of entry `orso_name` + refl1d_resolution_name : str + The corresponding refl1d error name the error of entry `orso_name` + + Notes + ----- + This function requires the instrument setting `orso_name` to have a "magnitue" and "error" attribute. + """ + column_index = next( + (i for i, c in enumerate(columns) if getattr(c, "physical_quantity", None) == orso_name), + None, + ) + resolution_index = None + if column_index is not None: + # NOTE: this is based on column being second index (under debate in ORSO) + header_out[refl1d_name] = data[:, column_index] + cname = columns[column_index].name + resolution_index, resolution_column = next( + ((i, c) for i, c in enumerate(columns) if getattr(c, "error_of", None) == cname), + (None, None), + ) + if resolution_index is not None: + header_out[refl1d_resolution_name] = data[:, resolution_index] + if resolution_column.value_is == "FWHM": + header_out[refl1d_resolution_name] = FWHM2sigma(header_out[refl1d_resolution_name]) + + # Fall back to instrument_settings if no column found + v = getattr(settings, orso_name, None) + if hasattr(v, "magnitude") and column_index is None: + # only set if not already set from column + header_out[refl1d_name] = v.magnitude + if hasattr(v, "error") and resolution_index is None: + header_out[refl1d_resolution_name] = v.error.error_value + if v.error.value_is == "FWHM": + header_out[refl1d_resolution_name] = float(FWHM2sigma(header_out[refl1d_resolution_name])) + + get_key("incident_angle", "angle", "angular_resolution") + get_key("wavelength", "wavelength", "wavelength_resolution") + + # convert error columns from FWHM to sigma if they are present + # and need conversion + if len(columns) >= 3: + # third column is always uncertainty on reflectivity + dR_col = columns[2] + if dR_col.value_is == "FWHM": + data[:, 2] = FWHM2sigma(data[:, 2]) + if len(columns) >= 4: + # fourth column is always uncertainty on Q + dQ_col = columns[3] + if dQ_col.value_is == "FWHM": + data[:, 3] = FWHM2sigma(data[:, 3]) + + entries_out.append((header_out, np.array(data).T)) + return entries_out + + +def orso_samplemodel_converter(model: orsopy_model.SampleModel): + """ + Convert an ORSO sample model to a refl1d Stack model. + + Parameters + ---------- + model : orsopy_model.SampleModel + The ORSO sample model to convert. + + Returns + ------- + refl1d.sample.layers.Stack + The converted refl1d model. + """ + + orso_layers = model.resolve_to_layers() + + refl1d_layers = [orso_layer_converter(layer) for layer in orso_layers] + + return Stack(refl1d_layers) + + +def orso_layer_converter(layer: orsopy_model.Layer): + """ + Convert an ORSO layer to a refl1d Slab. + + Parameters + ---------- + layer : ORSOSample.Layer + The ORSO layer to convert. + + Returns + ------- + refl1d.sample.layers.Slab + The converted refl1d slab. + """ + + refl1d_material = orso_material_converter(layer.material) + + refl1d_layer = Slab( + material=refl1d_material, + thickness=layer.thickness.as_unit("angstrom"), + interface=layer.roughness.as_unit("angstrom") if layer.roughness else None, + ) + + return refl1d_layer + + +def orso_material_converter(material: ORSOMaterial): + """ + Convert an ORSO material to a refl1d Material. + + Parameters + ---------- + material : ORSOMaterial + The ORSO material to convert. + + Returns + ------- + refl1d.sample.material.Material + The converted refl1d material. + """ + if isinstance(material, orsopy_model.Composit): + parts = [] + for component, fraction in material.composition.items(): + # TODO: how are we supposed to get the number density from ORSO? + number_density = ResolverSLDDB().resolve_formula(component) # in 1/nm³ + cmaterial = NumberDensityMaterial( + formula=component, number_density=number_density * 1e21 + ) # convert to 1/cm³ + parts.extend([cmaterial, fraction * 100]) # convert to percentage for Mixture + # Mixture is expecting a list [base, M2, F2, M3, F3, ...] + # but ORSO Composit does not have a base material, + # so we will set that to vacuum with fraction 0.0 (implicitly) + # as the other fractions add up to 1.0 + return Mixture( + base=Vacuum(), + parts=parts, + ) + elif material.mass_density is not None: + return BulkDensityMaterial(formula=material.formula, density=material.mass_density.as_unit("g/cm^3")) + elif material.number_density is not None: + return NumberDensityMaterial(formula=material.formula, number_density=material.number_density.as_unit("1/cm^3")) + elif material.sld is not None: + sld_value = material.sld.as_unit("1/angstrom^2") * 1e6 # in 1e-6 A^-2 + return SLD(rho=sld_value.real, irho=sld_value.imag) + else: + raise ValueError(f"Unsupported material: {material}") diff --git a/refl1d/sample/material.py b/refl1d/sample/material.py index 07f2b616..a40bb985 100644 --- a/refl1d/sample/material.py +++ b/refl1d/sample/material.py @@ -105,6 +105,7 @@ class Vacuum(Scatterer): def __init__(self, *args, **kw): self.name = "Vacuum" + self.density = 0.0 def parameters(self): return [] @@ -651,7 +652,7 @@ def _density(self): if (fraction < 0).any(): return nan volume = self._volume(fraction) - density = np.array([m.density() for m in [self.base] + self.material]) + density = np.array([float(m.density) for m in [self.base] + self.material]) return np.sum(volume * density) density = property(_density, doc=_density.__doc__) diff --git a/refl1d/webview/server/__init__.py b/refl1d/webview/server/__init__.py index 94a38144..90dad796 100644 --- a/refl1d/webview/server/__init__.py +++ b/refl1d/webview/server/__init__.py @@ -1 +1,2 @@ from .cli import start_refl1d_server +from bumps.webview.server.cli import BumpsOptions diff --git a/refl1d/webview/server/cli.py b/refl1d/webview/server/cli.py index 5beb96c6..280ba228 100644 --- a/refl1d/webview/server/cli.py +++ b/refl1d/webview/server/cli.py @@ -5,8 +5,10 @@ import sys import asyncio from pathlib import Path +from typing import Optional from bumps.webview.server import cli +from bumps.webview.server.cli import BumpsOptions from . import api # uses side-effects to register refl1d functions from refl1d import __version__ @@ -35,7 +37,7 @@ def main(): cli.plugin_main(name="refl1d", client=CLIENT_PATH, version=__version__) -def start_refl1d_server(): +def start_refl1d_server(options: Optional[BumpsOptions] = None): """ Start a Jupyter server for the webview. This returns an asyncio.Task object that should be awaited @@ -48,7 +50,7 @@ def start_refl1d_server(): api.state.app_version = __version__ api.state.client_path = CLIENT_PATH - return asyncio.create_task(start_app(jupyter_link=True)) + return asyncio.create_task(start_app(options, jupyter_link=True)) if __name__ == "__main__": diff --git a/tests/refl1d/orso_substacks_sample.yml b/tests/refl1d/orso_substacks_sample.yml new file mode 100644 index 00000000..804a1e72 --- /dev/null +++ b/tests/refl1d/orso_substacks_sample.yml @@ -0,0 +1,38 @@ +stack: substrate | film | water +sub_stacks: + substrate: + sequence: + - material: Si + roughness: 2 + - material: SiO2 + thickness: 5 + roughness: 3 + film: + repetitions: 5 + stack: head_group 4 | tail | tail | head_group 4 +layers: + tail: + material: tailstuff + thickness: 22. +materials: + head_group: + sld: 0.2E-06 + tailstuff: + formula: CH2 + mass_density: 1.2 + SiO2: + formula: SiO2 + mass_density: 2.5 + Si: + formula: Si + mass_density: 2.33 +composits: + water: + composition: + H2O: 0.3 + D2O: 0.7 +globals: + roughness: {magnitude: 5, unit: angstrom} + length_unit: angstrom + mass_density_unit: g/cm^3 + sld_unit: 1/angstrom^2 diff --git a/tests/refl1d/test_orsopy_sample.py b/tests/refl1d/test_orsopy_sample.py new file mode 100644 index 00000000..84d0788e --- /dev/null +++ b/tests/refl1d/test_orsopy_sample.py @@ -0,0 +1,14 @@ +import yaml +from pathlib import Path +from refl1d.probe import NeutronProbe +from orsopy.fileio.data_source import Sample +from refl1d.probe.data_loaders import orso + +sample_file = Path(__file__).parent / "orso_substacks_sample.yml" +substacks = yaml.safe_load(open(sample_file, "r").read()) + +orso_sample = Sample(name="substacks example", model=substacks) +orso_model = orso_sample.model +refl1d_sample = orso.orso_samplemodel_converter(orso_sample.model) +mixture = refl1d_sample.layers[-1].material +probe = NeutronProbe(T=[0.01], L=[5.0])