Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions Sources/SimulatoProbe/LineScan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,43 @@ public struct LineScan: Sendable, Hashable, ScanPattern {
adjustSampling()
}

/// A line scan matched to a probe (abTEM `LineScan(end=None)` then `match_probe`, which abTEM `scan` runs; the kit's
/// `STEM.scan` does not, so build the scan with this initialiser). Fractional coordinates are not offered here; use
/// the plain initialiser with `fractional` and `extent` and an explicit `gpts` or `sampling`. A missing `end`
/// is (0, the probe grid's `extentY`), the probe's second-axis extent; without a `sampling`, the sampling is 0.99 × the
/// Nyquist sampling of the aperture, 1 / (4 α_c / λ × 10⁻³) [Å], and the number of positions follows from it. A given
/// `sampling` wins, as in the plain initialiser; `gpts` with an `end` keeps its count.
/// - Parameters:
/// - start: the first point [Å] (default the origin, as abTEM).
/// - end: the last point [Å]; `nil` for the probe-matched end.
/// - gpts: the number of positions; needs `end` (see Throws).
/// - sampling: the largest distance between positions [Å]; `nil` for the probe-matched sampling.
/// - endpoint: true (abTEM's default) puts the last position at `end`.
/// - probe: the probe the scan is matched to.
/// - Throws: `invalidScan` for `gpts` without `end` or `sampling` (abTEM silently replaces that count by the
/// probe-matched one), for a probe-matched sampling of an aperture with no cutoff (no Nyquist sampling), and for
/// everything the plain initialiser refuses.
public init(start: SIMD2<Double> = .zero, end: SIMD2<Double>? = nil, gpts: Int? = nil, sampling: Double? = nil,
endpoint: Bool = true, matching probe: Probe) throws {
if gpts != nil, end == nil, sampling == nil {
throw SimulatoError.invalidScan("gpts needs an end point: without end or sampling the probe sets the sampling, and abTEM then ignores gpts")
}
var s = sampling
if s == nil && gpts == nil {
let cutoff = probe.aperture.semiangleCutoff
guard cutoff.isFinite else {
throw SimulatoError.invalidScan("an aperture with no cutoff has no Nyquist sampling: give gpts or sampling")
}
s = 0.99 * Self.nyquistSampling(semiangleCutoff: cutoff, wavelength: probe.beam.wavelength)
}
try self.init(start: start, end: end ?? SIMD2(0, probe.grid.extentY), gpts: gpts, sampling: s, endpoint: endpoint)
}

/// The Nyquist sampling of an aperture [Å] (abTEM `nyquist_sampling`): 1 / (4 α_c / λ × 10⁻³), α_c in mrad, λ in Å.
public static func nyquistSampling(semiangleCutoff: Double, wavelength: Double) -> Double {
1 / (4 * semiangleCutoff / wavelength * 1e-3)
}

/// The length of the line [Å] (abTEM `extent`).
public var extent: Double {
let d = end - start
Expand Down
69 changes: 69 additions & 0 deletions Tests/SimulatoProbeTests/LineScanDefaultsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// LineScanDefaultsTests.swift
// Role: a line scan matched to a probe (abTEM's LineScan(end=None) and match_probe), against numbers abTEM 1.0.10 printed
// (tools/pins/linescan-defaults/pins.py) for a 20 mrad probe at 200 keV and a 30 mrad probe at 80 keV on a
// 12.3 x 9.7 A grid; and its refusals.
//

import Foundation
import Testing
import SimulatoCore
import SimulatoProbe

@Suite("Line scan defaults matched to a probe")
struct LineScanDefaultsTests {
static func probe(_ cutoff: Double, _ energy: Double) throws -> Probe {
try Probe(beam: ElectronBeam(energy: energy), grid: Grid(nx: 64, ny: 48, extentX: 12.3, extentY: 9.7),
aperture: Aperture(semiangleCutoff: cutoff))
}

/// End points and counts exact; the sampling at the assert_allclose default rtol 1e-7 (abTEM states no tolerance).
static func check(_ s: LineScan, start: SIMD2<Double>, end: SIMD2<Double>, gpts: Int, sampling: Double) -> Bool {
s.start == start && s.end == end && s.gpts == gpts && abs(s.sampling - sampling) <= 1e-7 * sampling
}

@Test func aMissingEndAndSamplingFollowTheProbeAsAbTEM() throws {
let p20 = try Self.probe(20, 200e3), p30 = try Self.probe(30, 80e3)
let end = SIMD2(0.0, 9.7)
#expect(Self.check(try LineScan(matching: p20), start: .zero, end: end, gpts: 32, sampling: 0.3129032258064516))
#expect(Self.check(try LineScan(matching: p30), start: .zero, end: end, gpts: 29, sampling: 0.3464285714285714))
#expect(Self.check(try LineScan(start: SIMD2(1, 2), matching: p20), start: SIMD2(1, 2), end: end, gpts: 26,
sampling: 0.3105865418848666))
#expect(Self.check(try LineScan(start: SIMD2(1, 2), matching: p30), start: SIMD2(1, 2), end: end, gpts: 23,
sampling: 0.35293925214189387))
#expect(Self.check(try LineScan(endpoint: false, matching: p20), start: .zero, end: end, gpts: 32, sampling: 0.303125))
#expect(Self.check(try LineScan(endpoint: false, matching: p30), start: .zero, end: end, gpts: 29,
sampling: 0.33448275862068966))
}

