Reading/Writing interchange with ORSO data files and the ORSO model language

The Open Reflectometry Standards Organisation is an international, open effort to improve the scientific techniques of neutron and X-ray reflectometry. One aspect of ORSO is to provide a standardised (reduced) data file that is designed to be Findable Accessible Interoperable Reusable (FAIR), which is vital for reproducible research. The Python functionality for reading/writing such files is provided by orsopy. Another aspect of this effort is the desire to have a simple model language that permits a description of the interfacial model used for analysis. This description should allow others to recreate the same analysis setup. Placing the data and model in the electronic supporting information of a paper helps with reproducible research.

Currently refnx is able to read/write ORSO data files, and can work with the ORSO model language for simple models composed solely of Slabs. The example is simulated reflectivity from a Nickel thin film.

import numpy as np
import matplotlib.pyplot as plt
from refnx.reflect import SLD, MaterialSLD, ReflectModel
from refnx.analysis import CurveFitter

Working with ORSO files that contain a model description

Let’s grab the example ORT dataset first.

import urllib.request
import shutil
url = "https://github.com/refnx/refnx-testdata/raw/refs/heads/master/data/dataset/Ni_example.ort"
with (urllib.request.urlopen(url, timeout=5) as response, open("Ni_example.ort", 'wb') as f):
    shutil.copyfileobj(response, f)
# load the example ORSO dataset
from refnx.dataset import load_data, OrsoDataset

# data = load_data("Ni_example.ort")
data = OrsoDataset("Ni_example.ort")

The example dataset already contains a model description of the sample. We can use this to create a Structure, ReflectModel and Objective

s, model, objective = data.setup_analysis()

objective.plot()
plt.yscale('log')
_images/11dd6ac59c132fd26dd81e7801a76bb7a2b14e9260c05554ea4c5a21cd7ae0c7.png

View a Structure in terms of the ORSO model language

orso_model = s.to_orso()              # an ORSO SampleModel object

print(orso_model.to_yaml())           # the YAML representation of the SampleModel object.
stack: air | m1 | SiO2 | Si
layers:
  air:
    thickness: 0.0
    roughness: 0.0
    material:
      sld: {real: 0.0, imag: 0.0}
  m1:
    thickness: 1000.0
    roughness: 4.0
    material:
      formula: Ni
      mass_density: 8.9
  SiO2:
    thickness: 10.0
    roughness: 3.0
    material:
      sld: {real: 3.4700000000000002e-06, imag: 0.0}
  Si:
    thickness: 0.0
    roughness: 3.5
    material:
      sld: {real: 2.0699999999999997e-06, imag: 0.0}
globals:
  roughness: {magnitude: 0.3, unit: nm}
  length_unit: angstrom
  mass_density_unit: g/cm^3
  number_density_unit: 1/nm^3
  sld_unit: 1/angstrom^2
  magnetic_moment_unit: muB
reference: ORSO model language | 1.0

Fitting the ORSO example

Now let’s try fitting this data. First of all we need to vary some parameters.

# s[1] is the first slab (Ni) after the fronting (air) medium
s[1].thick.setp(vary=True, bounds=(950, 1050))

for slab in s:
    slab.rough.setp(vary=True, bounds=(2, 6))

# s[2] is the second slab (SiO2) after the fronting medium.
s[2].thick.setp(vary=True, bounds=(5, 20))

# ReflectModel has a background that we'll want to vary
model.bkg.setp(vary=True, bounds=(1e-7, 1e-5))

If you don’t know how to figure out what parameters are present you can try examining the Component and printing its Parameters

print(s[1])
________________________________________________________________________________
Parameters:      'm1'      
<Parameter: 'm1 - thick'  , value=1000          , bounds=[950.0, 1050.0]>
________________________________________________________________________________
Parameters:       ''       
<Parameter:   'density'   , value=8.9  (fixed) , bounds=[-inf, inf]>
<Parameter: 'm1 - rough'  , value=4          , bounds=[2.0, 6.0]>
<Parameter:'m1 - volfrac solvent', value=0  (fixed) , bounds=[0.0, 1.0]>
for i, p in enumerate(s[1].parameters.flattened()):
    print(i, p)
0 <Parameter: 'm1 - thick'  , value=1000          , bounds=[950.0, 1050.0]>
1 <Parameter:   'density'   , value=8.9  (fixed) , bounds=[-inf, inf]>
2 <Parameter: 'm1 - rough'  , value=4          , bounds=[2.0, 6.0]>
3 <Parameter:'m1 - volfrac solvent', value=0  (fixed) , bounds=[0.0, 1.0]>

You can see that there’s a density parameter, that’s because the Slab was setup using a MaterialSLD object. The density parameter is the density of the Nickel film. Let’s vary the Nickel density in the fit as well.

s[1].parameters.flattened()[1].setp(vary=True, bounds=(8.5, 8.9))

# the following is equivalent, and is straightforward to think about once you're familiar with how refnx works.
# s[1].sld.density.setp(vary=True, bounds=(8.5, 8.9))
fitter = CurveFitter(objective)
fitter.fit('differential_evolution')
objective.plot()
plt.yscale('log')
-2462.809685994783: : 19it [00:01, 13.12it/s] 
_images/def58bdbdc18d0ddd952f673bbf9e31e0180106ce7e68b9d9596633cb5eee446.png

updating the model within the ORSO file using a Structure

Once you’ve fitted the data you’ll need to update the model within the original ORSO file, it’s not done automatically. This is done by using the OrsoDataset.update_model method, you need to supply a Structure object to do so. Again, there’s no link between the Structure originally created by OrsoDataset.setup_analysis() and the model contained within the OrsoDataset.

Once the update is done you’ll need to resave the ORSO file.

data.update_model(s)
data.save("Ni_example_updated.ort")