"""Magnification / camera-length calibration (DE-Server ``mag.yaml`` semantics).
DE-Server's ``Camera::ParseEmMetaData`` looks the reported magnification up in
the project (``Mode``/``SubMode``/``TemStemMode``) of ``configurations/mag.yaml``:
* TEM ``Mags``: nm per *unbinned* detector pixel (the table is camera specific);
the processed-frame value is ``table * hw_binning * sw_binning * TEM_Scale_Factor(HT)``.
* TEM ``CamLength(cm)``: 1/nm per unbinned pixel (x binning x TEM_Diff_Scale_Factor).
* STEM ``Mags``: nm per volt of scan; the step is ``table * (Vmax - Vmin) / max(nx, ny)
* STEM_Virt_Scale_Factor``.
* STEM ``CamLength(cm)``: mrad per unbinned pixel (x STEM_Scale_Factor).
The twin always works in *unbinned* detector pixels (the detector bins), so the
binning factor never appears here. :meth:`Calibration.default` builds
JEOL-like ladders computed geometrically from the camera's pixel pitch, so the
twin works without any file.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from ..column.ladders import (
DEFAULT_CAMERA_LENGTHS_MM,
DEFAULT_LOWMAG_MAGS,
DEFAULT_SAMAG_MAGS,
DEFAULT_STEM_MAGS,
MAG1_MAGS,
Ladder,
)
from ..state import MicroscopeState
from .physics import electron_wavelength_nm
def _alphanumeric_key(name: str) -> str:
return re.sub(r"[^a-z0-9]", "", str(name).lower())
# mag_mode string (column) -> normalised project-name candidates
_MODE_ALIASES = {
"mag1": ("mag1", "mag", "m", "sa"),
"mag": ("mag1", "mag", "m", "sa"),
"mag2": ("mag2", "mag1", "mag"),
"lowmag": ("lowmag", "lm", "low"),
"lm": ("lm", "lowmag", "low"),
"samag": ("samag", "sa", "mag1"),
"sa": ("sa", "samag", "mag1"),
"smag": ("smag", "mag1"),
"diff": ("diff", "diffmag", "d"),
"diffmag": ("diffmag", "diff", "d"),
}
@dataclass
class MagProject:
name: str
mode: int
sub_mode: int
tem_stem: int # 0 TEM, 1 STEM
mags: dict = field(default_factory=dict) # magnification -> value (see module doc)
cam_lengths_cm: dict = field(default_factory=dict) # CL [cm] -> value, or None = geometric
@property
def is_diffraction(self) -> bool:
return bool(self.cam_lengths_cm) and not self.mags
_MISSING = object()
def _lookup(table: dict, key: float, rel_tol: float):
"""Exact key, else nearest key within ``rel_tol`` (DE-Server LookupNearest).
Returns ``_MISSING`` when nothing matches (a stored ``None`` means "geometric").
"""
if not table or key is None or key <= 0:
return _MISSING
if key in table:
return table[key]
best = min(table, key=lambda k: abs(math.log(k / key)) if k > 0 else math.inf)
if best > 0 and abs(best - key) <= rel_tol * max(abs(key), 1e-12):
return table[best]
return _MISSING
[docs]
class Calibration:
"""Magnification -> nm/px and camera length -> 1/nm/px, per mag mode."""
[docs]
def __init__(
self,
projects: list[MagProject],
*,
reference_pixel_um: float | None = None,
ht_scale: dict | None = None,
tolerance: float = 0.005,
stem_scan_span_v: float = 1.0,
stem_fov_nm_at_1x: float = 1.28e8,
name: str = "custom",
):
self.projects = list(projects)
# Pixel pitch (um) the tables were measured with; None = use values as-is.
self.reference_pixel_um = reference_pixel_um
# {ht_kv: {"tem":, "tem_diff":, "stem":, "stem_virt":}}
self.ht_scale = ht_scale or {}
self.tolerance = tolerance
self.stem_scan_span_v = stem_scan_span_v
self.stem_fov_nm_at_1x = stem_fov_nm_at_1x # geometric STEM: FOV = this / mag
self.name = name
# ------------------------------------------------------------------ io
[docs]
@classmethod
def from_mag_yaml(
cls, path, *, reference_pixel_um: float | None = None, ht_table=None, tolerance: float = 0.005
) -> Calibration:
"""Load a DE-Server ``mag.yaml`` (tabs tolerated) and optionally an ``ht_table_*.yaml``."""
text = Path(path).read_text(encoding="utf-8", errors="replace").replace("\t", " ")
data = yaml.safe_load(text) or []
projects = []
for p in data:
if not isinstance(p, dict) or "Project" not in p:
continue
mags = {float(k): float(v) for k, v in (p.get("Mags") or {}).items()}
cl = {float(k): float(v) for k, v in (p.get("CamLength(cm)") or {}).items()}
if p.get("CamLengthInMM"):
cl = {k / 10.0: v for k, v in cl.items()}
projects.append(
MagProject(
str(p["Project"]),
int(p.get("Mode", -1)),
int(p.get("SubMode", -1)),
int(p.get("TemStemMode", 0)),
mags,
cl,
)
)
ht_scale = cls._load_ht_table(ht_table) if ht_table else None
return cls(
projects,
reference_pixel_um=reference_pixel_um,
ht_scale=ht_scale,
tolerance=tolerance,
name=Path(path).name,
)
@staticmethod
def _load_ht_table(path) -> dict:
text = Path(path).read_text(encoding="utf-8", errors="replace").replace("\t", " ")
data = yaml.safe_load(text) or {}
table = data.get("HT", data)
out = {}
for volts, row in (table or {}).items():
if not isinstance(row, dict):
continue
out[round(float(volts) / 1000.0, 3)] = {
"tem": float(row.get("TEM_Scale_Factor", 1.0)),
"tem_diff": float(row.get("TEM_Diff_Scale_Factor", 1.0)),
"stem": float(row.get("STEM_Scale_Factor", 1.0)),
"stem_virt": float(row.get("STEM_Virt_Scale_Factor", 1.0)),
}
return out
[docs]
@classmethod
def default(cls) -> Calibration:
"""Built-in JEOL-like ladders, computed geometrically.
Tables are nm per 1-um pixel (``1000 / mag``) and are scaled by the camera's pixel
pitch, so 20 kx on a 6.5 um pixel is 0.325 nm/px. Camera lengths are geometric
(``None`` entries). A camera behind a post-column magnification is
``OpticsConfig.post_magnification``: the calibration is what the column believes.
"""
def mags(ladder):
return {float(m): 1000.0 / m for m in ladder}
cl = {m / 10.0: None for m in DEFAULT_CAMERA_LENGTHS_MM}
projects = [
MagProject("MAG1", 1, 0, 0, mags(MAG1_MAGS)),
MagProject("MAG2", 1, 1, 0, mags(MAG1_MAGS)),
MagProject("LowMAG", 1, 2, 0, mags(DEFAULT_LOWMAG_MAGS)),
MagProject("SAMAG", 1, 3, 0, mags(DEFAULT_SAMAG_MAGS)),
MagProject("DIFF", 2, 4, 0, {}, dict(cl)),
MagProject("STEM", 1, 0, 1, {float(m): None for m in DEFAULT_STEM_MAGS}, dict(cl)),
]
return cls(projects, reference_pixel_um=1.0, name="default")
# ------------------------------------------------------------ projects
def _candidates(self, tem_stem: int, want_cl: bool):
out = []
for p in self.projects:
if p.tem_stem != tem_stem:
continue
if want_cl and p.cam_lengths_cm or not want_cl and p.mags:
out.append(p)
return out
[docs]
def project_for(self, state: MicroscopeState, want_cl: bool = False) -> MagProject | None:
tem_stem = int(state.tem_stem)
cands = self._candidates(tem_stem, want_cl)
if not cands:
return None
mode = _alphanumeric_key(state.mag_mode)
aliases = _MODE_ALIASES.get(mode, (mode,))
magnification = float(state.magnification)
for alias in aliases:
for p in cands:
if _alphanumeric_key(p.name) == alias and (
want_cl or _lookup(p.mags, magnification, self.tolerance) is not _MISSING
):
return p
# Diffraction: the TEM Diff project regardless of the imaging mode name
if want_cl:
if tem_stem == 0:
diff = [p for p in cands if p.is_diffraction] or cands
return diff[0]
return cands[0]
# Imaging: the first project whose ladder holds this magnification
for p in cands:
if _lookup(p.mags, float(state.magnification), self.tolerance) is not _MISSING:
return p
return cands[0]
def _ht(self, state: MicroscopeState, key: str) -> float:
if not self.ht_scale:
return 1.0
row = self.ht_scale.get(round(float(state.ht_kv), 3))
return float(row.get(key, 1.0)) if row else 1.0
def _pixel_scale(self, camera) -> float:
if self.reference_pixel_um and camera is not None and getattr(camera, "pixel_um", 0) > 0:
return float(camera.pixel_um) / float(self.reference_pixel_um)
return 1.0
# ------------------------------------------------------------- lookups
[docs]
def specimen_pixel_nm(self, state: MicroscopeState, camera) -> float:
"""nm of specimen per *unbinned* detector pixel (TEM imaging); 0.0 = not calibrated."""
p = self.project_for(state, want_cl=False) if int(state.tem_stem) == 0 else None
if p is None:
return 0.0
v = _lookup(p.mags, float(state.magnification), self.tolerance)
if v is _MISSING or v is None:
return 0.0
return float(v) * self._pixel_scale(camera) * self._ht(state, "tem")
[docs]
def recip_pixel_inv_nm(self, state: MicroscopeState, camera) -> float:
"""1/nm per unbinned detector pixel in diffraction; 0.0 = not calibrated.
Geometric entries use ``mrad/px = pixel_um / CL_mm`` and ``1/nm = mrad / (1000 lambda)``.
"""
cl_mm = float(state.camera_length_mm)
lam = electron_wavelength_nm(state.ht_kv)
p = self.project_for(state, want_cl=True)
if p is None or cl_mm <= 0:
return 0.0
v = _lookup(p.cam_lengths_cm, cl_mm / 10.0, self.tolerance)
if v is _MISSING or v is None:
return self.geometric_recip_inv_nm(state, camera)
if p.tem_stem == 1: # STEM table is mrad/px
return float(v) * self._pixel_scale(camera) * self._ht(state, "stem") / (1000.0 * lam)
return float(v) * self._pixel_scale(camera) * self._ht(state, "tem_diff")
[docs]
@staticmethod
def geometric_recip_inv_nm(state: MicroscopeState, camera, camera_length_mm: float = 0.0) -> float:
cl = camera_length_mm if camera_length_mm > 0 else float(state.camera_length_mm)
pix = float(getattr(camera, "pixel_um", 0.0) or 0.0)
if cl <= 0 or pix <= 0:
return 0.0
return (pix / cl) / (1000.0 * electron_wavelength_nm(state.ht_kv))
[docs]
def stem_step_nm(self, state: MicroscopeState, scan_size: tuple[int, int]) -> float:
"""nm per scan point for a (nx, ny) scan; 0.0 = not calibrated."""
n = max(1, max(int(scan_size[0]), int(scan_size[1])))
cands = self._candidates(1, want_cl=False)
if not cands:
return 0.0
mode = _alphanumeric_key(state.mag_mode)
magnification = float(state.magnification)
proj = next((p for p in cands if _alphanumeric_key(p.name) in _MODE_ALIASES.get(mode, (mode,))), None)
proj = proj or next(
(p for p in cands if _lookup(p.mags, magnification, self.tolerance) is not _MISSING), None
)
if proj is None:
return 0.0
v = _lookup(proj.mags, magnification, self.tolerance)
if v is _MISSING:
return 0.0
if v is None: # geometric ladder
return self.stem_fov_nm_at_1x / magnification / n
return float(v) * self.stem_scan_span_v / n * self._ht(state, "stem_virt")
# -------------------------------------------------------------- ladders
def _mode_project(self, mode: str | None, tem_stem: int = 0) -> MagProject | None:
cands = self._candidates(tem_stem, want_cl=False)
if not cands:
return None
if mode is None:
return cands[0]
m = _alphanumeric_key(mode)
for alias in _MODE_ALIASES.get(m, (m,)):
for p in cands:
if _alphanumeric_key(p.name) == alias:
return p
return None
[docs]
def mag_modes(self, tem_stem: int = 0) -> list[str]:
return [p.name for p in self._candidates(tem_stem, want_cl=False)]
[docs]
def mag_ladder(self, mode: str | None = "MAG1", tem_stem: int = 0) -> Ladder:
p = self._mode_project(mode, tem_stem)
return Ladder(sorted(p.mags)) if p else Ladder()
[docs]
def cl_ladder(self, tem_stem: int = 0) -> Ladder:
"""Camera lengths in **mm** (the twin's unit; mag.yaml stores cm)."""
cands = self._candidates(tem_stem, want_cl=True)
diff = [p for p in cands if p.is_diffraction] or cands
if not diff:
return Ladder()
return Ladder(sorted(round(k * 10.0, 6) for k in diff[0].cam_lengths_cm))
[docs]
def snap_mag(self, mode: str | None, magnification: float, tem_stem: int = 0) -> float:
return self.mag_ladder(mode, tem_stem).snap_logarithmic(magnification)
[docs]
def snap_cl(self, cl_mm: float, tem_stem: int = 0) -> float:
return self.cl_ladder(tem_stem).snap_logarithmic(cl_mm)
def __repr__(self) -> str:
return f"Calibration({self.name!r}, projects={[p.name for p in self.projects]})"