Fitting GISAS Data

This example demonstrates an lmfit fit of synthetic 2D GISAS data.

Final fit state

Fit script

  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
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["bornagain>=25,<26", "lmfit"]
# ///
"""
Basic GISAS 2D fitting example.
Fake experimental data are generated from a known sample model.
"""

import bornagain as ba
ba.require_versions("bornagain>=25,<26")
from bornagain import deg, nm, nm2
import lmfit
import numpy as np


def get_sample(P):
    """
    Dilute cylinders on a substrate, parameterized for GISAS fitting.
    """
    substrate_color = (0.28, 0.57, 0.82)
    particle_color = (0.86, 0.24, 0.18)

    substrate_mat = ba.RefractiveMaterial("Substrate", substrate_color, 6e-6, 2e-8)
    particle_mat = ba.RefractiveMaterial("Particle", particle_color, 6e-4, 2e-8)
    cylinder_ff = ba.Cylinder(P["cylinder_radius"], P["cylinder_height"])
    particle = ba.Particle(particle_mat, cylinder_ff)

    particle_layer = ba.Layer(ba.Vacuum())
    # Modest coverage keeps material averaging physical over the fit bounds.
    particle_layer.deposit2D(ba.Dilute2D(1e-3/nm2, particle))

    sample = ba.Sample()
    sample.addLayer(particle_layer)
    sample.addLayer(ba.Layer(substrate_mat))
    return sample


def get_simulation(P):
    """
    GISAS simulation with fitted beam intensity and background.
    """
    beam = ba.Beam(10**P["lg_intensity"], 0.1*nm, 0.2*deg)
    detector = ba.SphericalDetector(100, -1.5*deg, 1.5*deg, 100, 0, 3*deg)
    simulation = ba.ScatteringSimulation(beam, get_sample(P), detector)
    simulation.setBackground(ba.ConstantBackground(10**P["lg_background"]))
    return simulation


def get_plotters(exp_data):
    """
    Creates the fit-progress plotters.
    """
    norm = ba.intensity_norm(exp_data)

    experiment_plotter = ba.FitPlotter(
        ba.plot_experimental,
        context_data=exp_data,
        norm=norm,
        with_cb=True,
        title="Experimental",
    )

    simulation_plotter = ba.FitPlotter(
        ba.plot_heatmap,
        norm=norm,
        with_cb=True,
        title="Simulation",
    )

    difference_plotter = ba.FitPlotter(
        ba.plot_difference,
        context_data=exp_data,
        with_cb=True,
        title="Relative difference",
    )

    return [
        experiment_plotter,
        simulation_plotter,
        difference_plotter,
    ]


if __name__ == '__main__':
    fake_params = {
        "lg_intensity": 5,
        "lg_background": 1,
        "cylinder_height": 5.*nm,
        "cylinder_radius": 5.*nm,
    }
    exp_data = get_simulation(fake_params).simulate()
    flat_exp_values = exp_data.intensities().ravel()

    # Fit progress display
    monitor = ba.FitMonitor(
        get_plotters(exp_data),
        ncols=2,
        show_best=True,
        max_fps=1,
        printer=ba.Printer(every_nth=10),
        live=True)

    def residuals(P):
        """
        Simulates, reports, and returns the residual vector.
        """
        sim_result = get_simulation(P.valuesdict()).simulate()
        flat_sim_values = sim_result.intensities().ravel()
        residuals = flat_exp_values - flat_sim_values
        monitor.update(sim_result, P, residuals)
        return residuals

    P = lmfit.Parameters()
    P.add("lg_intensity", value=4.3, min=0, max=15)  # (dimensionless)
    P.add("lg_background", value=2.0, min=-2, max=10)  # (dimensionless)
    P.add("cylinder_height", value=8*nm, min=0.01*nm)
    P.add("cylinder_radius", value=3.5*nm, min=0.01*nm, max=8*nm)

    result = lmfit.minimize(residuals, P, method="leastsq")

    finalP = result.params.valuesdict()
    # Recompute and report the simulation at the fitted parameters.
    residuals(result.params)
    # Render the just-reported evaluation as the final fit state.
    monitor.render_final(result.params)
    print("Fit completed.")
    print(lmfit.fit_report(result))
    ba.showSample3D(get_sample(finalP), sample_size=120*nm, seed=0)
    ba.plt.show()
auto/Examples/fit/scatter2d/fit_gisas.py

Explanation

The function get_sample is basically the same as in our basic GISAS simulation example, except that radius and height of the cylindrical disks are now supplied as external parameters. These parameters are passed through a Python dictionary.

The function get_simulation has the same function argument params, from which it takes beam intensity and background level. These two parameters are on a logarithmic scale.

The residual function is defined directly in the script. It converts lmfit.Parameters to a dictionary, runs the simulation, extracts simulated intensities as a NumPy array, and returns the flattened difference to the experimental data. It reports the simulation, parameters, and residuals together through FitMonitor.update; no optimizer callback or shared simulation state is needed. Three FitPlotter objects describe the experimental map, simulation, and relative difference. The monitor arranges them and the fit status automatically. Each map owns its colorbar, while both intensity maps share a fixed normalization.

The third plot is the signed relative difference 2*(simulation-experiment)/(|simulation|+|experiment|) on a fixed [-2, 2] scale; it is a visual diagnostic, not the fit objective.