@Test func aGivenEndKeepsItsPointAndAGivenSamplingOrCountWins() throws {
let p20 = try Self.probe(20, 200e3), p30 = try Self.probe(30, 80e3)
#expect(Self.check(try LineScan(start: SIMD2(1, 1), end: SIMD2(8, 5), matching: p20), start: SIMD2(1, 1),
end: SIMD2(8, 5), gpts: 26, sampling: 0.32249030993194194))
#expect(Self.check(try LineScan(start: SIMD2(1, 1), end: SIMD2(8, 5), matching: p30), start: SIMD2(1, 1),
end: SIMD2(8, 5), gpts: 24, sampling: 0.3505329455781978))
for p in [p20, p30] {
#expect(Self.check(try LineScan(end: SIMD2(8, 5), gpts: 17, matching: p), start: .zero, end: SIMD2(8, 5),
gpts: 17, sampling: 0.5896238207535377))
#expect(Self.check(try LineScan(sampling: 0.3, matching: p), start: .zero, end: SIMD2(0, 9.7), gpts: 33,
sampling: 0.303125))
}
// The matched scan is the plain scan with the same end and sampling.
let s = try LineScan(matching: p20)
#expect(s == (try LineScan(end: SIMD2(0, 9.7), sampling: 0.99 * LineScan.nyquistSampling(
semiangleCutoff: 20, wavelength: p20.beam.wavelength))))
}

@Test func refusals() throws {
let p20 = try Self.probe(20, 200e3)
// abTEM silently replaces this count by the probe-matched 32 positions.
#expect(throws: SimulatoError.self) { try LineScan(gpts: 10, matching: p20) }
let open = try Probe(beam: ElectronBeam(energy: 200e3), grid: Grid(nx: 64, ny: 48, extentX: 12.3, extentY: 9.7),
aperture: Aperture(semiangleCutoff: .infinity))
#expect(throws: SimulatoError.self) { try LineScan(matching: open) }
#expect(throws: SimulatoError.self) { try LineScan(sampling: 0, matching: p20) }
// A given count or sampling needs no cutoff.
#expect(try LineScan(end: SIMD2(8, 5), gpts: 4, matching: open).gpts == 4)
#expect(try LineScan(sampling: 0.5, matching: open).gpts == 20)
}
}
33 changes: 33 additions & 0 deletions tools/pins/linescan-defaults/pins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""SimulatoKit line-scan defaults: LineScan(end=None) and LineScan.match_probe (abTEM 1.0.10, numpy).

Prints the numbers that Tests/SimulatoProbeTests/LineScanDefaultsTests.swift holds inline (no fixture file): line scans
without an end point or a sampling, matched to a probe (a 20 mrad probe at 200 keV and a 30 mrad probe at 80 keV on a
12.3 x 9.7 A grid). match_probe puts a missing end at (0, extent[1]) of the probe and, without a sampling, sets 0.99 x the
Nyquist sampling of the aperture, which recomputes the number of positions. The test holds the end points and counts
exact and the sampling at rtol 1e-7 (assert_allclose defaults; abTEM states no tolerance).

Run from the repo root: .venv/bin/python -I -B tools/pins/linescan-defaults/pins.py
"""

from abtem import LineScan, Probe

probes = {
"p20": Probe(semiangle_cutoff=20.0, energy=200e3, extent=(12.3, 9.7), gpts=(64, 48)),
"p30": Probe(semiangle_cutoff=30.0, energy=80e3, extent=(12.3, 9.7), gpts=(64, 48)),
}

cases = {
"default": dict(),
"start only": dict(start=(1.0, 2.0)),
"no end, no endpoint": dict(endpoint=False),
"end, no sampling": dict(start=(1.0, 1.0), end=(8.0, 5.0)),
"end and gpts": dict(end=(8.0, 5.0), gpts=17),
"no end, sampling": dict(sampling=0.3),
"no end, gpts": dict(gpts=10),
}

for pname, probe in probes.items():
for cname, kwargs in cases.items():
scan = LineScan(**kwargs)
scan.match_probe(probe)
print(pname, "|", cname, "| start", scan.start, "end", scan.end, "gpts", scan.gpts, "sampling", repr(scan.sampling))
Loading