Simulation¶
PyExtal computes diffraction intensities using the Bloch wave method: for each incident beam direction (tilt) the structure-factor matrix is diagonalised, and intensities are assembled from the resulting eigenvalues and eigenvectors as a function of sample thickness.
Two types of simulation are supported:
CBED — convergent beam electron diffraction: a 2D image where each pixel corresponds to one incident angle inside a cone.
LARBED — large angle rocking beam: a stack of 2D maps, one per diffraction disk, where each pixel is the intensity in that disk at a given sample tilt.
Both start from a .dat configuration file. See Configuration for the full reference.
CBED simulation¶
Quick path — simulate()¶
simulate() runs the full pipeline (parse → eigendecompose → render) and returns a 2D intensity image alongside the SimParams object.
import numpy as np
import matplotlib.pyplot as plt
import pyextal.callBloch as cb
# displayParam: [x0, y0, gl, xs, ys, theta]
# x0, y0 — pattern centre in pixels
# gl — geometric scale (pixel size in reciprocal space, Å⁻¹/pixel)
# xs, ys — output image size in pixels
# theta — pattern rotation angle (degrees)
displayParam = [512, 512, 0.05, 1024, 1024, 0.0]
thickness = 300 # Å
image, param = cb.simulate(
'path/to/crystal.dat',
thickness,
displayParam,
)
plt.imshow(image, cmap='inferno')
plt.colorbar()
plt.show()
simulate() uses subaper=0 (full square tilt grid) internally. Do not pass subaper=1 — see Aperture modes below.
Step-by-step path — bloch() then assemble manually¶
For more control (e.g., to inspect per-tilt intensities or use a non-standard aperture), call bloch() and compute intensities yourself:
import numpy as np
import pyextal.callBloch as cb
param = cb.bloch('path/to/crystal.dat')
# param.tilts is a list of Data objects, one per tilt
# For each tilt, compute intensity from eigenvalues (gamma) and eigenvectors:
# psi[g] = Σ_k C[g,k] · cinv[k] · exp(i π γ_k t / K_n)
# I[g] = |psi[g]|²
thickness = 300 # Å
K_n = param.bigK * np.cos(np.radians(param.alpha))
intensities = []
for data in param.tilts:
phase = np.exp(1j * np.pi * data.gamma * thickness / K_n)
psi = data.eigenvector @ (data.cinv * phase)
I = np.abs(psi) ** 2
intensities.append(I) # shape: (nout,)
Each Data object in param.tilts contains:
Field |
Shape |
Description |
|---|---|---|
|
scalar |
Number of strongly excited beams for this tilt |
|
|
Complex eigenvalues |
|
|
Rows are output beams; columns are Bloch modes |
|
|
First column of C⁻¹ — initial excitation amplitudes |
|
scalar |
Grid position of this tilt |
LARBED simulation¶
LARBED computes the intensity of each diffraction disk as a 2D map over the tilt grid. The two-step structure (eigendecompose once, evaluate at many thicknesses) makes it efficient: call bloch() once, then call LARBED() or LARBED_tilt() at any number of thicknesses without repeating the eigensolve.
LARBED() — render the full 2D map¶
import numpy as np
import matplotlib.pyplot as plt
import pyextal.callBloch as cb
param = cb.bloch('path/to/crystal.dat')
thickness = 400 # Å
disks = cb.LARBED(param, thickness) # shape: (nout, H, W)
fig, axes = plt.subplots(1, param.nout, figsize=(4 * param.nout, 4))
for i, ax in enumerate(axes):
ax.imshow(disks[i], cmap='inferno')
h, k, l = param.hklout[:, i]
ax.set_title(f'({h} {k} {l})')
plt.tight_layout()
plt.show()
LARBED() renders the tilt grid of the param it is given, from the eigen-solutions stored by bloch() / bloch_run().
LARBED_tilt() — per-disk intensity array¶
LARBED_tilt() returns a (nout, ntilt) array — the intensity of each output disk at each tilt. This is the function used during refinement because it works on any tilt list, including one passed to tilt_run(), and supports thickness arrays (one value per tilt).
# Single thickness
intensity = cb.LARBED_tilt(param, thickness=400, nout=param.nout)
# shape: (nout, ntilt)
# Per-tilt thickness (e.g., wedge-shaped sample)
thickness_map = np.linspace(300, 500, param.ntilt)
intensity = cb.LARBED_tilt(param, thickness=thickness_map, nout=param.nout)
Number of cores¶
The eigen-decomposition runs on a thread pool; ncores sets its size (ncores=0 uses
all physical cores). bloch(), bloch_run(), tilt_run() and simulate() default
to ncores=1:
param = cb.bloch('crystal.dat', ncores=8)
See Simulation Engine for how the eigensolver works and how it scales.
Adjusting structure factors¶
To modify structure factors before simulation — for example to test a refinement step — use updateUgh():
from pyextal.callBloch import bloch_parse, bloch_run, updateUgh
param = bloch_parse('crystal.dat')
# reflections: list of (h, k, l) tuples
# values: (nref, 4) array — columns are [|Ugh|, phase°, |U'gh|, phase°]
reflections = [(1, 1, 1), (2, 2, 0)]
values = np.array([
[0.0312, 180.0, 0.0015, 0.0], # (1 1 1)
[0.0210, 0.0, 0.0010, 0.0], # (2 2 0)
])
updateUgh(reflections, values)
param = bloch_run(param)
updateUgh() must be called after bloch_parse() and before bloch_run(). Calling bloch_parse() again resets structure factors to the values in the .dat file.
Aperture modes¶
The subaper parameter controls which tilts are included in the tilt grid:
|
Description |
|---|---|
|
Full square grid of side |
|
Circular aperture of radius |
|
Custom circular aperture; specify radius via |
|
Custom rectangular aperture; specify bounds via |
# Circular aperture
param = cb.bloch('crystal.dat', subaper=1)
# Custom rectangle: x in [-20, 30], y in [-10, 15]
param = cb.bloch(
'crystal.dat', subaper=3,
subnx=np.array([-20, 30], dtype=np.int32),
subny=np.array([-10, 15], dtype=np.int32),
)
simulate()callscbedpinternally, which assumes a full square grid. Passingsubaper=1tosimulate()will produce a shape mismatch and a crash. Usebloch()+ manual rendering for non-square apertures.
Sample tilt¶
A sample tilt away from the zone axis can be set via t_x and t_y (degrees):
param = cb.bloch('crystal.dat', t_x=0.5, t_y=-0.3)
These shift the centre of the tilt grid relative to the zone axis direction defined in the .dat file.
Complete LARBED example¶
import numpy as np
import matplotlib.pyplot as plt
import pyextal.callBloch as cb
# 1. Eigendecompose once
param = cb.bloch('path/to/crystal.dat')
print(f'{param.ntilt} tilts, {param.nout} output disks')
# 2. Render disk maps at several thicknesses
thicknesses = np.arange(100, 600, 50) # Å
fig, axes = plt.subplots(
len(thicknesses), param.nout,
figsize=(3 * param.nout, 3 * len(thicknesses)),
)
for row, t in enumerate(thicknesses):
disks = cb.LARBED(param, thickness=t) # shape: (nout, H, W)
for col in range(param.nout):
ax = axes[row, col] if param.nout > 1 else axes[row]
ax.imshow(disks[col], cmap='inferno', origin='lower')
if row == 0:
h, k, l = param.hklout[:, col]
ax.set_title(f'({h} {k} {l})')
ax.set_ylabel(f't = {t} Å' if col == 0 else '')
ax.axis('off')
plt.tight_layout()
plt.show()