Convergence

Contents

Hide code cell source
import ase
import matplotlib.pyplot as plt
import numpy as np
import xarray
from abtem.bloch import StructureFactor
from tqdm.auto import tqdm

import abtem

xarray.set_options(display_expand_data=False)
<xarray.core.options.set_options at 0x2064f5c5a50>

Convergence#

In this tutorial, we go through how to ensure a multislice simulation with respect to three major convergence parameters: real space sampling, slice thickness and the size of the ensemble, when calculating ensemble means.

We start by setting the precission to 64-bit floating point, the default is 32-bit, as calculations with extremely small samplings and slice thicknesses can lead to overflow.

abtem.config.set({"precision": "float64"})
<abtem.core.config.set at 0x2064f486010>

Slice thickness#

We now turn convergence with respect to slice thickness, our test system is a simulation diffraction patterns with an acceleration voltage of \(200 \ \mathrm{keV}\) and a real space sampling of \(0.02 \ \mathrm{Å}\).

atoms = ase.build.bulk("Si", cubic=True)
abtem.show_atoms(atoms)

wave = abtem.PlaneWave(energy=100e3, sampling=0.02)
../../_images/ad61b996b606ece4b16092f36629b29fef0b272feecd56528175564558278a8e.png

We use a geometric sequence halving the slice thickness at each step, starting from the thickness of the entire unit cell, \(a/1, a/2, a/4, ..., a/256\).

thickness_fractions = np.array([2**n for n in np.arange(0, 9)])

thicknesses = atoms.cell[2, 2] / thickness_fractions

thicknesses
array([5.43      , 2.715     , 1.3575    , 0.67875   , 0.339375  ,
       0.1696875 , 0.08484375, 0.04242187, 0.02121094])

We test both finite and infinite potential integrals, below the calculations are set up.

# Initialize a dictionary to store exit wave data for different projections.
exit_waves_lists = {"finite": [], "infinite": []}

# Loop through specified slice thicknesses and projections.
for thickness in thicknesses:
    for projection in exit_waves_lists:

        # Create a potential for the given projection and thickness.
        potential_unit = abtem.Potential(
            atoms,
            projection=projection,
            slice_thickness=thickness,
            sampling=wave.sampling,
        )

        # Define a crystal potential with the potential unit repeated in the z-direction.
        potential = abtem.CrystalPotential(potential_unit, repetitions=(1, 1, 6))

        # Calculate the exit wave using the multislice method.
        exit_wave = wave.multislice(potential)

        # Append the exit wave to the corresponding list in the dictionary.
        exit_waves_lists[projection].append(exit_wave)

We stack all the results to allow for parallelization and to make further analysis easier.

# Define an OrdinalAxis object to represent metadata for the axis.
axes_metadata = abtem.core.axes.OrdinalAxis(
    label="slice thickness", values=[f"a/{f}" for f in thickness_fractions]
)

# Stack the finite exit waves along a new axis using the defined axes metadata.
stacks = [
    abtem.stack(value, axis_metadata=axes_metadata)
    for value in exit_waves_lists.values()
]

# Define new axes metadata, now labeling the axis as "projection"
axes_metadata = abtem.core.axes.OrdinalAxis(
    label="projection", values=list(exit_waves_lists.keys())
)

# Stack the finite and infinite exit waves along a new 'projection' axis.
exit_waves = abtem.stack(
    stacks,  # List of the two exit wave stacks to combine
    axis_metadata=axes_metadata,  # The new axis metadata
)

# Swap the first and second axes of the exit_waves array for plotting
exit_waves = abtem.array.swapaxes(exit_waves, 0, 1)

exit_waves.axes_metadata
type           label            coordinates
-------------  ---------------  ------------------
OrdinalAxis    slice thickness  -
OrdinalAxis    projection       -
RealSpaceAxis  x [Å]            0.00 0.02 ... 5.41
RealSpaceAxis  y [Å]            0.00 0.02 ... 5.41

Compute all the calculations.

