A Swift package for simulating electron scattering on Apple platforms: projected potentials, multislice propagation,
frozen phonons, probes with aberrations, STEM scans with detectors, and the contrast transfer function for HRTEM images.
It is a port of abTEM 1.0.10, checked against abTEM in float64 (Python reference, pinned
inputs). The algorithms and the Lobato–Van Dyck (2014) parameters are abTEM's; credit
for the method belongs to the abTEM authors. Licence: GPL-3.0-or-later (LICENSE, NOTICE); software built on
SimulatoKit must be GPL-compatible. It is a library of numbers, not an application, and has no UI.
| Product | What it does |
|---|---|
SimulatoCore |
the electron beam (wavelength, interaction parameter), grids, complex and real fields, waves (intensity, diffraction pattern), the antialias aperture, a minimal orthogonal-cell AtomicStructure, a float64 FFT |
SimulatoMultislice |
the Lobato parameterisation, slicing, the projected potential, transmission and Fresnel propagation, Multislice.run / runBatch on .cpu and .metal, frozen phonons |
SimulatoProbe |
the soft aperture, polar aberrations C10 to C56, the probe at a position, GridScan, ContrastTransferFunction (envelopes, Scherzer defocus, point resolution) and Waves.applying for HRTEM |
SimulatoDetector |
annular (BF, ADF, HAADF), pixelated (4D-STEM) and segmented (DPC) detectors, and STEM.scan, averaged over frozen-phonon configurations |
.package(url: "https://github.com/mac4DSTEM/SimulatoKit", from: "0.3.1")
// target dependencies, as needed:
.product(name: "SimulatoMultislice", package: "SimulatoKit") // also SimulatoCore, SimulatoProbe, SimulatoDetectorSwift 6.2 tools, Swift 6 language mode. Platforms: macOS 15 and iOS/iPadOS 18 (from 0.2.0). Every module, the Metal
path included, builds for both (MPSGraph's FFT needs macOS 14 / iOS 17, Synchronization.Mutex macOS 15 / iOS 18); the
tests and the consumer checks run on macOS, where the numbers are validated. Developed and tested on macOS 27.
Build with -c release: the FFT and potential loops are many times slower unoptimised. Grid lengths are any integer of at least 2
(Accelerate's DFT where it has the length, Bluestein's algorithm otherwise; both backends). Cells are orthogonal.
Names checked against the sources and docs/parity.md (which lists every abTEM entry, ported or not).
| abTEM | SimulatoKit |
|---|---|
Potential(atoms, gpts, slice_thickness) |
Potential(structure:nx:ny:sliceThickness:) |
Potential(atoms, sampling=0.1, ...) |
Potential(structure:sampling:sliceThickness:) |
PlaneWave(tilt=...) |
Waves.planeWave(beam:grid:tilt:) |
Probe(semiangle_cutoff, energy, tilt=...) |
Probe(beam:grid:aperture:aberrations:tilt:) |
waves.multislice(potential) |
Multislice.run(_:through:exitPlanes:backend:) |
waves.intensity() |
Waves.intensity() |
waves.diffraction_patterns(max_angle=...) |
Waves.diffractionPattern(maxAngle:) |
CTF(...), waves.apply_ctf(ctf) |
ContrastTransferFunction(aberrations:...), Waves.applying(_:) |
GridScan(start, end, gpts), GridScan(..., sampling=) |
GridScan(start:end:nx:ny:), GridScan(start:end:samplingX:samplingY:) |
dp.center_of_mass(), dp.gaussian_source_size(s), .poisson_noise(...) |
CenterOfMassDetector, ScanMeasurement.gaussianSourceSize(...), .poissonNoise(...) |
probe.scan(potential, scan, detectors) |
STEM.scan(probe:potentials:scan:detectors:backend:) |
AnnularDetector, PixelatedDetector, SegmentedDetector |
the same names, with labelled initialisers |
FrozenPhonons(atoms, num_configs, sigmas, seed) |
FrozenPhonons(structure:sigmas:configurations:seed:directions:) |
macOS 15 or later and iOS/iPadOS 18 or later (Package.swift). Linux is not supported yet: the FFT uses Apple's
Accelerate framework, and the GPU backend uses Metal and MPSGraph.
SimulatoKit is a port of abTEM. If you use it in published work, please cite abTEM: J. Madsen and T. Susi, "The abTEM
code: transmission electron microscopy from first principles", Open Research Europe 1, 24 (2021), and, for the
scattering parameters, I. Lobato and D. Van Dyck, Acta Cryst. A 70, 636-649 (2014), doi:10.1107/S205327331401643X.
CITATION.cff has the same references in machine-readable form.
import SimulatoCore
import SimulatoMultislice
import SimulatoProbe
import SimulatoDetector
let a = 4.05, h = a / 2 // fcc Al, Å
let cell = try AtomicStructure(box: SIMD3(a, a, a), atoms: [
.init(atomicNumber: 13, position: SIMD3(0, 0, 0)), .init(atomicNumber: 13, position: SIMD3(0, h, h)),
.init(atomicNumber: 13, position: SIMD3(h, 0, h)), .init(atomicNumber: 13, position: SIMD3(h, h, 0))])
let beam = try ElectronBeam(energy: 300e3) // eV
let backend: Backend = Multislice.metalIsAvailable ? .metal : .cpu
// A Potential is the sliced structure: built on the GPU slice by slice on .metal, or on the CPU (`build()`) on .cpu.
let potential = try Potential(structure: cell.repeated(3, 3, 10), nx: 256, ny: 256, sliceThickness: 2)
// HRTEM: plane wave, multislice, CTF at Scherzer defocus (Cs = 1 mm; lengths in Å)
let exit = try Multislice.run(.planeWave(beam: beam, grid: potential.grid), through: potential, backend: backend)[0]
var ab = Aberrations()
ab.Cs = 1e7
ab.defocus = try ContrastTransferFunction.scherzerDefocus(Cs: 1e7, energy: 300e3)
let hrtem = try exit.applying(ContrastTransferFunction(aberrations: ab)).intensity() // RealField
// STEM HAADF: 20 mrad probe over one unit cell, annular detector 70 to 200 mrad
let probe = try Probe(beam: beam, grid: potential.grid, aperture: Aperture(semiangleCutoff: 20))
let scan = try GridScan(start: SIMD2(0, 0), end: SIMD2(a, a), nx: 16, ny: 16)
let haadf = try STEM.scan(probe: probe, potentials: [potential], scan: scan,
detectors: [AnnularDetector(inner: 70, outer: 200)],
backend: backend)[0] // ScanMeasurement: values over the scanBackend.cpu is the reference: float64 on Accelerate (vDSP); scans and batches use up to 8 threads. Backend.metal
runs in float32 on the GPU (MPSGraph) behind the same API: for a Potential the projected potential is built on the GPU
slice by slice, the transmission functions always are, the wave stays on the device, and scans build their probes and
diffraction intensities there too; one compiled step is encoded per slice into one command buffer. Without a Metal
device it throws SimulatoError.metalUnavailable (Multislice.metalIsAvailable tells beforehand). The registered
tolerances of Metal against the CPU: exit wave 1e−4, intensity, diffraction pattern and detector images 2e−4 relative to
the maximum, total intensity 1.5e−6 per slice, up to 100 slices; measured, the wave
is within about 5e−5 and the total intensity within 5e−7 per slice (1.4e−7 on light elements) at up to 1024² with 100
slices. Detector images are bounded by 2e−4 × max(their maximum, 1e−5 of the probe's intensity): weaker signals sit at
float32's noise floor (MetalAccuracy.registered.detectorBound(imageMaximum:incidentIntensity:)).
Measured speed (Apple M5 Pro, macOS 27; fcc Al at 300 keV, 100 slices, about 0.047 Å per pixel; from the structure to the numbers, the potential included):
| Grid | Plane wave exit: Metal / CPU | 64 × 64 HAADF scan: Metal / CPU | Metal peak memory |
|---|---|---|---|
| 256² | 0.09 s / 0.59 s | 17 s / 516 s | 1.0 GB |
| 512² | 0.13 s / 4.4 s | 90 s / 2521 s | 1.3 GB |
| 1024² | 0.28 s / 54 s | 408 s / 15157 s | 1.7 GB |
The CPU scans run 8 threads and were timed on a subset of positions and scaled (linear in positions). Caveat: these first numbers were taken on a heavily loaded machine (other builds were running), which slows the CPU side most, so read the Metal-to-CPU ratios as upper bounds until the table is re-measured on an idle machine.
Every choice the kit makes has a parameter, and the default is the value the numbers were validated with:
ExecutionOptions (options: on Multislice.run, Multislice.runBatch and STEM.scan: CPU threads, Metal batch memory
and shapes, the CPU scan's chunk, the Metal renormalisation and executable cache), MetalAccuracy.registered (the
Metal-against-CPU bounds the tests hold), Grid.matches(_:relativeTolerance:), the slice ceiling (maximumSliceCount:)
and the constants of scherzerDefocus, pointResolution and nyquistSampling. abTEM's own conventions (the antialias
aperture, the slicing rules, the π choice in LobatoParameterization) stay as abTEM defines them.
// continuing the quick start: its 256 probe positions as a batch, with smaller Metal batches and fewer CPU threads
var options = ExecutionOptions.standard
options.maximumCPUThreads = 4
options.metalBatchBytes = 128 << 20
let probes = try scan.positions.map { try probe.build(at: $0) }
let exits = try Multislice.runBatch(probes, through: potential, backend: backend, options: options)Numbers are held to the Python reference within its own tolerance; docs/parity.md lists each function, its state and its tolerance.
- Pins against abTEM 1.0.10 (
164e644) in float64: wavelength, potentials, probes, exit waves, patterns, detectors, CTF kernels and HRTEM images agree within 1e−10 of the array's maximum or tighter (measured 1e−13 to 1e−16, with abTEM's float32 π reproduced by.abTEMFloat32), and within 1e−6 with the kit's default exact π. - Truth tests independent of abTEM: Kirkland's table, the k = 0 identity, the far field, vacuum propagation, slice symmetry, the probe, the aberration phase, the thickness series.
tools/consumer-check: a separate package that uses the whole public API from outside.
- Nothing is validated against a micrograph. Partial coherence is abTEM's envelope approximation; there is no detector
response; the only noise is
ScanMeasurement.poissonNoise(the kit's own generator, not NumPy's stream). Frozen phonons use Einstein σ given by the caller, and agree with abTEM exactly only when abTEM's displacements are supplied (otherwise in distribution). - Metal's float32 FFT is not exactly unitary (it loses about 8e−7 of the total intensity per slice; a per-step renormalisation brings that to 5e−7 or less); the Metal bounds are registered to 100 slices, and past 300 slices the pattern error exceeds its bound.
- Cells are orthogonal and always periodic; atoms in the gap of a short slice list are kept (abTEM drops them);
.afterSlicesalways adds the last slice. Finite projections, rotated or cut cells and some of abTEM's options are not ported (docs/parity.md).AtomicStructureand the example's CIF reader are minimal local stand-ins.
- MPSGraph's float32 FFT is not exactly unitary (
tools/metal-fft-gain/); weak high-angle signals on Metal sit at float32's floor, so the detector bound is floored (MetalAccuracy.detectorBound); use.cpufor them. Unphysical phases (σV above about 200 rad in one pixel column) break the Metal bounds. - The Metal bounds hold to 100 slices and grids to 1024²; past 300 slices the pattern error exceeded its bound (not re-measured with the renormalisation). Re-measure after OS updates.
- Not validated against a micrograph; frozen phonons agree with abTEM only when abTEM's displacements are given; segmented detectors sum in float64 where abTEM sums in float32 (agreement about 2e−7).
- A
ProjectedPotentialon.metaluploads every slice; preferPotentialfor large Metal runs.AtomicStructureand the example's CIF reader are local stand-ins for DiffractoKit's cell types.
swift run -c release SimulatoExample [file.cif] [output-directory]Without arguments it uses the bundled Examples/SimulatoExample/Al.cif, builds a 3 × 3 × 10-cell crystal on 256², and writes
Al-hrtem.png (CTF at Scherzer defocus, 300 keV, Cs = 1 mm, focal spread 30 Å), Al-haadf.png (16 × 16 scan over one
cell, 70 to 200 mrad) and Al-numbers.csv to the current directory, printing the image statistics and the HAADF values,
on Metal when a device exists. It takes a few seconds.
The CIF must have an orthogonal cell.
swift test -c releaseUse swift test -c release rather than swift build --build-tests -c release, which fails on the current toolchain. The
abTEM reference (a Python virtual environment) is not in the repository; tools/slice-*/pins.py regenerate the fixtures.
Issues and pull requests are welcome; see CONTRIBUTING.md. A change that moves a number needs a test that fails when
the code is broken and a comparison against abTEM; say which. GPL-3.0-or-later; see LICENSE and NOTICE.