diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py index 1b07dfa1..fa325343 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py @@ -18,6 +18,11 @@ import numpy as np from bec_lib import bec_logger from bec_lib.alarm_handler import AlarmBase +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import ( + filters_out_of_beam, + warn_and_confirm, +) + from .lamni import LamNI logger = bec_logger.logger @@ -56,7 +61,7 @@ class MagLamNI(LamNI): lamni_at_each_angle(self, angle) return - self.tomo_scan_projection(angle) + self.tomo_scan_projection(angle, _internal=True) self.tomo_reconstruct() @@ -87,6 +92,14 @@ class DataDrivenLamNI(LamNI): """ bec = builtins.__dict__.get("bec") scans = builtins.__dict__.get("scans") + dev = builtins.__dict__.get("dev") + + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=True, force=False): + print("Aborting tomo scan.") + return bec.builtin_actors.scan_interlock.trigger_setting = "restart_scan" bec.builtin_actors.scan_interlock.enabled = True diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 7a88df3e..d620cbf8 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -14,6 +14,10 @@ from typeguard import typechecked from csaxs_bec.bec_ipython_client.plugins.LamNI.gui_tools import LamniGuiTools from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_alignment_mixin import LamNIAlignmentMixin +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import ( + filters_out_of_beam, + warn_and_confirm, +) from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import ( OMNYTools, PtychoReconstructor, @@ -224,7 +228,6 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self.tomo_id_manager = TomoIDManager() self.OMNYTools = OMNYTools(self.client) - self.tomo_id = -1 self.special_angles = [] self.special_angle_repeats = 20 self.special_angle_tolerance = 20 @@ -1159,9 +1162,33 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools # Scan projection # ------------------------------------------------------------------ - def tomo_scan_projection(self, angle: float): + def tomo_scan_projection(self, angle: float, _internal: bool = False): + """Acquire one fermat-scan projection at `angle`. + + _internal=True is passed by callers that already sit inside an + already-checked flow (tomo_scan()'s/tomo_alignment_scan()'s own + per-angle loop, via _at_each_angle) so the filter-out-of-beam check + below only runs once per tomogram/alignment run instead of once per + projection. Direct/standalone calls (the default) are checked every + time, mirroring Flomni.tomo_scan_projection()'s _internal convention. + """ scans = builtins.__dict__.get("scans") + if not _internal: + dev = builtins.__dict__.get("dev") + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=True, force=False): + # Raise rather than return: this can run inside a + # per-angle loop (directly, or via a custom + # at_each_angle hook), and a bare return would leave the + # projection silently missing / the job stuck running. + raise LamNIError( + "tomo_scan_projection: declined to proceed with filter(s) " + f"in the beam ({', '.join(offending)})." + ) + additional_correction = self.compute_additional_correction(angle) additional_correction_2 = self.compute_additional_correction_2(angle) correction_xeye_mu = self.lamni_compute_additional_correction_xeye_mu(angle) @@ -1262,6 +1289,13 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools bec = builtins.__dict__.get("bec") dev = builtins.__dict__.get("dev") + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=True, force=force): + print("Aborting alignment scan.") + return + self.leye_out() self.write_alignment_scan_numbers(bec.queue.next_scan_number) @@ -1281,7 +1315,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools while not successful: try: start_scan_number = bec.queue.next_scan_number - self.tomo_scan_projection(angle) + self.tomo_scan_projection(angle, _internal=True) except AlarmBase as exc: if exc.alarm_type == "TimeoutError": bec.queue.request_queue_reset() @@ -1333,7 +1367,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools hook(self, angle) return - self.tomo_scan_projection(angle) + self.tomo_scan_projection(angle, _internal=True) self.tomo_reconstruct() # ------------------------------------------------------------------ @@ -1667,6 +1701,14 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools ) time.sleep(10) + dev = builtins.__dict__.get("dev") + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=interactive, force=force): + print("Aborting tomo scan.") + return + self.lamnigui_show_progress() bec = builtins.__dict__.get("bec") diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py index fe041318..e886ac54 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py @@ -92,6 +92,9 @@ _HISTORY_MAX = 50 # events persisted per stream _HISTORY_SHOW = 5 # events surfaced to the page per stream _TRACE_MAX = 288 # ring-current samples (24 h at 5 min) _FLAP_COALESCE_S = 60 # re-alarm within this window extends the event +_ALARM_TEXTS_MAX = 100 # distinct alarm texts kept on a single open eps_event; + # only one event is ever open at a time, so this bounds + # total history growth during a long-running flapping alarm _SEVERITY_NAMES = {0: "NO_ALARM", 1: "MINOR", 2: "MAJOR", 3: "INVALID"} @@ -235,7 +238,9 @@ class HistoryTracker: if open_ev is not None: open_ev["count"] = max(open_ev.get("count", 0), count) if text and text not in open_ev.get("texts", []): - open_ev.setdefault("texts", []).append(text) + texts = open_ev.setdefault("texts", []) + texts.append(text) + del texts[:-_ALARM_TEXTS_MAX] return # coalesce a rapid re-alarm with identical text if self.eps_events: diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py new file mode 100644 index 00000000..1503433a --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py @@ -0,0 +1,46 @@ +import time + +from csaxs_bec.bec_ipython_client.plugins.cSAXS.filter_transmission import cSAXSFilterTransmission + + +def filters_out_of_beam(dev, tol: float = 0.1) -> tuple[bool, list[str]]: + """Check whether all cSAXS exposure-box filters are retracted. + + Reuses cSAXSFilterTransmission's own axis list/position table (index 0 of + each row is the "out" position) so there is one source of truth for what + "out" means, matching csaxs.fil_trans()/_fil_trans_report()'s own check. + """ + offending = [] + for axis_name, positions in zip( + cSAXSFilterTransmission._AXES, cSAXSFilterTransmission._POSITIONS_USER + ): + axis_obj = getattr(dev, axis_name, None) + out_position = positions[0] + if axis_obj is None or out_position is None: + continue + try: + rb = float(axis_obj.readback.get()) + except Exception: + continue + if abs(rb - out_position) > tol: + offending.append(axis_name) + return not offending, offending + + +def warn_and_confirm(setup, warning: str, interactive: bool = True, force: bool = False) -> bool: + """Eye/optics-check-style warn-and-confirm gate, reusable across plugins. + + Returns True if the caller should proceed. Interactive: red warning + a + "Continue anyway?" prompt defaulting to no. Non-interactive (queued/ + unattended): warns, waits 10s, and always proceeds -- matches + LamNI.tomo_scan()'s existing eye/optics gate so queued jobs never hang on + input(). + """ + if force: + return True + setup.OMNYTools.printredbold(f"WARNING: {warning}") + if interactive: + return setup.OMNYTools.yesno("Continue anyway?", "n") + setup.OMNYTools.printredbold("Proceeding automatically in 10 s (unattended/queued run)...") + time.sleep(10) + return True diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py index 218105fa..3959efb5 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py @@ -327,12 +327,11 @@ class TomoIDManager: """Registers a tomography measurement in the OMNY sample database and returns its assigned tomo ID. - Non-production accounts (e.g. test accounts like "gac-x01dc") register - against the test server (TEST_OMNY_URL) instead of production, so - testing still gets a real, incrementing tomo ID -- matching the - counter the samples-folder PDF upload reads from on that same test - host -- accepting that the eaccount recorded in that test database - won't be a real e-account. + Non-production accounts (e.g. test accounts like "gac-x01dc") are not + registered against OMNY_URL at all -- there is no longer a separate + test server to redirect them to, and registering test runs against + the production sample database would pollute it. They get + FALLBACK_TOMO_ID instead. Usage: id_manager = TomoIDManager() @@ -348,7 +347,6 @@ class TomoIDManager: """ OMNY_URL = "https://omny.psi.ch/samples/newmeasurement.php" - TEST_OMNY_URL = "https://omny.psi.ch/samples/newmeasurement.php" FALLBACK_TOMO_ID = 0 @staticmethod @@ -368,20 +366,21 @@ class TomoIDManager: ) -> int: """Register a new measurement and return the assigned tomo ID. - Registers against OMNY_URL (production) for a real e-account, or - TEST_OMNY_URL (test server) otherwise. Returns FALLBACK_TOMO_ID (0) - only if the server actually can't be reached / returns an unusable - response. + Registers against OMNY_URL for a real e-account. For any other + account (e.g. a test/"gac-*" account), skips registration entirely + and returns FALLBACK_TOMO_ID (0) -- there is no separate test + server to register against, and registering test runs against + production would pollute the real sample database. FALLBACK_TOMO_ID + is also returned if the server actually can't be reached / returns + an unusable response. """ - if self._is_valid_eaccount(eaccount): - omny_url = self.OMNY_URL - else: - omny_url = self.TEST_OMNY_URL + if not self._is_valid_eaccount(eaccount): logger.warning( - f"Account '{eaccount}' is not a valid e-account; registering " - f"against the test server ({self.TEST_OMNY_URL}) instead of " - "production -- the eaccount recorded there won't be real." + f"Account '{eaccount}' is not a valid e-account; skipping OMNY " + f"registration (no separate test server to register against) " + f"and falling back to tomo ID {self.FALLBACK_TOMO_ID}." ) + return self.FALLBACK_TOMO_ID params = { "sample": sample_name, @@ -407,7 +406,7 @@ class TomoIDManager: try: response = requests.get( - omny_url, + self.OMNY_URL, params=params, timeout=30, verify=False, # accept self-signed certs diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py index 927c546a..f5af08ef 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py @@ -213,6 +213,7 @@ class TomoQueueMixin: # Name of a registered at_each_angle hook (see register_at_each_angle_hook()), # or None for the default per-projection behaviour. at_each_angle_hook = _GlobalVarParam(None) + tomo_id = _GlobalVarParam(-1, type_=int) def _init_tomo_queue(self) -> None: """Call once from ``__init__``, after ``self.client`` is set. @@ -392,9 +393,8 @@ class TomoQueueMixin: user=user, ) - # Must match whatever host TomoIDManager.OMNY_URL/TEST_OMNY_URL - # registered against, since that host's sample counter is what - # self.tomo_id came from. + # Must match whatever host TomoIDManager.OMNY_URL registered against, + # since that host's sample counter is what self.tomo_id came from. _SAMPLES_UPLOAD_HOSTS = ("https://omny.psi.ch",) def _upload_pdf_report_to_samples(self, pdf_path: str) -> None: diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/cSAXS_HR August 2018_chip_maxime.pdf b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/cSAXS_HR August 2018_chip_maxime.pdf new file mode 100755 index 00000000..39a3e6bd Binary files /dev/null and b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/cSAXS_HR August 2018_chip_maxime.pdf differ diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 91a603ba..d013a68f 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -19,6 +19,10 @@ from csaxs_bec.bec_ipython_client.plugins.cSAXS import cSAXSBeamlineChecks from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin import FlomniOpticsMixin from csaxs_bec.bec_ipython_client.plugins.flomni.gui_tools import flomniGuiTools from csaxs_bec.bec_ipython_client.plugins.flomni.x_ray_eye_align import XrayEyeAlign +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import ( + filters_out_of_beam, + warn_and_confirm, +) from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import ( TomoQueueMixin, _GlobalVarParam, @@ -1044,13 +1048,13 @@ class FlomniSampleTransferMixin: ): packed_name = pack_desc(name, owner) if position == 100: - dev.flomni_samples.sample_in_gripper.set(used) - dev.flomni_samples.sample_in_gripper_name.set(packed_name) + dev.flomni_samples.sample_in_gripper.set(used).wait(timeout=5) + dev.flomni_samples.sample_in_gripper_name.set(packed_name).wait(timeout=5) else: signal = getattr(dev.flomni_samples.sample_placed, f"sample{position}") - signal.set(used) + signal.set(used).wait(timeout=5) signal_name = getattr(dev.flomni_samples.sample_names, f"sample{position}") - signal_name.set(packed_name) + signal_name.set(packed_name).wait(timeout=5) def check_position_is_valid(self, position: int): if 0 <= position < 21: @@ -1760,7 +1764,6 @@ class Flomni( super().__init__() self.client = client self.device_manager = client.device_manager - self.tomo_id = -1 self.special_angles = [] self.special_angle_repeats = 20 self.special_angle_tolerance = 20 @@ -2197,6 +2200,13 @@ class Flomni( dev = builtins.__dict__.get("dev") bec = builtins.__dict__.get("bec") + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=True, force=False): + print("Aborting tomo alignment scan.") + return + # Only run the full eye-out / optics-in transition if we are not # already in measurement condition with feedback running. That # transition disables and re-enables-with-reset the rt feedback, which @@ -2637,8 +2647,10 @@ class Flomni( Args: interactive: accepted for signature compatibility with LamNI.tomo_scan() (tomo_queue_execute() calls both the same - way) -- unused here, FlOMNI has no fine-alignment gate. + way). FlOMNI has no fine-alignment gate, but it is used by + the filter-out-of-beam check below. """ + dev = builtins.__dict__.get("dev") if not self._check_eye_out_and_optics_in(): print( @@ -2650,6 +2662,13 @@ class Flomni( print("Stopping.") return + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=interactive, force=False): + print("Aborting tomo scan.") + return + self.flomnigui_show_progress() bec = builtins.__dict__.get("bec") @@ -3099,12 +3118,27 @@ class Flomni( random_offset_x: float | None = None, random_offset_y: float | None = None, ): - """write the tomo reconstruct file for the reconstruction queue""" + """Write the tomo reconstruct file for the reconstruction queue. + + Normally called automatically at the end of tomo_scan_projection()/ + tomo_acquire_at_angle(), which keep self._current_scan_list up to + date with the scan number(s) of the projection just acquired + (possibly several, when stitching). When called directly -- e.g. + from the command line after a plain scans.flomni_fermat_scan(), + without going through either of those -- that cached list is either + stale (left over from an earlier tomo scan) or not set at all, so + fall back to just the most recently completed scan number. + """ bec = builtins.__dict__.get("bec") + next_scan_number = bec.queue.next_scan_number + last_scan_number = next_scan_number - 1 + scan_list = getattr(self, "_current_scan_list", None) + if not scan_list or scan_list[-1] != last_scan_number: + scan_list = [last_scan_number] self.reconstructor.folder_name = self.ptycho_reconstruct_foldername self.reconstructor.write( - scan_list=self._current_scan_list, - next_scan_number=bec.queue.next_scan_number, + scan_list=scan_list, + next_scan_number=next_scan_number, base_path=base_path, probe_file_propagation=probe_propagation, random_offset_x=random_offset_x, @@ -3323,10 +3357,11 @@ class Flomni( def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None: tomo_scan_numbers_file = os.path.expanduser("~/data/raw/logs/tomography_scannumbers.txt") + sample_name = self.sample_name.replace(" ", "_") with open(tomo_scan_numbers_file, "a+") as out_file: # pylint: disable=undefined-variable out_file.write( - f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.5f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n" + f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.5f} {self.tomo_id} {subtomo_number} {0} {sample_name}\n" ) def tomo_scan_projection(self, angle: float, _internal: bool = False): @@ -3343,6 +3378,21 @@ class Flomni( _at_each_angle, and tomo_alignment_scan, which needs real ptycho data) pass _internal=True to skip the prompt. """ + dev = builtins.__dict__.get("dev") + if not _internal: + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=True, force=False): + # Raise rather than return, for the same reason as the + # fermat-vs-single-point decline below: this can run + # inside a per-angle loop, and a bare return would leave + # the projection silently missing / the job stuck. + raise FlomniError( + "tomo_scan_projection: declined to proceed with filter(s) " + f"in the beam ({', '.join(offending)})." + ) + if not _internal and self.single_point_instead_of_fermat_scan: print( "\x1b[93mWarning: single_point_instead_of_fermat_scan is set, but" @@ -3963,10 +4013,55 @@ class Flomni( stitching = f"{self.stitch_x:.0f}/{self.stitch_y:.0f}" dataset_id = str(self.client.queue.next_dataset_number) account = bec.active_account - # Same grid sub_tomo_scan() actually uses -- see - # _tomo_type1_actual_grid()'s docstring for why this can't just - # recompute int((tomo_angle_range/tomo_angle_stepsize)*8) locally. - _, _, tomo_type1_total_projections = self._tomo_type1_actual_grid() + # The correct total-projections / sub-tomogram-count / angular-step + # values depend on which tomography type is actually configured -- + # mirrors tomo_parameters()'s tomo_type 1/2/3 branching above. This + # branch used to be missing: write_pdf_report() always called + # _tomo_type1_actual_grid() (int(180/tomo_angle_stepsize)*8), which + # is only meaningful for tomo_type==1. For golden-ratio scans + # (tomo_type==2/3) that silently reported a stale, unrelated + # type-1 total left over in the persistent tomo_angle_stepsize + # global var, instead of self.golden_max_number_of_projections -- + # the value the user actually configured and that the web page's + # progress display and tomo_parameters() already use correctly. + if self.tomo_type == 1: + _, achievable_step, total_projections = self._tomo_type1_actual_grid() + subtomo_count_label = "Number of individual sub-tomograms:" + subtomo_count_value = "8" + angle_step_label = "Angular step within sub-tomogram:" + angle_step_value = f"{achievable_step:.2f}" + elif self.tomo_type == 3: + total_projections = ( + self.golden_max_number_of_projections + if self.golden_max_number_of_projections > 0 + else 0 + ) + subtomo_count_label = "Number of projections per sub-tomogram:" + subtomo_count_value = f"{180 / self.tomo_angle_stepsize:.1f}" + angle_step_label = "Angular step within sub-tomogram:" + angle_step_value = f"{self.tomo_angle_stepsize:.2f}" + else: + # tomo_type == 2: golden ratio tomography -- there is no fixed + # set of sub-tomograms or a fixed angular step, so report the + # bunch size instead (matches tomo_parameters()'s printout). + total_projections = ( + self.golden_max_number_of_projections + if self.golden_max_number_of_projections > 0 + else 0 + ) + subtomo_count_label = "Sorted in bunches of:" + subtomo_count_value = f"{self.golden_ratio_bunch_size}" + angle_step_label = "Angular step within sub-tomogram:" + angle_step_value = "N/A (golden ratio)" + + total_projections_str = ( + f"{total_projections}" if total_projections else "N/A (manual stop)" + ) + last_scan_number_str = ( + f"{self.client.queue.next_scan_number + total_projections + 10}" + if total_projections + else "N/A" + ) # Same device ffzp_info() already reads. Defensive: may not be # configured/available in every session (e.g. simulated configs). try: @@ -4016,17 +4111,16 @@ class Flomni( f"{'Dataset ID:':<{padding}}{dataset_id}\n", f"{'Sample Info:':<{padding}}Sample Info\n", f"{'e-account:':<{padding}}{account}\n", - f"{'Number of projections:':<{padding}}{tomo_type1_total_projections}\n", + f"{'Number of projections:':<{padding}}{total_projections_str}\n", f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number}\n", - f"{'Last scan number approx.:':<{padding}}" - f"{self.client.queue.next_scan_number + tomo_type1_total_projections + 10}\n", + f"{'Last scan number approx.:':<{padding}}{last_scan_number_str}\n", f"{'Current photon energy:':<{padding}}{energy_str}\n", f"{'Exposure time:':<{padding}}{self.tomo_countingtime:.2f}\n", f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:.2f}\n", f"{'FOV:':<{padding}}{fovxy}\n", f"{'Stitching:':<{padding}}{stitching}\n", - f"{'Number of individual sub-tomograms:':<{padding}}8\n", - f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:.2f}\n", + f"{subtomo_count_label:<{padding}}{subtomo_count_value}\n", + f"{angle_step_label:<{padding}}{angle_step_value}\n", f"{'FZP diameter:':<{padding}}{fzp_diameter_um} microns\n", f"{'FZP outermost zone width:':<{padding}}{fzp_zone_width_nm} nm\n", f"{'FZP details:':<{padding}}{fzp_details}\n", diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py index 20004b63..0757ad20 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py @@ -167,6 +167,13 @@ class FlomniOpticsMixin: dev.rtx.controller.feedback_enable_with_reset() def fosa_in(self): + # TODO(commissioning): the OSA travels inside the heater's (fheater) + # envelope, but this function never checks fheater position before + # driving fosaz. fosaz must only move while fheater is fully "up" or + # fully "down" -- an intermediate heater position here risks a + # collision. Add a heater-state check (see ensure_osa_back()/ + # ensure_fheater_up() in flomni.py for the pattern) once fheater's + # up/down limits are commissioned. # 6.2 keV, 170 um FZP # umv(dev.losax, -1.4450000, dev.losay, -0.1800) # umv(dev.losaz, -1) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py index 83685508..0a933ec9 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py @@ -48,7 +48,12 @@ class flomniGuiTools: def flomnigui_show_gui(self): if "flomni" in self.gui.windows: self.flomni_window = self.gui.windows["flomni"] - self.gui.flomni.raise_window() + # raise_window() is currently buggy (bec_widgets RPCServer "raise" handling can + # detach/hide the window instead of bringing it to front) - disabled so that + # calling a flomnigui_show_* macro again on an already-open GUI is a safe no-op + # instead of risking hiding the window. See flomnigui_raise() above for the same + # workaround. + # self.gui.flomni.raise_window() else: # geometry: (pos_x, pos_y, w, h) pos_x = self._SCREEN_WIDTH - self._WINDOW_WIDTH @@ -60,7 +65,8 @@ class flomniGuiTools: self.gui.flomni.hide() def flomnigui_raise(self): - self.gui.flomni.raise_window() + pass + #self.gui.flomni.raise_window() def flomnigui_show_xeyealign(self): self.flomnigui_show_gui() diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_filter_and_eye_checks.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_filter_and_eye_checks.md new file mode 100644 index 00000000..451bd93b --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_filter_and_eye_checks.md @@ -0,0 +1,46 @@ +# TODO: OMNY is missing two pre-scan safety checks that flomni/LamNI now have + +Both items below were found while implementing a filter-out-of-beam check for +flomni and LamNI (branch `flomni_fixes_during_beamtimes`). OMNY was deliberately +left out of that work to avoid conflicting with other in-progress OMNY changes on +a separate branch — do both of these there. + +## 1. Filter-out-of-beam check (new feature, not yet on OMNY at all) + +flomni (`flomni.py`) and LamNI (`lamni.py`) now warn hard if any of the four cSAXS +exposure-box filters (`filter_array_1_x` … `filter_array_4_x`) are not at their "out" +position before a scan starts. The check itself is already shared/reusable: + +- `csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py` + - `filters_out_of_beam(dev, tol=0.1) -> (bool, list[str])` + - `warn_and_confirm(setup, warning, interactive=True, force=False) -> bool` — red + warning + "Continue anyway?" prompt (default no) when interactive; warning + 10s + pause + always-proceed when not (for unattended/queued runs). + +To wire it into OMNY, mirror what was done in `flomni.py`/`lamni.py`: + +- `OMNY.tomo_scan()` (`omny.py:1005`): add a check at the top, right where + `Flomni.tomo_scan()`/`LamNI.tomo_scan()` now have theirs — note OMNY's + `tomo_scan()` currently has **no `interactive`/`force` params at all** (OMNY has no + tomo-queue, see item 2 below, so there's no unattended-run case to support yet); + just call `warn_and_confirm(self, warning, interactive=True, force=False)`. +- `OMNY.tomo_scan_projection()` (`omny.py:1244`): currently `def + tomo_scan_projection(self, angle: float)` — no `_internal` flag. Add + `_internal: bool = False`, gate the filter check on `not _internal`, and raise + `OMNYError` on decline (same rationale as flomni/LamNI: a bare `return` inside a + per-angle loop can silently drop a projection). Update its internal call sites to + pass `_internal=True`: `_at_each_angle()` (`omny.py:1166`) and the loop call at + `omny.py:893`. + +## 2. Missing eye-out/optics-in check (pre-existing gap, unrelated to filters) + +Discovered independently while researching the filter check: `Flomni.tomo_scan()` +and `LamNI.tomo_scan()` both hard-warn if `_check_eye_out_and_optics_in()` fails +(X-ray eye IN / optics OUT is not a valid measurement configuration). **`OMNY.tomo_scan()` +(`omny.py:1005`) has no equivalent check at all** — there's no OMNY-side +`_check_eye_out_and_optics_in()`, and nothing calls one. Worth adding the same gate, +following `flomni_optics_mixin.py`/`lamni_optics_mixin.py`'s +`_check_eye_out_and_optics_in()` implementations as the template (device names will +differ for OMNY's axes). + +Not scoped/designed further here — just flagging it so it isn't lost. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_reconstruct_stale_scan_list.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_reconstruct_stale_scan_list.md new file mode 100644 index 00000000..a6722d81 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_reconstruct_stale_scan_list.md @@ -0,0 +1,68 @@ +# TODO: OMNY.tomo_reconstruct() writes a stale/missing scan list + +Found while fixing the same bug in `Flomni.tomo_reconstruct()` +(`csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py:3114`, branch +`flomni_fixes_during_beamtimes`). Not applied to OMNY yet to avoid conflicting +with other in-progress OMNY changes on a separate branch — do it there. + +## The bug + +`OMNY.tomo_reconstruct()` (`csaxs_bec/bec_ipython_client/plugins/omny/omny.py:1217`) +names the reconstruction queue file from a fresh `bec.queue.next_scan_number` +(so the *filename* is always correct), but writes the file's *content* from +`self._current_scan_list`: + +```python +def tomo_reconstruct(self, base_path="~/Data10/specES1"): + """write the tomo reconstruct file for the reconstruction queue""" + bec = builtins.__dict__.get("bec") + base_path = os.path.expanduser(base_path) + ptycho_queue_path = Path(os.path.join(base_path, self.ptycho_reconstruct_foldername)) + ptycho_queue_path.mkdir(parents=True, exist_ok=True) + + last_scan_number = bec.queue.next_scan_number - 1 + ptycho_queue_file = os.path.abspath( + os.path.join(ptycho_queue_path, f"scan_{last_scan_number:05d}.dat") + ) + with open(ptycho_queue_file, "w") as queue_file: + scans = " ".join([str(scan) for scan in self._current_scan_list]) + queue_file.write(f"p.scan_number {scans}\n") + queue_file.write("p.check_nextscan_started 1\n") +``` + +`self._current_scan_list` is only kept up to date by whichever internal method +last ran and then called `tomo_reconstruct()` itself right after (the OMNY +equivalents of flomni's `tomo_scan_projection()`/`tomo_acquire_at_angle()` — +check where `_current_scan_list` is assigned in `omny.py` for the exact call +sites). Call `tomo_reconstruct()` directly from the command line instead — +e.g. after a plain fermat scan run by hand, not through those internal +flows — and the file gets written with whatever scan list happened to be +cached from the *previous* tomo scan (or raises `AttributeError` if none ran +yet this session). Filename right, content wrong/stale. + +## The fix (already applied to flomni, mirror it here) + +`Flomni.tomo_reconstruct()` now falls back to just the most recently +completed scan number whenever the cached list doesn't actually match it: + +```python +bec = builtins.__dict__.get("bec") +next_scan_number = bec.queue.next_scan_number +last_scan_number = next_scan_number - 1 +scan_list = getattr(self, "_current_scan_list", None) +if not scan_list or scan_list[-1] != last_scan_number: + scan_list = [last_scan_number] +``` + +Internal callers are unaffected (their cached list's last entry always equals +`next_scan_number - 1` at the point they call `tomo_reconstruct()`), while a +direct/standalone call now correctly falls back to `[last_scan_number]` +instead of writing stale content or crashing. + +Port the same `getattr(...)`/fallback logic into `OMNY.tomo_reconstruct()` +(note OMNY's version doesn't go through the shared `PtychoReconstructor` +class like flomni's does — it writes the file inline — so the fix applies +directly to the `scans = " ".join(...)` line, not to a shared `write()` +method). + +Not scoped/designed further here — just flagging it so it isn't lost. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py index 2a987d75..34b18556 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py @@ -1235,10 +1235,11 @@ class OMNY( tomo_scan_numbers_file = os.path.expanduser( "~/Data10/specES1/dat-files/tomography_scannumbers.txt" ) + sample_name = self.sample_name.replace(" ", "_") with open(tomo_scan_numbers_file, "a+") as out_file: # pylint: disable=undefined-variable out_file.write( - f"{scan_number} {angle} {dev.osamroy.read()['osamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n" + f"{scan_number} {angle} {dev.osamroy.read()['osamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {sample_name}\n" ) def tomo_scan_projection(self, angle: float): diff --git a/csaxs_bec/device_configs/bl_detectors.yaml b/csaxs_bec/device_configs/bl_detectors.yaml index 203e04f3..a9a54206 100644 --- a/csaxs_bec/device_configs/bl_detectors.yaml +++ b/csaxs_bec/device_configs/bl_detectors.yaml @@ -1,26 +1,26 @@ -# eiger_1_5: -# description: Eiger 1.5M in-vacuum detector -# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M -# deviceConfig: -# detector_distance: 2200 -# beam_center: [870, 1203] -# onFailure: raise -# enabled: True -# readoutPriority: async -# softwareTrigger: False -# deviceTags: -# - bl_detectors - -eiger_9: - description: Eiger 9M detector - deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M +eiger_1_5: + description: Eiger 1.5M in-vacuum detector + deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M deviceConfig: - detector_distance: 6975.225 - beam_center: [1149.71, 1450.89] + detector_distance: 2200 + beam_center: [870, 1203] onFailure: raise enabled: True readoutPriority: async softwareTrigger: False + deviceTags: + - bl_detectors + +# eiger_9: +# description: Eiger 9M detector +# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M +# deviceConfig: +# detector_distance: 6975.225 +# beam_center: [1149.71, 1450.89] +# onFailure: raise +# enabled: True +# readoutPriority: async +# softwareTrigger: False # ids_cam: # description: IDS camera for live image acquisition diff --git a/csaxs_bec/device_configs/main.yaml b/csaxs_bec/device_configs/main.yaml index 48261723..157b0958 100644 --- a/csaxs_bec/device_configs/main.yaml +++ b/csaxs_bec/device_configs/main.yaml @@ -16,17 +16,17 @@ endstation: detectors: - !include ./bl_detectors.yaml -xeye: - - !include ./xeye.yaml +# xeye: +# - !include ./xeye.yaml -ssaxs: - - !include ./ssaxs.yaml +# ssaxs: +# - !include ./ssaxs.yaml #sastt: # - !include ./sastt.yaml -# flomni: -# - !include ./ptycho_flomni.yaml +flomni: + - !include ./ptycho_flomni.yaml #omny: # - !include ./ptycho_omny.yaml diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index e6b0b8e4..c0101817 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -21,8 +21,8 @@ feyex: userParameter: in: -16.453 out: -1 - fttrx_in: 2.3 - fttrx_out: -24 + fttrx_in: -2.4 + fttrx_out: -27 deviceTags: - ptycho_flomni @@ -46,6 +46,13 @@ feyey: in: -10.09 deviceTags: - ptycho_flomni +# TODO(commissioning): needs userParameter (e.g. in/up, down) -- move_fheater_down() +# in flomni.py currently reads user_parameter.get("in") and gets None. +# KNOWN GAP: fosa_in()/foptics_in() (flomni_optics_mixin.py) never check fheater +# position before driving fosaz. The OSA travels inside the heater's envelope, so +# fosaz must only move while fheater is fully at its "up" or fully at its "down" +# limit -- an intermediate heater position during fosa_in() risks a collision. +# Verify/add that check once real up/down limits are known from hardware. fheater: description: Heater Y deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -82,16 +89,19 @@ foptx: readoutPriority: baseline connectionTimeout: 20 userParameter: - #170 micron, 60 nm - in: -13.831 + #150 micros, 60 + in: -14.191 out: -13.831 + #170 micron, 60 nm + #in: -13.8909 + #out: -13.831 #250 micron, 30 nm, Abe structures # in: -13.8809375 # out: -14.1809 #250 micron, 30 nm, Tomas structures # in: -14.5490625 # out: -14.1809 - fzp_diameter: 170 # microns + fzp_diameter: 150 # microns fzp_outermost_zone_width: 60 # nm detector_distance: -1 # mm, sample-to-detector; unknown for now fzp_details: "manufacturing notes here" # free-text FZP manufacturing notes @@ -115,9 +125,12 @@ fopty: readoutPriority: baseline connectionTimeout: 20 userParameter: - #170 micron, 60 nm - in: 0.42 + #150 micron, 60 + in: 1.02 out: 0.57 + #170 micron, 60 nm + #in: 0.42 + #out: 0.57 #250 micron, 30 nm, Abe structures # in: 2.8299 # out: 2.8299 @@ -277,7 +290,7 @@ ftransy: readoutPriority: baseline connectionTimeout: 20 userParameter: - sensor_voltage: -1.6 + sensor_voltage: -2.2 deviceTags: - ptycho_flomni ftransz: @@ -354,12 +367,15 @@ fosax: readoutPriority: baseline connectionTimeout: 20 userParameter: + #150micron, 60 nm, 7.9 kev + in: 9.0393 + out: 5.1 #170 micron, 60 nm, 7.6 kev #in: 8.7568 #out: 5.1 #170 micron, 60 nm, 7.9 kev - in: 8.727079 - out: 5.1 + #n: 8.722151 + #ut: 5.1 #250 micron, 30 nm, Abe structures # in: 8.7392 # out: 5.1 @@ -386,10 +402,12 @@ fosay: readoutPriority: baseline connectionTimeout: 20 userParameter: + #150 micron, 60 nm, 7.9 kev + in: -0.6422 #170 micron, 60 nm, 7.6 kev #in: -0.0235 #170 micron, 60 nm, 7.9 kev - in: -0.04603 + #in: -0.0563 #250 micron, 30 nm, Abe structures # in: -2.3684 #250 micron, 30 nm, Tomas structures @@ -413,12 +431,15 @@ fosaz: readoutPriority: baseline connectionTimeout: 20 userParameter: + #150 micron, 60 nm, 7.9 kev, foptz 16.9, probe size 7.5 mu + in: 11.0998 + out: 6 #170 micron, 60 nm, 7.6 kev #in: 8.5 #out: 6 #170 micron, 60 nm, 7.9 kev, foptz 16.9, probe size 7.5 mu - in: 13.1 - out: 6 + # in: 13.1 + # out: 6 # micron, 30 nm, 7.9 kev, very close to the sample. make sure foptz is 32.02 or smaller //abe's fzp's # in: 0.5 # out: -5 @@ -625,33 +646,33 @@ calculated_signal: ############################################################ #################### OMNY Pandabox ######################### ############################################################ -omny_panda: - readoutPriority: async - deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY - deviceConfig: - host: omny-panda.psi.ch - signal_alias: - FMC_IN.VAL1.Min: cap_voltage_fzp_y_min - FMC_IN.VAL1.Max: cap_voltage_fzp_y_max - FMC_IN.VAL1.Mean: cap_voltage_fzp_y_mean - FMC_IN.VAL2.Min: cap_voltage_fzp_x_min - FMC_IN.VAL2.Max: cap_voltage_fzp_x_max - FMC_IN.VAL2.Mean: cap_voltage_fzp_x_mean - INENC1.VAL.Max: interf_st_fzp_y_max - INENC1.VAL.Mean: interf_st_fzp_y_mean - INENC1.VAL.Min: interf_st_fzp_y_min - INENC2.VAL.Max: interf_st_fzp_x_max - INENC2.VAL.Mean: interf_st_fzp_x_mean - INENC2.VAL.Min: interf_st_fzp_x_min - INENC3.VAL.Max: interf_st_rotz_max - INENC3.VAL.Mean: interf_st_rotz_mean - INENC3.VAL.Min: interf_st_rotz_min - INENC4.VAL.Max: interf_st_rotx_max - INENC4.VAL.Mean: interf_st_rotx_mean - INENC4.VAL.Min: interf_st_rotx_min - PCAP.GATE_DURATION.Value: pcap_gate_duration_value - enabled: true - readOnly: false - softwareTrigger: false - deviceTags: - - ptycho_flomni +# omny_panda: +# readoutPriority: async +# deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY +# deviceConfig: +# host: omny-panda.psi.ch +# signal_alias: +# FMC_IN.VAL1.Min: cap_voltage_fzp_y_min +# FMC_IN.VAL1.Max: cap_voltage_fzp_y_max +# FMC_IN.VAL1.Mean: cap_voltage_fzp_y_mean +# FMC_IN.VAL2.Min: cap_voltage_fzp_x_min +# FMC_IN.VAL2.Max: cap_voltage_fzp_x_max +# FMC_IN.VAL2.Mean: cap_voltage_fzp_x_mean +# INENC1.VAL.Max: interf_st_fzp_y_max +# INENC1.VAL.Mean: interf_st_fzp_y_mean +# INENC1.VAL.Min: interf_st_fzp_y_min +# INENC2.VAL.Max: interf_st_fzp_x_max +# INENC2.VAL.Mean: interf_st_fzp_x_mean +# INENC2.VAL.Min: interf_st_fzp_x_min +# INENC3.VAL.Max: interf_st_rotz_max +# INENC3.VAL.Mean: interf_st_rotz_mean +# INENC3.VAL.Min: interf_st_rotz_min +# INENC4.VAL.Max: interf_st_rotx_max +# INENC4.VAL.Mean: interf_st_rotx_mean +# INENC4.VAL.Min: interf_st_rotx_min +# PCAP.GATE_DURATION.Value: pcap_gate_duration_value +# enabled: true +# readOnly: false +# softwareTrigger: false +# deviceTags: +# - ptycho_flomni diff --git a/csaxs_bec/devices/omny/flomni_sample_storage.py b/csaxs_bec/devices/omny/flomni_sample_storage.py index 380d8d1a..3588b261 100644 --- a/csaxs_bec/devices/omny/flomni_sample_storage.py +++ b/csaxs_bec/devices/omny/flomni_sample_storage.py @@ -64,30 +64,30 @@ class FlomniSampleStorage(Device): if slot_nr > 20: raise FlomniSampleStorageError(f"Invalid slot number {slot_nr}.") - getattr(self.sample_placed, f"sample{slot_nr}").set(1) - getattr(self.sample_names, f"sample{slot_nr}").set(pack_desc(name, owner)) + getattr(self.sample_placed, f"sample{slot_nr}").set(1).wait(timeout=5) + getattr(self.sample_names, f"sample{slot_nr}").set(pack_desc(name, owner)).wait(timeout=5) def unset_sample_slot(self, slot_nr: int) -> bool: if slot_nr > 20: raise FlomniSampleStorageError(f"Invalid slot number {slot_nr}.") - getattr(self.sample_placed, f"sample{slot_nr}").set(0) - getattr(self.sample_names, f"sample{slot_nr}").set("-") + getattr(self.sample_placed, f"sample{slot_nr}").set(0).wait(timeout=5) + getattr(self.sample_names, f"sample{slot_nr}").set("-").wait(timeout=5) def set_sample_in_gripper(self, name: str, owner: str = "") -> bool: - self.sample_in_gripper.set(1) - self.sample_in_gripper_name.set(pack_desc(name, owner)) + self.sample_in_gripper.set(1).wait(timeout=5) + self.sample_in_gripper_name.set(pack_desc(name, owner)).wait(timeout=5) def unset_sample_in_gripper(self) -> bool: - self.sample_in_gripper.set(0) - self.sample_in_gripper_name.set("-") + self.sample_in_gripper.set(0).wait(timeout=5) + self.sample_in_gripper_name.set("-").wait(timeout=5) def is_sample_slot_used(self, slot_nr: int) -> bool: - val = getattr(self.sample_placed, f"sample{slot_nr}").get() + val = getattr(self.sample_placed, f"sample{slot_nr}").get(use_monitor=False) return bool(val) def is_sample_in_gripper(self) -> bool: - val = self.sample_in_gripper.get() + val = self.sample_in_gripper.get(use_monitor=False) return bool(val) def get_sample_name(self, slot_nr) -> str: diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py index de0ce5ff..ea1ddc7a 100644 --- a/csaxs_bec/devices/omny/galil/galil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py @@ -58,9 +58,17 @@ class GalilController(Controller): @threadlocked def socket_put(self, val: str) -> None: + time.sleep(0.01) self.command_history.append(f"[PUT]: {val}") self.sock.put(f"{val}\r".encode()) + @threadlocked + def socket_get(self) -> str: + time.sleep(0.01) + response = self.sock.receive().decode() + self.command_history.append(f"[GET]: {response}") + return response + @retry_once def socket_put_confirmed(self, val: str) -> None: """Send message to controller and ensure that it is received by checking that the socket receives a colon. diff --git a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py index 123aab5f..0cf66f39 100644 --- a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py @@ -375,7 +375,7 @@ class RtFlomniController(Controller): return False def laser_tracker_wait_on_target(self): - max_repeat = 25 + max_repeat = 100 count = 0 self.laser_tracker_galil_enable() while not self.laser_tracker_on_target(): @@ -383,6 +383,7 @@ class RtFlomniController(Controller): time.sleep(0.5) count += 1 if count == 10: + print("Waiting for the tracker.") ftrackz_con = self.device_manager.devices.ftrackz.obj.controller ftrackz_con.socket_put_confirmed("tracken=1") ftrackz_con.socket_put_confirmed("trackyct=0") diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py index 3dd851b9..8aa1ec64 100644 --- a/csaxs_bec/scans/flomni_fermat_scan.py +++ b/csaxs_bec/scans/flomni_fermat_scan.py @@ -101,6 +101,9 @@ class FlomniFermatScan(ScanBase): Returns: ScanReport + + Examples: + >>> scans.flomni_fermat_scan(10, 10, 0, 0, 1, 0, 0, exp_time=0.1, frames_per_trigger=1, burst_at_each_point=1) """ super().__init__(**kwargs) self._baseline_readout_status = None diff --git a/csaxs_bec/scans/omny_fermat_scan.py b/csaxs_bec/scans/omny_fermat_scan.py index 55a7a391..6c336f8b 100644 --- a/csaxs_bec/scans/omny_fermat_scan.py +++ b/csaxs_bec/scans/omny_fermat_scan.py @@ -93,6 +93,9 @@ class OmnyFermatScan(ScanBase): Returns: ScanReport + + Examples: + >>> scans.omny_fermat_scan(10, 10, 0, 0, 1, 0, 0, exp_time=0.1, frames_per_trigger=1, readout_time=0) """ super().__init__(**kwargs) self._baseline_readout_status = None diff --git a/docs/plans/ids-camera-manual-exposure.md b/docs/plans/ids-camera-manual-exposure.md new file mode 100644 index 00000000..fced10cd --- /dev/null +++ b/docs/plans/ids-camera-manual-exposure.md @@ -0,0 +1,330 @@ +# Plan: Manual exposure / auto-gain control for IDSCamera + xrayeye widget knobs + +Status: **planned, not yet implemented** (`IDSCamera` and the `OMNY_XRayEye` GUI widget +are both used in production during beamtimes; implementation should happen as its own +change, reviewed and tested outside a live beamtime). + +## Context + +`IDSCamera` (the cSAXS ophyd device wrapping IDS uEye cameras via the vendor's `pyueye` +SDK) currently gives operators no way to leave auto-exposure/auto-gain and drive the +camera manually. The underlying driver already has almost everything needed — it's just +not wired up or exposed to BEC clients: + +- `Camera.exposure_time` (get/set property) — `base_integration/camera.py:220-235` +- `Camera.set_auto_shutter(enable)` — `base_integration/camera.py:248-257` (auto-exposure toggle) +- `Camera.set_auto_gain(enable)` — `base_integration/camera.py:237-246` (auto-gain toggle) + +None of these three are called anywhere, and none are on `IDSCamera.USER_ACCESS`, so a +BEC client can't reach them today. + +`OMNY_XRayEye` (`csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`) is the GUI panel +for the flomni/LamNI/OMNY X-ray-eye alignment camera (`cam_xeye`, an `IDSCamera` +device). It should get exposure/auto-gain control knobs too, using the same +cached/event-driven pattern it already uses for its "Camera running" toggle, rather than +polling the device with RPC calls. + +Per user decisions (2026-09-11): cover exposure control + the auto-gain enable/disable +toggle only (no manual gain *value* — the driver has no SDK call for that yet); leave +connect-time default behavior untouched (no new constructor/config kwarg — the camera +keeps whatever state `is_ResetToDefault()` leaves it in unless a client/GUI explicitly +calls the new methods); and be careful to use cached readings wherever possible rather +than polling the hardware for GUI display. + +## Design: `Cpt(Signal, kind=Kind.config)`, not plain methods only + +`OMNY_XRayEye`'s existing "Camera running" toggle is the template for a control knob +that both *writes* device state and *reflects* it back without polling: + +- **Write**: `self.dev.get(CAMERA[0]).live_mode_enabled.put(enabled)` (`x_ray_eye.py:882`) + — RPC `.put()` on an ophyd `Cpt(Signal, kind=Kind.config)` on the device + (`ids_camera.py:59-65`). +- **Read (cached/event-driven, no polling)**: the widget subscribes once, at + construction, to `MessageEndpoints.device_read_configuration(CAMERA[0])` + (`x_ray_eye.py:293-295`) — BEC pushes **all** `Kind.config` signals of the device + together in one message whenever any of them changes. `getting_camera_status` + (`x_ray_eye.py:886-892`) reads `data["signals"]["cam_xeye_live_mode_enabled"]["value"]` + and updates the toggle via the `blockSignals(True)/.checked = .../blockSignals(False)` + idiom (to avoid re-triggering the write handler). + +This matches the repo's established convention for Signal-backed device state +(confirmed against `slit_control.py`, `sample_storage.py`, `tomo_params.py`: +`read(cached=True)` for polled numeric readbacks, and +`bec_dispatcher.connect_slot(..., device_read_configuration(...))` for `Kind.config` +signals, which is cheaper still — zero RPC round-trips, pure pub/sub). So exposure_time +and the two auto-enable flags should be `Cpt(Signal, kind=Kind.config)` on `IDSCamera` +(not plain methods only), with thin `USER_ACCESS` wrapper methods kept on top for +ipython/scripted use — mirroring how `start_live_mode()`/`stop_live_mode()` are just +wrappers around `live_mode_enabled.put()`. **No code should poll `get_exposure_time()` / +`get_gain()` RPCs from a `QTimer` loop.** + +## Changes + +### 1. `csaxs_bec/devices/ids_cameras/ids_camera.py` + +Add three new components, next to `live_mode_enabled` (`ids_camera.py:59-65`): + +```python +exposure_time = Cpt( + Signal, name="exposure_time", value=0.0, + doc="Camera exposure time (ms).", kind=Kind.config, +) +auto_exposure_enabled = Cpt( + Signal, name="auto_exposure_enabled", value=True, + doc="Enable/disable auto-exposure (auto-shutter).", kind=Kind.config, +) +auto_gain_enabled = Cpt( + Signal, name="auto_gain_enabled", value=True, + doc="Enable/disable auto-gain.", kind=Kind.config, +) +``` + +In `__init__`, alongside the existing `live_mode_enabled.subscribe(...)` +(`ids_camera.py:127`): + +```python +self.exposure_time.subscribe(self._on_exposure_time_changed, run=False) +self.auto_exposure_enabled.subscribe(self._on_auto_exposure_enabled_changed, run=False) +self.auto_gain_enabled.subscribe(self._on_auto_gain_enabled_changed, run=False) +``` + +New callbacks + thin `USER_ACCESS` wrappers, in a new +`############## Exposure / Gain ##############` section (placed after the live-mode/ROI +methods and before `############## User Interface Methods ##############`): + +```python +def _on_exposure_time_changed(self, *args, value, **kwargs): + self.cam.exposure_time = value + +def _on_auto_exposure_enabled_changed(self, *args, value, **kwargs): + self.cam.set_auto_shutter(bool(value)) + +def _on_auto_gain_enabled_changed(self, *args, value, **kwargs): + self.cam.set_auto_gain(bool(value)) + +def get_exposure_time(self) -> float: + """Get the current exposure time (ms), from the cached Signal value.""" + return float(self.exposure_time.get()) + +def set_exposure_time(self, value: float) -> None: + """Set the exposure time (ms). Does not itself disable auto-exposure -- + call set_auto_exposure_enabled(False) first, or the driver will keep overriding it.""" + self.exposure_time.put(value) + +def set_auto_exposure_enabled(self, enable: bool) -> None: + self.auto_exposure_enabled.put(bool(enable)) + +def set_auto_gain_enabled(self, enable: bool) -> None: + self.auto_gain_enabled.put(bool(enable)) +``` + +(Confirm the SDK's exposure unit — `is_Exposure`/`IS_EXPOSURE_CMD_*` is documented in +milliseconds; adjust the docstring if inspection shows otherwise.) + +Add the four wrapper names to `USER_ACCESS` (`ids_camera.py:67-76`): +`"get_exposure_time"`, `"set_exposure_time"`, `"set_auto_exposure_enabled"`, +`"set_auto_gain_enabled"`. + +In `on_connected()` (`ids_camera.py:283-288`), after `self.cam.on_connect()`, seed the +`exposure_time` signal from the real hardware value once, so the GUI shows a real number +immediately on connect instead of the `0.0` placeholder: + +```python +self.exposure_time.put(self.cam.exposure_time) +``` + +(This round-trips through `_on_exposure_time_changed`, which writes the same value back +to the driver — a harmless one-time no-op write, same idiom as the existing +`self.live_mode_enabled.put(bool(self._inputs.get("live_mode", False)))` line right +below it.) `auto_exposure_enabled`/`auto_gain_enabled` are **not** seeded from hardware — +there is no SDK query for current auto-shutter/auto-gain state in this driver (only +enable-setters), so, like `live_mode_enabled`, they start at their declared default +(`True`, matching the uEye SDK's typical post-reset default) and only reflect reality +once explicitly set through this API. + +No changes needed in `base_integration/camera.py` — `exposure_time`, `set_auto_shutter`, +`set_auto_gain` already exist there exactly as needed. + +### 2. `csaxs_bec/devices/sim/sim_cameras.py` + +`SimIDSCamera` replaces `self.cam` with `_SimIDSBackend` (line 211), which currently has +no `exposure_time` attribute and no `set_auto_shutter`/`set_auto_gain` methods — +`on_connected()`'s `self.exposure_time.put(self.cam.exposure_time)` and the new +subscribe callbacks would raise `AttributeError` against `SimIDSCamera` otherwise. Add to +`_SimIDSBackend` (mirroring `_SimAlliedVisionBackend.exposure_time`, lines 397-403): + +```python +def __init__(self, ...): + ... + self._exposure_time = 10000.0 # ms + self._auto_exposure = True + self._auto_gain = True + +@property +def exposure_time(self) -> float: + return self._exposure_time + +@exposure_time.setter +def exposure_time(self, value: float): + self._exposure_time = value + +def set_auto_shutter(self, enable: bool): + self._auto_exposure = bool(enable) + +def set_auto_gain(self, enable: bool): + self._auto_gain = bool(enable) +``` + +### 3. Tests — `tests/tests_devices/test_ids_camera.py` + +```python +def test_get_set_exposure_time(ids_camera): + ids_camera.set_exposure_time(1234.5) + assert ids_camera.cam.exposure_time == 1234.5 + assert ids_camera.get_exposure_time() == 1234.5 + +def test_set_auto_exposure_enabled(ids_camera): + ids_camera.set_auto_exposure_enabled(False) + ids_camera.cam.set_auto_shutter.assert_called_once_with(False) + +def test_set_auto_gain_enabled(ids_camera): + ids_camera.set_auto_gain_enabled(False) + ids_camera.cam.set_auto_gain.assert_called_once_with(False) + +def test_on_connected_seeds_exposure_time(ids_camera): + ids_camera.cam.on_connect = mock.Mock() + ids_camera.cam.exposure_time = 4200.0 + ids_camera.on_connected() + assert ids_camera.get_exposure_time() == 4200.0 +``` + +## Phase 2: GUI control knobs in `OMNY_XRayEye` + +### 4. `csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py` + +**Imports**: add `QDoubleSpinBox` to the `qtpy.QtWidgets` import block (`x_ray_eye.py:15-29`) +— only `QSpinBox` is imported today. + +**New widgets in `_init_ui`**, extending the existing `switch_grid` (`x_ray_eye.py:368-404`) +with a 3rd row (same `QGridLayout` the shutter/camera-running and smear toggles already +share, so columns keep lining up): + +```python +self.auto_exposure_label = QLabel("Auto exposure", parent=self) +self.auto_exposure_toggle = ToggleSwitch(parent=self) +self.auto_exposure_toggle.checked = True +self.auto_exposure_toggle.enabled.connect(self.auto_exposure_enabled_changed) + +self.auto_gain_label = QLabel("Auto gain", parent=self) +self.auto_gain_toggle = ToggleSwitch(parent=self) +self.auto_gain_toggle.checked = True +self.auto_gain_toggle.enabled.connect(self.auto_gain_enabled_changed) + +switch_grid.addWidget(self.auto_exposure_label, 2, 1, _right_vcenter) +switch_grid.addWidget(self.auto_exposure_toggle, 2, 2, Qt.AlignmentFlag.AlignVCenter) +switch_grid.addWidget(self.auto_gain_label, 2, 3, _right_vcenter) +switch_grid.addWidget(self.auto_gain_toggle, 2, 4, Qt.AlignmentFlag.AlignVCenter) +``` + +An exposure-time spinbox, added as its own small form near the step-size form +(`x_ray_eye.py:424-439`), mirroring that form's `QGridLayout` structure: + +```python +exposure_form = QGridLayout() +self.exposure_time_spin = QDoubleSpinBox(parent=self) +self.exposure_time_spin.setRange(0.01, 1000.0) # ms; confirm real driver range +self.exposure_time_spin.setDecimals(2) +self.exposure_time_spin.setSuffix(" ms") +self.exposure_time_spin.setEnabled(False) # auto-exposure starts enabled +self.exposure_time_spin.editingFinished.connect(self.exposure_time_submitted) +exposure_form.addWidget(QLabel("Exposure time", parent=self), 0, 0) +exposure_form.addWidget(self.exposure_time_spin, 0, 1) +self.control_panel_layout.addLayout(exposure_form) +``` + +Using `editingFinished` (fires once, on Enter/focus-loss) rather than `valueChanged` +(fires on every arrow-button click/scroll tick) is the throttling choice here — the +"don't hammer the device" concern applied to a manual-entry control instead of a poll +loop. + +**New handlers**, alongside `camera_running_enabled`/`opening_shutter` +(`x_ray_eye.py:875-906`): + +```python +@SafeSlot(bool) +def auto_exposure_enabled_changed(self, enabled: bool): + if self._manual_toggle_blocked_by_queue(): + logger.warning("Ignoring auto-exposure toggle while scan queue is busy.") + return + self.auto_exposure_toggle.blockSignals(True) + self.dev.get(CAMERA[0]).auto_exposure_enabled.put(enabled) + self.auto_exposure_toggle.checked = enabled + self.exposure_time_spin.setEnabled(not enabled) + self.auto_exposure_toggle.blockSignals(False) + +@SafeSlot(bool) +def auto_gain_enabled_changed(self, enabled: bool): + if self._manual_toggle_blocked_by_queue(): + logger.warning("Ignoring auto-gain toggle while scan queue is busy.") + return + self.auto_gain_toggle.blockSignals(True) + self.dev.get(CAMERA[0]).auto_gain_enabled.put(enabled) + self.auto_gain_toggle.checked = enabled + self.auto_gain_toggle.blockSignals(False) + +def exposure_time_submitted(self): + self.dev.get(CAMERA[0]).exposure_time.put(self.exposure_time_spin.value()) +``` + +**Extend the existing `getting_camera_status`** (`x_ray_eye.py:886-892`) — no new Redis +subscription needed, since `exposure_time`/`auto_exposure_enabled`/`auto_gain_enabled` +are `Kind.config` signals on the same device, so they already arrive in the same +`device_read_configuration(CAMERA[0])` message `live_mode_enabled` uses: + +```python +@SafeSlot(dict, dict) +def getting_camera_status(self, data, meta): + signals = data.get("signals") + live_mode_enabled = signals.get(f"{CAMERA[0]}_live_mode_enabled").get("value") + self.camera_running_toggle.blockSignals(True) + self.camera_running_toggle.checked = live_mode_enabled + self.camera_running_toggle.blockSignals(False) + + auto_exp = signals.get(f"{CAMERA[0]}_auto_exposure_enabled") + if auto_exp is not None: + enabled = bool(auto_exp.get("value")) + self.auto_exposure_toggle.blockSignals(True) + self.auto_exposure_toggle.checked = enabled + self.exposure_time_spin.setEnabled(not enabled) + self.auto_exposure_toggle.blockSignals(False) + + auto_gain = signals.get(f"{CAMERA[0]}_auto_gain_enabled") + if auto_gain is not None: + self.auto_gain_toggle.blockSignals(True) + self.auto_gain_toggle.checked = bool(auto_gain.get("value")) + self.auto_gain_toggle.blockSignals(False) + + exposure_time = signals.get(f"{CAMERA[0]}_exposure_time") + if exposure_time is not None: + self.exposure_time_spin.blockSignals(True) + self.exposure_time_spin.setValue(float(exposure_time.get("value"))) + self.exposure_time_spin.blockSignals(False) +``` + +The `is not None` guards are defensive (harmless if this ever runs against an older +`IDSCamera` revision without these signals) and cost nothing. + +## Verification + +- Unit tests: `pytest tests/tests_devices/test_ids_camera.py -v`. GUI widget tests (if + any exist under `tests/` for `x_ray_eye.py` — check before implementing) should cover + `getting_camera_status` updating the three new widgets from a synthetic message dict, + and `exposure_time_submitted`/`auto_*_enabled_changed` calling the right RPC `.put()`. +- Manual/simulation check: run against `SimIDSCamera` (no real hardware needed) — + confirm toggling "Auto exposure" off enables the spinbox, entering a value + Enter (or + focus-loss) calls `exposure_time.put(...)`, and the value round-trips back through + `getting_camera_status` into the spinbox display without any added polling. +- Manual/integration check on real hardware (during a non-live test session): open the + xrayeye widget against a connected `cam_xeye`, toggle auto-exposure off, set an + exposure time, and confirm the live image brightness responds and stays stable + (doesn't drift back, confirming auto-exposure is actually off). diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 5796480b..6e3f389d 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -222,12 +222,13 @@ The basic scan function can be called by `scans.flomni_fermat_scan()` and offers | fovy (float) | Fov in the piezo plane (i.e. piezo range). Max 100 um | | cenx (float) | center position in x | | ceny (float) | center position in y | -| exp_time (float) | exposure time per frame | -| frames_per_trigger(int) | Number of burst frames per position | | step (float) | stepsize | | zshift (float) | shift in z | | angle (float) | rotation angle (will rotate first) | -| corridor_size (float) | corridor size for the corridor optimization. Default 3 um | +| corridor_size (float) | corridor size for the corridor optimization. Default 3 um (auto-estimated if not provided) | +| exp_time (float) | exposure time per frame | +| frames_per_trigger (int) | Number of burst frames per position | +| burst_at_each_point (int) | Number of triggers and readouts at each point | Example: `scans.flomni_fermat_scan(fovx=20, fovy=25, cenx=0.02, ceny=0, zshift=0, angle=0, step=0.5, exp_time=0.01, frames_per_trigger=1)` @@ -272,6 +273,22 @@ The loading routine uses default values for the vertical alignment. This behavio At each projection, the angular dependent is computed by `flomni.get_alignment_offset(angle)`, with _angle_ in degrees. +To manually nudge the alignment by a few microns without recording a new fit +(e.g. to compensate for a small shift introduced by moving `foptz`), edit the +constant offset term of the currently loaded fit directly: +```python +fit = flomni.client.get_global_var("tomo_alignment_fit") +fit[0][2] += 5.0 # x offset, microns +fit[1][2] += 3.0 # y offset, microns +flomni.client.set_global_var("tomo_alignment_fit", fit) +``` +`tomo_alignment_fit` is a 2x5 list (row 0 = x, row 1 = y); column 2 in each +row is the constant offset of the sinusoidal fit +(`correction = A*sin(angle + phase) + offset [+ 3rd-order term for y]`), so +changing it shifts the correction by that amount at every angle without +touching the fitted amplitude/phase. Check the result with +`flomni.get_alignment_offset(angle)`. + The alignment can be cleared by `flomni.reset_tomo_alignment_fit()` diff --git a/docs/user/ptychography/omny.md b/docs/user/ptychography/omny.md index e910c811..e1739929 100644 --- a/docs/user/ptychography/omny.md +++ b/docs/user/ptychography/omny.md @@ -327,15 +327,16 @@ The basic scan function can be called by `scans.omny_fermat_scan()` and offers a | fovy (float) | Fov in the piezo plane (i.e. piezo range). Max 100 um | | cenx (float) | center position in x | | ceny (float) | center position in y | -| exp_time (float) | exposure time per frame | -| frames_per_trigger(int) | Number of burst frames per position | | step (float) | stepsize | | zshift (float) | shift in z | | angle (float) | rotation angle (will rotate first) | | corridor_size (float) | corridor size for the corridor optimization. Default 3 um | +| exp_time (float) | exposure time per frame | +| frames_per_trigger (int) | Number of burst frames per position | +| readout_time (float) | configurable readout time for devices that support it | Example: -`scans.omny_fermat_scan(fovx=20, fovy=25, cenx=0.02, ceny=0, zshift=0, angle=0, step=0.5, exp_time=0.01, frames_per_trigger=1)` +`scans.omny_fermat_scan(fovx=20, fovy=25, cenx=0.02, ceny=0, zshift=0, angle=0, step=0.5, exp_time=0.01, frames_per_trigger=1, readout_time=0)` #### Overview of the alignment steps @@ -378,6 +379,21 @@ The loading routine uses default values for the vertical alignment for setup. Th At each projection, the angular dependent is computed by `omny.get_alignment_offset(angle)`, with _angle_ in degrees. +To manually nudge the alignment by a few microns without recording a new fit, +edit the constant offset term of the currently loaded fit directly: +```python +fit = omny.client.get_global_var("tomo_alignment_fit") +fit[0][2] += 5.0 # x offset, microns +fit[1][2] += 3.0 # y offset, microns +omny.client.set_global_var("tomo_alignment_fit", fit) +``` +`tomo_alignment_fit` is a 2x5 list (row 0 = x, row 1 = y); column 2 in each +row is the constant offset of the sinusoidal fit +(`correction = A*sin(angle + phase) + offset [+ 3rd-order term for y]`), so +changing it shifts the correction by that amount at every angle without +touching the fitted amplitude/phase. Check the result with +`omny.get_alignment_offset(angle)`. + The alignment can be cleared by `omny.reset_tomo_alignment_fit()` diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py index 258dc920..4b6fa550 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py @@ -97,7 +97,7 @@ def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch): lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) calls = [] - def _fake_projection(angle): + def _fake_projection(angle, _internal=False): calls.append(angle) builtins.__dict__["bec"].queue.next_scan_number += 1 @@ -115,7 +115,7 @@ def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch): def test_tomo_alignment_scan_rotates_back_to_zero(monkeypatch): lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) - lamni.tomo_scan_projection = lambda angle: None + lamni.tomo_scan_projection = lambda angle, _internal=False: None umv_calls = [] monkeypatch.setattr(lamni_module, "umv", lambda *a: umv_calls.append(a), raising=False) diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py index 6ceca9ec..a35f12e3 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py @@ -208,7 +208,7 @@ def test_at_each_angle_default_path_unchanged_without_hook(): behaviour lamni had before this port: tomo_scan_projection + tomo_reconstruct.""" lamni = make_lamni() calls = [] - lamni.tomo_scan_projection = lambda angle: calls.append(("projection", angle)) + lamni.tomo_scan_projection = lambda angle, _internal=False: calls.append(("projection", angle)) lamni.tomo_reconstruct = lambda: calls.append(("reconstruct",)) lamni._at_each_angle(30.0)