diff --git a/Sources/SimulatoProbe/LineScan.swift b/Sources/SimulatoProbe/LineScan.swift index bfc48b8..5c87ca3 100644 --- a/Sources/SimulatoProbe/LineScan.swift +++ b/Sources/SimulatoProbe/LineScan.swift @@ -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 = .zero, end: SIMD2? = 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 diff --git a/Tests/SimulatoProbeTests/LineScanDefaultsTests.swift b/Tests/SimulatoProbeTests/LineScanDefaultsTests.swift new file mode 100644 index 0000000..ceb91fe --- /dev/null +++ b/Tests/SimulatoProbeTests/LineScanDefaultsTests.swift @@ -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, end: SIMD2, 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) + } +} diff --git a/tools/pins/linescan-defaults/pins.py b/tools/pins/linescan-defaults/pins.py new file mode 100644 index 0000000..6b55be0 --- /dev/null +++ b/tools/pins/linescan-defaults/pins.py @@ -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))