Skip to content

Commit c2cce89

Browse files
Baharisstefsmeets
andauthored
Generalize stage motion calibration to include stage translation (#150)
* Allow server cameras to be streamable by removing precaution checks * Unify interface, naming between get_microscope/camera(_class) * Remove unused _init_attr_dict/get_attrs from microscope client/server * Do not force server-side attributes to be callable, use them first: these 3 lines took ~3h * EAFP: Allow camera to call functions whether they are registered or not * EAFP: Allow camera to call unregistered functions - fixes server cameras * Remove unnecessary print debug statement * Add **streamable** description to `config.md` documentation * Encapsulate FastADT paths in separate prop/method * Add new TrackingArtist to be used with plotting multi-runs * Working multi-tracking FastADT frame! Still needs polish * Change `ClickEvent` to dataclass, implement `ClickEvent.xy` * Optimize clicking logic in FastADT experiment * Streamline the `calibrate_beamshift_live` function * Streamline `CalibBeamShift.plot` * Improvements to `CalibBeamShift` readability (WIP) * Add option to calibrate beamshift with vsp * Fix errors, add beam center * Switch calibration output format from pickle to yaml * Allow delay during calibrate beamshift * Add necessary reflections to fix plotting * Final tweaks * Make the yaml produced by CalibBeamShift human-readable * Update src/instamatic/calibrate/calibrate_beamshift.py Co-authored-by: Stef Smeets <[email protected]> * Update src/instamatic/calibrate/calibrate_beamshift.py Co-authored-by: Stef Smeets <[email protected]> * Minor post-review type-hint improvements + ruff * Add `instamatic.utils.iterating` with `sawtooth` iterating function * Make the `click_dispatcher:ClickEvent.xy` a property * Rephrase `VideoStreamProcessor.temporary` using `blocked` context * Fix the bug where canceling FastADT did not remove its elements * Fix the bug where colors of crystal tracking repeated after 10 * Remove debug message * Attempt to generalize collecting, revert as needed * Fix: rotation speed for negative target pace is negative, rounds to 0 * Rename tracking "mode" to "algo"; if continuous, track w/ movie * Generalize FastADT run collection (+fix resulting bugs) * Revert change: use stills for continuous tracking * Minor fixes and code quality improvements * Clean, remove unused code * Fix tracking failing for beam not in the center at alignment * Fix Run.__str__, clean up code, method, call order * Log all behavior in two separate message windows. * Fix ignore msg1,2 variables in headless FastADT experiment * Update in tests `tracking_mode` -> `tracking_algo` * Add estimated time required dialog in FastADT message 2 * Display which experiment is being collected in multi-expt * Display which experiment is being collected in multi-expt * Trace variables only after everything was defined to avoid Exceptions * Fix: leaving input empty even temporarily caused exception * Fix: do not define ExperimentalFastADT.q to receive it from parent * Fix: Add LiveVideoStream, FakeVideoStream to camera init to fix instamatic.camera script * Fix: properly defined VideoStreamFrame.app as "advertised" * Move `get_frame/image` from jobs to VideoStreanFrame: to fix it/avoid crashes * Prevent repeated calls to alpha_wobble from crashing GUI * Overwriting prevention mechanism was not needed * Do not overwrite `self.wobble_stop_event` if not putting in queue * FastADT: Save tracking images to tracking/ directory * Remove development imports from videostream_processor.py * Some initial code to be used for stage translation calibration * Encapsulate, Test, Generalize `FloatOptions` to `NumericDomain` * Realize calib translation and rotation are basically identical: TODO generalize * Refactor translation/rotation into two * Add documentation and hooks, fix interface inconsistency. TODO test * Split x, y, z axis into separate speeds to be calibrated, improve program args * Fix and vastly improve plotting compared to previous versions * Works fine! Added better display, docstring. * Fixed all mypy warnings but imports + few * Import Self from typing extensions, not typing * Fix stage rotation test * Python typing is truly a somewhat enchanting Lovecraftian nightmare * Sorry mypy, Span and SpeedN can't be bound by Generic in NamedTuple in python 3.9 & 3.10 * Update typos in docs & docstring * Fix tecnai bug: set with speed received numpy.floating speed * Move `native` to a separate file in utils * Add a simple test for new `instamatic.utils.native` * Add more details, otherwise more difficult to reach, to pets input * Add some more examples to the pets prefix --------- Co-authored-by: Stef Smeets <[email protected]>
1 parent 6ad085c commit c2cce89

File tree

14 files changed

+730
-259
lines changed

14 files changed

+730
-259
lines changed

docs/config.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,9 +256,21 @@ This file holds the specifications of the camera. This file is must be located t
256256
```
257257

258258
**pets_prefix**
259-
: Arbitrary information to be added at the beginning of the `.pts` file created after an experiment. The prefix can include any [valid PETS2 input lines](http://pets.fzu.cz/download/pets2_manual.pdf). In the case of duplicate commands, prefix lines take precedence over hard-coded and suffix commands, and prevent the latter ones from being added. Additionally, this field can contain new python-style [replacement fields](https://pyformat.info/) which, if present among the `ImgConversion` instance attributes, will be filled automatically after each experiment (see the `pets_suffix` example). A typical `pets_prefix`, capable of overwriting the default detector specification output can look like this:
259+
: Arbitrary information to be added at the beginning of the `.pts` file created after an experiment. The prefix can include any [valid PETS2 input lines](http://pets.fzu.cz/download/pets2_manual.pdf). In the case of duplicate commands, prefix lines take precedence over hard-coded and suffix commands, and prevent the latter ones from being added. Additionally, this field can contain new python-style [replacement fields](https://pyformat.info/) which, if present among the `ImgConversion` instance attributes, will be filled automatically after each experiment. A typical `pets_prefix`, capable of overwriting the default detector specification output can look like this:
260260
```yaml
261-
pets_prefix: "noiseparameters 4.2 0\nreflectionsize 8\ndetector asi"
261+
pets_prefix: |
262+
263+
# MEASUREMENT CONDITIONS:
264+
# Start angle: {start_angle:6.2f} deg
265+
# End angle: {end_angle:6.2f} deg
266+
# Step size: {osc_angle:6.2f} deg
267+
# Exposure: {acquisition_time:6.3f} s
268+
# Wave length: {wavelength:15.8g} A
269+
# Camera distance: {distance:15.8g} mm
270+
271+
noiseparameters 4.2 0
272+
reflectionsize 8
273+
detector asi
262274
```
263275

264276
**pets_suffix**

docs/programs.md

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ These tools help calibrate instamatic for some experiments.
2828
- [instamatic.calibrate_movie_delays](#instamaticcalibrate_movie_delays) (`instamatic.calibrate.calibrate_movie_delays:main_entry`)
2929
- [instamatic.flatfield](#instamaticflatfield) (`instamatic.processing.flatfield:main_entry`)
3030
- [instamatic.stretch_correction](#instamaticstretch_correction) (`instamatic.processing.stretch_correction:main_entry`)
31-
- [instamatic.calibrate_stage_rotation](#instamaticcalibrate_stage_rotation) (`instamatic.calibrate.calibrate_stage_lowmag:main_entry`)
31+
- [instamatic.calibrate_stage_rotation](#instamaticcalibrate_stage_rotation) (`instamatic.calibrate.calibrate_stage_rotation:main_entry`)
32+
- [instamatic.calibrate_stage_translation](#instamaticcalibrate_stage_translation) (`instamatic.calibrate.calibrate_stage_translation:main_entry`)
3233

3334

3435
**Tools**
@@ -180,23 +181,53 @@ instamatic.calibrate_stage_lowmag [-h] [IMG [IMG ...]]
180181
181182
## instamatic.calibrate_stage_rotation
182183
183-
Program to calibrate and plan the rotation pace (in seconds / angle) against speed settings available on the microscope.
184+
Program to calibrate and plan the stage alpha rotation pace (in seconds / angle) against speed settings available on the microscope.
184185
185186
**Usage:**
186187
```bash
187-
instamatic.calibrate_stage_rotation [-h] [-a ALPHAS] [-s SPEEDS] [-m MODE] [-o OUTDIR]
188+
instamatic.calibrate_stage_rotation [-h] [-x SPANS] [-s SPEEDS] [-m MODE] [-o OUTDIR]
188189
```
189190
190191
**Optional arguments:**
191192
192193
`-h`, `--help`
193194
: Show this help message and exit
194195
195-
`-a ALPHAS`, `--alphas ALPHAS`
196-
: Comma-delimited list of alpha spans to calibrate (default: "1,2,3,4,5,6,7,8,9,10").
196+
`-x SPANS`, `--spans SPANS`
197+
: Space-delimited list of alpha spans to calibrate (default: "1 2 3 4 5 6 7 8 9 10").
197198
198199
`-s SPEEDS`, `--speeds SPEEDS`
199-
: Comma-delimited list of speed settings to calibrate (default: "0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1.0" or "1,2,3,4,5,6,7,8,9,10,11,12", whichever is accepted by the microscope).
200+
: Space-delimited list of speed settings to calibrate (default: "0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0" or "1 2 3 4 5 6 7 8 9 10 11 12", whichever is accepted by the microscope).
201+
202+
`-m MODE`, `--mode MODE`
203+
: Calibration mode to be used: "auto" - auto-determine upper and lower speed limits based on TEM response (default); "limited" - restrict TEM goniometer speed limits between min and max of --speeds; or "listed" - restrict TEM goniometer speed settings exactly to --speeds provided.
204+
205+
`-o OUTDIR`, `--outdir OUTDIR`
206+
: Path to the directory where calibration file should be output (default: "%appdata%/calib" on Windows or "$AppData/calib" on Unix).
207+
208+
209+
## instamatic.calibrate_stage_translation
210+
211+
Program to calibrate and plan the stage translation pace (in seconds / nm) along given axis x/y/z against speed settings available on the microscope.
212+
213+
**Usage:**
214+
```bash
215+
instamatic.calibrate_stage_translation [-h] [-x SPANS] [-s SPEEDS] [-a AXIS] [-m MODE] [-o OUTDIR]
216+
```
217+
218+
**Optional arguments:**
219+
220+
`-h`, `--help`
221+
: Show this help message and exit
222+
223+
`-x SPANS`, `--spans SPANS`
224+
: Space-delimited list of axis spans to calibrate (default: "10000 20000 30000 40000 50000 60000 70000 80000 90000 100000").
225+
226+
`-s SPEEDS`, `--speeds SPEEDS`
227+
: Space-delimited list of speed settings to calibrate (default: "0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0").
228+
229+
`-a AXIX`, `--axis AXIS`
230+
: Axis whose translation speed should be calibrated. Possible choices: 'x', 'y', or 'z'. Default 'x'.
200231
201232
`-m MODE`, `--mode MODE`
202233
: Calibration mode to be used: "auto" - auto-determine upper and lower speed limits based on TEM response (default); "limited" - restrict TEM goniometer speed limits between min and max of --speeds; or "listed" - restrict TEM goniometer speed settings exactly to --speeds provided.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ publishing = [
109109
"instamatic.flatfield" = "instamatic.processing.flatfield:main_entry"
110110
"instamatic.stretch_correction" = "instamatic.processing.stretch_correction:main_entry"
111111
"instamatic.calibrate_stage_rotation" = "instamatic.calibrate.calibrate_stage_rotation:main_entry"
112+
"instamatic.calibrate_stage_translation" = "instamatic.calibrate.calibrate_stage_translation:main_entry"
112113
# tools
113114
"instamatic.browser" = "scripts.browser:main"
114115
"instamatic.viewer" = "scripts.viewer:main"

src/instamatic/calibrate/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
from .calibrate_movie_delays import CalibMovieDelays
77
from .calibrate_stage_lowmag import CalibStage
88
from .calibrate_stage_rotation import CalibStageRotation
9+
from .calibrate_stage_translation import CalibStageTranslation
910

1011
# from .calibrate_stage_mag1 import CalibStageMag1
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from abc import ABC
5+
from dataclasses import asdict, dataclass
6+
from pathlib import Path
7+
from typing import ClassVar, Generic, NamedTuple, Optional, Sequence, TypeVar, Union
8+
9+
import matplotlib.pyplot as plt
10+
import numpy as np
11+
import yaml
12+
from matplotlib.lines import Line2D
13+
from scipy.optimize import curve_fit
14+
from typing_extensions import Self
15+
16+
from instamatic._typing import AnyPath, float_deg, int_nm
17+
from instamatic.config import calibration_drc
18+
from instamatic.utils.domains import NumericDomain, NumericDomainConstrained
19+
20+
logger = logging.getLogger(__name__)
21+
22+
23+
def log(s: str) -> None:
24+
logger.info(s)
25+
print(s)
26+
27+
28+
Span = TypeVar('Span', float_deg, int_nm)
29+
SpeedN = TypeVar('SpeedN', float, int)
30+
Speed = Optional[SpeedN]
31+
32+
33+
@dataclass
34+
class MotionPlan(Generic[SpeedN]):
35+
"""A set of motion parameters/outcomes nearest to the ones requested."""
36+
37+
pace: float # time it takes goniometer to cover 1 span unit (nm or degree)
38+
speed: Optional[SpeedN] # speed setting to get pace, None = not supported
39+
total_delay: float # total goniometer delay: delay + windup / speed
40+
41+
42+
class SpanSpeedTime(NamedTuple):
43+
"""A single measurement point used to calibrate stage motion speed.
44+
45+
- span: the motion span expressed in degrees (rotation) or nm (translation);
46+
- speed: the speed setting used expressed in arbitrary TEM units;
47+
- time: time taken to travel span with speed expressed in seconds.
48+
"""
49+
50+
span: Span
51+
speed: Speed
52+
time: float
53+
54+
55+
@dataclass
56+
class CalibStageMotion(ABC):
57+
"""Abstract base class for stage-motion calibration.
58+
59+
Stores three parameters (pace, windup, delay) and optional speed_options.
60+
The `time` it takes the stage to move some `span` with `speed`
61+
is calculated using the following formula:
62+
63+
time = (alpha_pace * alpha_span + alpha_windup) / speed + delay
64+
65+
The two variables and three coefficients used above represent the following:
66+
67+
- span: total translation/rotation distance for stage to cover in degrees;
68+
- speed: goniometer speed setting linearly related to real speed, unit-less;
69+
- pace: time needed to cover 1 distance unit at speed=1 in dist unit / deg;
70+
- windup: variable time delay for stage speed up or slow down in seconds;
71+
- delay: constant time needed for stage communication in seconds;
72+
73+
The calibration also accepts `NumericDomain`-type `speed_options` to account
74+
for the fact that different goniometers accept different speed settings.
75+
`CalibStageRotation.speed_options.nearest(requested)` is used to find the
76+
speed setting nearest to the one requested. Any mention of `speed=None`
77+
signals that the microscope does not allow control over this motion speed.
78+
79+
Concrete subclasses should provide:
80+
- units and axis labels for plotting
81+
- a `live` class method that runs live calibration and returns an instance
82+
"""
83+
84+
pace: float
85+
windup: float
86+
delay: float
87+
speed_options: Optional[NumericDomain] = None
88+
89+
def __post_init__(self) -> None:
90+
self.pace = float(self.pace)
91+
self.windup = float(self.windup)
92+
self.delay = float(self.delay)
93+
if isinstance(self.speed_options, dict):
94+
self.speed_options = NumericDomain(**self.speed_options)
95+
96+
_program_name: ClassVar[str] = NotImplemented
97+
_span_typical_limits: ClassVar[tuple[float, float]] = NotImplemented
98+
_span_units: ClassVar[str] = NotImplemented
99+
_yaml_filename: ClassVar[str] = NotImplemented
100+
101+
@staticmethod
102+
def model1(
103+
span_speed: tuple[Span, SpeedN],
104+
pace: float,
105+
windup: float,
106+
delay: float,
107+
) -> float:
108+
"""Model 1 for estimating the total motion time for scipy curve_fit."""
109+
span, speed = span_speed
110+
return (pace * span + windup) / speed + delay
111+
112+
@staticmethod
113+
def model2(span: Span, pace: float, delay: float):
114+
"""Simplified model used when speed is not supported i.e. = None."""
115+
return pace * span + delay
116+
117+
def span_speed_to_time(self, span: Span, speed: Speed = None) -> float:
118+
"""`time` needed to move stage by `span` with `speed`."""
119+
return (self.pace * span + self.windup) / (speed or 1.0) + self.delay
120+
121+
def span_time_to_speed(self, span: Span, time: float) -> float:
122+
"""`speed` that allows to move stage by `span` with `speed`."""
123+
return (self.pace * span + self.windup) / (time - self.delay)
124+
125+
def time_speed_to_span(self, time: float, speed: Speed = None) -> float:
126+
"""Maximum `span` covered with `speed` in `time` (including delay)."""
127+
return ((speed or 1.0) * (time - self.delay) - self.windup) / self.pace
128+
129+
def plan_motion(self, target_pace: float) -> MotionPlan:
130+
"""Given target pace, find nearest pace, needed speed, and delay."""
131+
if self.speed_options is None:
132+
return MotionPlan(self.pace, None, self.windup + self.delay)
133+
target_speed: float = abs(self.pace / target_pace)
134+
nearest_speed: Union[float, int] = self.speed_options.nearest(target_speed)
135+
nearest_pace: float = self.pace / nearest_speed
136+
total_delay: float = self.windup / nearest_speed + self.delay
137+
return MotionPlan(nearest_pace, nearest_speed, total_delay)
138+
139+
def plot(self, sst: Optional[Sequence[SpanSpeedTime]] = None) -> None:
140+
"""Generic plot of experimental (if given) & fit motion speed data."""
141+
142+
# determine speeds to plot; use experimental if given, fabricate otherwise
143+
speeds: list[Speed] # sorted
144+
if sst is not None:
145+
speeds = sorted(dict.fromkeys(s.speed for s in sst).keys())
146+
elif self.speed_options is None:
147+
speeds = [None]
148+
elif isinstance(self.speed_options, NumericDomainConstrained):
149+
so: NumericDomainConstrained = self.speed_options
150+
speeds = list(np.linspace(so.lower_lim, so.upper_lim, 10))
151+
else: # isinstance(calib.speed_options, NumericDomainDiscrete):
152+
speeds = sorted(getattr(self.speed_options, 'options', [1.0]))
153+
speeds = [s for s in speeds if s != 0]
154+
155+
# determine spans to plot; use experimental if given, fabricate otherwise
156+
spans: list[float] # sorted
157+
if sst is not None:
158+
spans = list(dict.fromkeys(s.span for s in sst).keys())
159+
else:
160+
spans = list(np.linspace(*self._span_typical_limits, 10))
161+
162+
# generate simulated span/speed/times data to be drawn later as lines
163+
simulated_sst = []
164+
for span in spans:
165+
for speed in speeds:
166+
t = self.span_speed_to_time(span, speed)
167+
simulated_sst.append(SpanSpeedTime(span, speed, t))
168+
plotted: list[tuple[Sequence[SpanSpeedTime], str]] = [(simulated_sst, '-')]
169+
170+
# generate experimental span/speed/times to be drawn later as points
171+
if sst is not None:
172+
plotted.append((sst, 'o'))
173+
174+
fig, ax = plt.subplots()
175+
ax.axvline(x=0, color='k')
176+
ax.axhline(y=0, color='k')
177+
ax.axhline(y=self.delay, color='r')
178+
179+
colors = plt.colormaps['coolwarm'](np.linspace(0, 1, num=len(speeds)))
180+
handles: list[Line2D] = []
181+
for color, speed in zip(colors, speeds):
182+
for sst, fmt in plotted:
183+
spans = [s.span for s in sst if s.speed == speed]
184+
times = [s.time for s in sst if s.speed == speed]
185+
ax.plot(spans, times, fmt, color=color)
186+
label = f'Speed setting {speed:.2f}'
187+
handles.append(Line2D([], [], color=color, marker='o', label=label))
188+
189+
ax.set_xlabel(f'Motion span [{self._span_units}]')
190+
ax.set_ylabel('Time required [s]')
191+
ax.set_title('Stage motion time vs span at different speeds')
192+
ax.legend(handles=handles, loc='best')
193+
plt.show()
194+
195+
@classmethod
196+
def from_data(cls, sst: Sequence[SpanSpeedTime]) -> Self:
197+
"""Fit cls.model to span-speed-time points and init based on result."""
198+
sst_array = np.array(sst).T
199+
spans = np.array(sst_array[0], dtype=float)
200+
speeds = np.array(sst_array[1], dtype=float)
201+
times = np.array(sst_array[2], dtype=float)
202+
203+
if np.all(np.isnan(speeds)): # TEM does not support setting with speed
204+
p = curve_fit(CalibStageMotion.model2, spans, ydata=times, p0=[1, 0])
205+
(pace_n, delay_n), p_cov = p # noqa - this unpacking is OK
206+
pace_u, delay_u = np.sqrt(np.diag(p_cov))
207+
windup_n, windup_u = 0.0, 0.0
208+
else:
209+
ss = sst_array[:2]
210+
p = curve_fit(CalibStageMotion.model1, ss, ydata=times, p0=[1, 0, 0])
211+
(pace_n, windup_n, delay_n), p_cov = p # noqa - this unpacking is OK
212+
pace_u, windup_u, delay_u = np.sqrt(np.diag(p_cov))
213+
214+
log(f'{cls.__name__} fit of motion model complete:')
215+
log(f'pace = {pace_n:12.6g} +/- {pace_u:12.6g} s / {cls._span_units}')
216+
log(f'windup = {windup_n:12.6f} +/- {windup_u:12.6f} s')
217+
log(f'delay = {delay_n:12.6f} +/- {delay_u:12.6f} s')
218+
log('model time = (pace * span + windup) / speed + delay')
219+
220+
return cls(pace_n, windup_n, delay_n)
221+
222+
@classmethod
223+
def from_file(cls, path: Optional[AnyPath] = None) -> Self:
224+
if path is None:
225+
path = Path(calibration_drc) / cls._yaml_filename
226+
try:
227+
with open(Path(path), 'r') as yaml_file:
228+
return cls(**yaml.safe_load(yaml_file))
229+
except OSError as e:
230+
raise OSError(f'{e.strerror}: {path}. Please run {cls._program_name} first.')
231+
232+
def to_file(self, outdir: Optional[AnyPath] = None) -> None:
233+
if outdir is None:
234+
outdir = calibration_drc
235+
yaml_path = Path(outdir) / self._yaml_filename
236+
with open(yaml_path, 'w') as yaml_file:
237+
yaml.safe_dump(asdict(self), yaml_file) # type: ignore[arg-type]
238+
log(f'{self.__class__.__name__} saved to {yaml_path}.')

0 commit comments

Comments
 (0)