Reflectometry: Fit Pt layer

In this example, we want to demonstrate how to fit experimental reflectivity data that was obtained by a time-of-flight experiment with unpolarized neutrons. Experimental data is available for a sample of a roughly 50 nm thick platinum layer on top of a silicon substrate that is published in this repository.

The mesaurements were made by Timothy Charlton, Haile Ambaye and Michael Fitzsimmons (ORNL) on a sample provided by Eric Fullerton (UCSD).

Fit model

We describe the above experiment by a three-layer model, where as usual the top layer is the vacuum and the substrate layer is the silicon substrate. On top of the silicon substrate, we place the platinum layer. The materials of both layers are described by their SLD, where we use literature values for both silicon as well as platinum and keep them constant throughout the fitting procedure.

The main parameters of the sample are stored in dictionary, where they are defined by a unique name and the following six parameters are utilized:

  • Beam intensity: intensity

We explicitly fit the beam intensity, in order to compensate for possible experimental errors and to circumvent problems with the rather large variance in the reflectivity data at low $Q$-values.

  • Roughness on top of the Pt layer: r_pt/nm

  • Roughness on top of the Si substrate: r_si/nm

  • Thickness of the Pt layer: t_pt/nm

  • The relative $Q$-resolution: q_res/q (c.f.)

  • A $Q$-offset: q_offset

This global offset is introduced to account for uncertainties in the angle at which the measurement is performed.

Due to saturation of the detector it is possible that the intensity at low $Q$-values (i.e. at high count rates) is underestimated. Furthermore, there is a rather large variance in the data that also leads to a rather bad fit in this region. Therefore, we neglect the data in the low $Q$-region by choosing a cutoff at $Q_{\text{min}} = $ 0.18. This value is selected by hand after performing several fits and visually selecting a good result.

$Q$-offset

Currently, BornAgain does not have an API support for an offset of the $Q$-axis. Therefore, we need to shift the $Q$-axis before performing a simulation

q_axis = q_axis + parameters["q_offset"]

This shift then needs to be counter-transformed when returning the results in the qr(result) function

q = numpy.array(result.result().axis(ba.Axes.QSPACE)) - q_offset

Initial parameters

In order to successfully fit this example, we chose some sane starting values and the example code, that is fully given below, can be run with the following command:

python3 Pt_layer_fit.py fit
This performs a simulation with the initial parameters and yields the following result:

Reflectivity with the initial parameters before fitting

Immediately afterwards the fit is performed.

Fit result

In order to run the fitting procedure, the following command can be issued:

python3 Pt_layer_fit.py fit

We need to allow a few seconds computational time and BornAgain should compute the following result

Reflectivity with the parameters obtained from our fit

If the fit keyword is omitted from the command line

python3 Pt_layer_fit.py
a simulation is performed with our fit results and one should obtain the result shown above.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
#!/usr/bin/env python3
"""
This example demonstrates how to fit actual experimental data
by M. Fitzsimmons et al. that is published in 
https://doi.org/10.5281/zenodo.4072376
"""

import numpy
import matplotlib.pyplot as plt
from sys import argv

import bornagain as ba
from bornagain import angstrom

# filename of the experimental data to be loaded
filename = 'RvsQ_36563_36662.txt.gz'

# restrict the Q-range of the data used for fitting
qmin = 0.18
qmax = 2.4

# number of points on which the computed result is plotted
scan_size = 1500

# Use fixed values for the SLD of the substrate and Pt layer
sldPt = (6.3568e-06, 1.8967e-09)
sldSi = (2.0728e-06, 2.3747e-11)

####################################################################
#                  Create Sample and Simulation                    #
####################################################################


def get_sample(params):

    mat_ambient = ba.MaterialBySLD("Ambient", 0.0, 0.0)
    mat_layer = ba.MaterialBySLD("Pt", *sldPt)
    mat_substrate = ba.MaterialBySLD("Si", *sldSi)

    ambient_layer = ba.Layer(mat_ambient)
    layer = ba.Layer(mat_layer, params["t_pt/nm"])
    substrate_layer = ba.Layer(mat_substrate)

    r_si = ba.LayerRoughness()
    r_si.setSigma(params["r_si/nm"])

    r_pt = ba.LayerRoughness()
    r_pt.setSigma(params["r_pt/nm"])

    multi_layer = ba.MultiLayer()
    multi_layer.addLayer(ambient_layer)
    multi_layer.addLayerWithTopRoughness(layer, r_pt)

    multi_layer.addLayerWithTopRoughness(substrate_layer, r_si)

    return multi_layer


def get_simulation(q_axis, parameters):

    scan = ba.QSpecScan(q_axis)
    scan.setOffset(parameters["q_offset"])

    n_sig = 4.0
    n_samples = 25

    distr = ba.RangedDistributionGaussian(n_samples, n_sig)
    scan.setAbsoluteQResolution(distr, parameters["q_res/q"])

    simulation = ba.SpecularSimulation()
    simulation.beam().setIntensity(parameters["intensity"])

    simulation.setScan(scan)
    return simulation