exit_waves.compute()
[########################################] | 100% Completed | 95.83 ss
<abtem.waves.Waves at 0x2064f7f3050>

We show the exit waves up to a slice thickness of \(a/16\), as it becomes difficult to tell differences by eye after this point.

exit_waves[:5].show(explode=True, common_color_scale=True, figsize=(12, 6));
../../_images/262c7bcfe7f7602fd44717e4c9f365567de0489ca3c3f7a288d51d3e5269ee68.png

To make the comparison quantitative, we obtain the diffraction spots. We convert the abTEM object to an Xarray DataArray to use its more powerful generic data manipulation tools, the abTEM AxisMetadata is converted to coordinates.

spots = (
    exit_waves.diffraction_patterns()
    .crop(140)
    .to_cpu()
    .index_diffraction_spots(atoms.cell, centering="F")
)

spots = spots**0.5

data = spots.to_data_array()

data
<xarray.DataArray (slice thickness: 9, projection: 2, hkl: 529)> Size: 76kB
0.9431 2.285e-08 0.07718 2.15e-09 ... 0.002454 0.00113 0.002416 0.0004934
Coordinates:
  * slice thickness  (slice thickness) <U5 180B 'a/1' 'a/2' ... 'a/128' 'a/256'
  * projection       (projection) <U8 64B 'finite' 'infinite'
  * hkl              (hkl) <U10 21kB '0 0 0' '0 2 0' ... '-1 -11 -1' '-1 -9 -1'
Attributes:
    long_name:  intensity
    units:      arb. unit
spots[:5].show(explode=True, figsize=(12, 6), scale=0.05, annotations=False)
<abtem.visualize.visualizations.Visualization at 0x2060082e810>
../../_images/46c77bc44376822201a2908d8707644e308a53fda39ecc4d659bf73be1b6925a.png

To calculate an error we need to choose a reference calculation, here we use the calculation with the finest finite projection integrals.

# Select the reference data point
reference = data.sel({"projection": "finite", "slice thickness": "a/256"})

# Calculate the difference between each data point in 'data' and the selected reference.
difference = data - reference

# Define a list of Miller indices to plot
miller_indices = [
    "0 0 0",
    "2 -2 0",
    "4 0 0",
]

# Create a 1-row, 3-column subplot structure and set the figure size.
fig, axes = plt.subplots(1, 3, figsize=(12.5, 4))

# Loop through each subplot axis and corresponding Miller index.
for ax, miller_index in zip(axes, miller_indices):
    # Plot the line graph of the difference data selected by Miller index,
    # with different lines for each projection.
    difference.sel(hkl=miller_index).plot.line(hue="projection", ax=ax)

    # Add a horizontal line at y=0 for reference
    ax.axhline(0, color="gray", linestyle="--")
    ax.ticklabel_format(axis='y', scilimits=[-3, 3])

plt.tight_layout()
../../_images/5f53ad224272cf8e0ee4905b27993880f7a1c63448eb7ff6c2484233a113e1e0.png

As there are four atomic planes in the unit cell, any smaller slice thickness beyond \(a/4\) does not improve the accuracy, when using the infinite projection. Actually, a any slice thickness that does not have \(4\) as a factor may be slightly worse, since such a slice thickness requires shifting the atoms along \(z\).

Here, we use the R-factor, which in crystallogrphy is a measure of the difference between the observed (\(F_{obs}\)) and calculated (\(F_{calc}\)) structure factor amplitudes in crystallography. $\( R = \frac{\sum |F_{\text{obs}} - F_{\text{calc}}|}{\sum |F_{\text{obs}}|} \)$

is a measure of the difference between the observed () and calculated () structure factor amplitudes in crystallography. The R-factor is used to assess the quality of a crystal structure model.

We find that the R-factor is converged to within \(1 \%\) for a slice thickness of \(a/16\), as stated above the infinite projection cannot improve beyound the the atomic planes, hence

r_factor = np.abs(data - reference).sum("hkl") / np.abs(data).sum("hkl")

fig, ax = plt.subplots()
ax.axhline(0, color="gray", linestyle="--")
r_factor.plot.line(hue="projection", ax=ax)

ax.set_ylabel("r-factor")
ax.set_ylim([-0.05, 0.5]);
../../_images/4372d927b259a0e8059a186a561d9bba27da8e9fbdbb2917845e9ee70e6ec115.png