Source code for de_twin.optics.config
"""Optics options: the column-side knobs of VirtualSpecimenProps plus the twin's
physical beam/aberration model.
Every C++ ``Simulator ...`` optics property has a snake_case field here (the
render-side ones live in :class:`de_twin.render.RenderConfig`).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from ..state import ProbeMode, RenderMode
if TYPE_CHECKING:
from .realism import ColumnRealism
[docs]
@dataclass
class OpticsConfig:
# ---- scale fallbacks / overrides (C++: Fallback Pixel Size, Pixel Size Override, ...)
fallback_pixel_nm: float = 1.0 # used when the calibration has no entry (sensor px)
fallback_recip_inv_nm: float = 0.04 # 1/nm per unbinned detector pixel
pixel_size_override_nm: float = 0.0 # >0 replaces the calibration outright (and the STEM step)
#: The post-column magnification the camera sits behind (the DE camera as a second camera
#: is about 1.25 to 1.5x; behind an energy filter it is larger). The rendered TEM pixel
#: is the calibrated one divided by this at every magnification, while the calibration
#: (and so the served `Specimen Pixel Size`) still reports the nominal value: the column
#: does not know. Ignored when `pixel_size_override_nm` is set. 1.0 changes nothing.
post_magnification: float = 1.0
geometric_diffraction: bool = True # recip px from camera pixel / camera length (C++ SIMULATOR path)
default_camera_length_mm: float = 250.0 # used when the column reports CL <= 0
# ---- geometry (C++: Stage Offset X/Y/Z, Defocus Offset, Eucentric Height, Flip X/Y)
stage_offset_um: tuple[float, float, float] = (0.0, 0.0, 0.0)
defocus_offset_um: float = 0.0
eucentric_height_um: float = 0.0
flip_x: bool = False
flip_y: bool = False
render_mode_override: RenderMode | None = None # None = Auto
#: The largest raster a TEM image is rendered at (pixels); a bigger frame is upsampled
#: from it (`OpticsState.raster_downsample` says by how much). 0 renders at the
#: frame's own sampling: what a CTF or MTF measurement needs, since an upsampled frame
#: carries its contrast transfer only up to the raster's Nyquist. The default keeps a
#: re-render (every stage move and focus step) well under a second on a 4096² camera.
max_raster_pixels: int = 1024 * 1024
# ---- illumination (C++: Illumination Semi-Angle TEM, SA Aperture, TEM Illuminated Area)
# 0 = derive from the beam spread (brightness conservation, see beam.py);
# >0 = fixed value (the C++ default was 0.1 mrad).
illumination_semi_angle_mrad: float = 0.0
sa_aperture_um: float = 10.0 # selected-area aperture projected to the specimen; 0 = none
tem_illuminated_area_um: float = 0.0 # >0 forces the illuminated diameter; 0 = from Intensity
stem_default_convergence_mrad: float = 10.0 # STEM alpha when the column reports 0
probe_d0_nm: float = 0.0 # >0 overrides the d0(probe mode, spot) table
# ---- dose model (see beam.py) --------------------------------
reference_current_pa: float = 600.0 # TEM-mode probe current at the reference spot
reference_spot: int = 3
spot_exponent: float = 2.0 # I ~ (ref/spot)^exponent (C++ law)
probe_mode_current_factor: dict = field(
default_factory=lambda: {
ProbeMode.TEM: 1.0,
ProbeMode.MICROPROBE: 1.0,
ProbeMode.EDS: 0.5,
ProbeMode.NANOPROBE: 0.05,
ProbeMode.NBD: 0.02,
ProbeMode.CBD: 0.1,
ProbeMode.UNKNOWN: 1.0,
}
)
stem_current_factor: float = 0.05 # STEM with a TEM/unknown probe mode behaves like nanoprobe
# Condenser aperture diameters (um) by 1-based index; current ~ (d/d_ref)^2.
condenser_apertures_um: tuple[float, ...] = (150.0, 70.0, 50.0, 10.0)
reference_condenser_index: int = 1
# Illuminated diameter vs Intensity (C2): D = D_min * (D_max/D_min)**intensity
illum_min_diameter_um: float = 0.1
illum_max_diameter_um: float = 100.0
# Brightness conservation: alpha_ill = ref_alpha * ref_D / D (when illumination_semi_angle_mrad == 0)
illum_reference_alpha_mrad: float = 0.1
illum_reference_diameter_um: float = 3.1623
nanoprobe_illuminated_um: float = 0.20 # C++ kNanoprobeIlluminatedAreaUm (TEM-mode nanoprobe)
lowmag_illumination_factor: float = 30.0 # LowMAG condenser setting spreads the beam this much more
#: Intensity zoom (TFS "Intensity Zoom"): in TEM imaging the condenser follows the
#: magnification, so the illuminated area keeps its size relative to the field of
#: view and the dose per detector pixel stays put as the operator zooms. Intensity
#: then sets the beam at `intensity_zoom_reference_mag` (and so at every mag). Off:
#: the dose per pixel goes as 1/mag^2 and zooming out saturates the camera.
intensity_zoom: bool = False
intensity_zoom_reference_mag: float = 20000.0
#: A real column's geometry (image rotation, true pixel size, image-shift matrices,
#: magnification offsets, backlash): what SerialEM's calibrations measure. None: ideal.
realism: ColumnRealism | None = None
[docs]
@classmethod
def realistic(cls, seed: int = 0, **overrides) -> OpticsConfig:
"""A column as imperfect as a real one (:mod:`de_twin.optics.realism`), reproducibly."""
from .realism import ColumnRealism
return cls(realism=ColumnRealism(seed=seed), **overrides)
# ---- aberrations / coherence (twin additions) --------------------------
cs_mm: float = 1.2
cc_mm: float = 1.4
energy_spread_ev: float = 0.7 # FWHM (Schottky FEG ~0.7, LaB6 ~1.5)
objective_aperture_mrad: float = 0.0 # 0 = no objective aperture
# Objective stigmator -> 2-fold astigmatism A1 (nm) = (stig - stig_zero) * nm_per_unit
stig_nm_per_unit: float = 1000.0
objective_stig_zero: tuple[float, float] = (0.0, 0.0)
# Condenser stigmator -> STEM probe astigmatism A1 (nm), same linear law. 1 unit = 1 um
# of A1: a coarse knob (a 20 mrad / 200 kV probe tolerates |A1| < lambda/(4 alpha^2) ~ 1.6 nm).
condenser_stig_nm_per_unit: float = 1000.0
condenser_stig_zero: tuple[float, float] = (0.0, 0.0)
# STEM partial spatial coherence: FWHM (nm) of the demagnified source image at the
# specimen at ``reference_spot``; it scales as reference_spot / spot (Schottky FEG:
# probe current ~ d_source^2 ~ 1/spot^2). 0 = spatially fully coherent probe.
stem_source_size_nm: float = 0.04