def run_simulation(q_axis, fitParams):
    parameters = dict(fitParams, **fixedParams)

    sample = get_sample(parameters)
    simulation = get_simulation(q_axis, parameters)

    simulation.setSample(sample)
    simulation.runSimulation()
    return simulation  #.result()


def qr(result):
    """
    helper function to return the q axis and 
    reflectivity from simulation result
    """
    q = numpy.array(result.result().axis(ba.Axes.QSPACE))
    r = numpy.array(result.result().array(ba.Axes.QSPACE))

    return q, r


####################################################################
#                         Plot Handling                            #
####################################################################


def plot(q, r, exp, filename, params=None):
    """
    helper function to plot a result
    """
    fig = plt.figure()
    ax = fig.add_subplot(111)

    ax.errorbar(exp[0],
                exp[1],
                xerr=exp[3],
                yerr=exp[2],
                label="R",
                fmt='.',
                markersize=1.,
                linewidth=0.6,
                color='r')

    ax.plot(q, r, label="Simulation", color='C0', linewidth=0.5)

    ax.set_yscale('log')

    ax.set_xlabel("Q [nm$^{^-1}$]")
    ax.set_ylabel("R")

    y = 0.5
    if params is not None:
        for n, v in params.items():
            plt.text(0.7, y, f"{n} = {v:.3g}", transform=ax.transAxes)
            y += 0.05

    plt.tight_layout()
    plt.savefig(filename)


####################################################################
#                          Data Handling                           #
####################################################################


def get_Experimental_data(qmin, qmax):
    """
    read experimental data, remove some duplicate q-values
    recalculate q axis to inverse nm
    """
    data = numpy.genfromtxt(filename, unpack=True)

    r0 = numpy.where(data[0] - numpy.roll(data[0], 1) == 0)
    data = numpy.delete(data, r0, 1)

    data[0] = data[0]/angstrom
    data[3] = data[3]/angstrom

    data[1] = data[1]
    data[2] = data[2]

    so = numpy.argsort(data[0])

    data = data[:, so]

    minIndex = numpy.argmin(numpy.abs(data[0] - qmin))
    maxIndex = numpy.argmin(numpy.abs(data[0] - qmax))

    return data[:, minIndex:maxIndex + 1]


####################################################################
#                          Fit Function                            #
####################################################################


def run_fit_ba(q_axis, r_data, r_uncertainty, simulationFactory, startParams):

    fit_objective = ba.FitObjective()
    fit_objective.setObjectiveMetric("chi2")

    fit_objective.addSimulationAndData(
        lambda params: simulationFactory(q_axis, params), r_data, r_uncertainty,
        1.0)

    fit_objective.initPrint(10)

    params = ba.Parameters()
    for name, p in startParams.items():
        params.add(name, p[0], min=p[1], max=p[2])

    minimizer = ba.Minimizer()

    result = minimizer.minimize(fit_objective.evaluate, params)
    fit_objective.finalize(result)

    return {r.name(): r.value for r in result.parameters()}


####################################################################
#                          Main Function                           #
####################################################################

if __name__ == '__main__':

    if len(argv) > 1 and argv[1] == "fit":
        fixedParams = {
            # parameters can be moved here to keep them fixed
        }
        fixedParams = {d: v[0] for d, v in fixedParams.items()}

        startParams = {
            # own starting values
            "q_offset": (0.0, -0.02, 0.02),
            "q_res/q": (0.0, 0, 0.02),
            "t_pt/nm": (53, 40, 60),
            "r_si/nm": (1.22, 0, 5),
            "r_pt/nm": (0.25, 0, 5),
            "intensity": (1, 0, 2),
        }
        fit = True

    else:
        startParams = {}
        fixedParams = {
            # parameters from our own fit run
            'q_offset': 0.015085985992837999,
            'q_res/q': 0.010156450689003465,
            't_pt/nm': 48.564838355355405,
            'r_si/nm': 1.2857515425763575,
            'r_pt/nm': 0.2868252673771518,
            'intensity': 1.3156374978332654
        }
        fit = False

    paramsInitial = {d: v[0] for d, v in startParams.items()}

    qzs = numpy.linspace(qmin, qmax, scan_size)
    q, r = qr(run_simulation(qzs, paramsInitial))
    data = get_Experimental_data(qmin, qmax)

    plot(q, r, data, f'PtLayerFit_initial.pdf',
         dict(paramsInitial, **fixedParams))

    if fit:
        fitResult = run_fit_ba(data[0], data[1], data[2], run_simulation,
                               startParams)

        print("Fit Result:")
        print(fitResult)

        q, r = qr(run_simulation(qzs, fitParams=fitResult))
        plot(q, r, data, f'PtLayerFit_fit.pdf', dict(fitResult, **fixedParams))
Pt_layer_fit.py

Reference data