diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py index f625a26..ca5928b 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py @@ -25,6 +25,7 @@ class LamniGuiTools: self.lamni_window = None self.text_box = None self.progressbar = None + self.alignment_progressbar = None self.xeyegui = None self.pdf_viewer = None self.idle_text_box = None @@ -72,6 +73,7 @@ class LamniGuiTools: if hasattr(self.gui, "lamni"): self.gui.lamni.delete_all(timeout=self.GUI_RPC_TIMEOUT) self.progressbar = None + self.alignment_progressbar = None self.text_box = None self.xeyegui = None self.pdf_viewer = None @@ -288,6 +290,48 @@ class LamniGuiTools: text += f"\n Hook: {hook_description}" self.progressbar.set_center_label(text) + # ------------------------------------------------------------------ + # Alignment scan progress bar + # ------------------------------------------------------------------ + + def lamnigui_show_alignment_progress(self): + """Open (or raise) a single-ring progress bar for tomo_alignment_scan(). + + A separate dock/widget from lamnigui_show_progress() (the real + tomogram's 3-ring bar) -- kept distinct since it's backed by its + own global var (see LamNI.alignment_scan_progress). + """ + self.lamnigui_show_gui() + if self._lamnigui_is_missing("alignment_progressbar"): + self.lamnigui_remove_all_docks() + self.alignment_progressbar = self.gui.lamni.new( + "RingProgressBar", timeout=self.GUI_RPC_TIMEOUT + ) + # Single ring: alignment-scan angle progress (manual update) + self.alignment_progressbar.add_ring().set_update("manual") + + self._lamnigui_update_alignment_progress() + + def _lamnigui_update_alignment_progress(self): + """Update the alignment-scan progress ring and centre label from + self.alignment_scan_progress (see LamNI.alignment_scan_progress).""" + if self.alignment_progressbar is None: + return + + ring = self.alignment_progressbar.rings[0] + total = self.alignment_scan_progress["total_angles"] + done = self.alignment_scan_progress["angle_index"] + progress = done / total * 100 if total else 0 + ring.set_value(progress) + + angle = self.alignment_scan_progress.get("angle", 0.0) + text = ( + f"Alignment scan progress:\n" + f" Angle {done}/{total}\n" + f" Current angle: {angle:.1f} deg" + ) + self.alignment_progressbar.set_center_label(text) + if __name__ == "__main__": from bec_lib.client import BECClient diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index f20f8bd..ff03247 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -2,7 +2,6 @@ import builtins import datetime import json import os -import subprocess import time from pathlib import Path @@ -13,7 +12,7 @@ from bec_lib.pdf_writer import PDFWriter from bec_lib.scan_repeat import scan_repeat from typeguard import typechecked -from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import ( +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import ( OMNYTools, PtychoReconstructor, TomoIDManager, @@ -109,6 +108,18 @@ class _ProgressProxy: return self._load() +class _AlignmentScanProgressProxy(_ProgressProxy): + """Same dict-proxy pattern as _ProgressProxy, but for tomo_alignment_scan()'s + own progress (angle N/12) -- kept in its own global var, deliberately + separate from tomo_progress, so anything watching tomo_progress for the + real tomogram (e.g. heartbeat/idle-time tracking) never sees alignment + scan writes mixed in. + """ + + _GLOBAL_VAR_KEY = "alignment_scan_progress" + _DEFAULTS: dict = {"angle_index": 0, "total_angles": 12, "angle": 0.0} + + class LamNIError(Exception): """A definite, non-transient tomo-scan failure (bad config, unmet precondition, ...) that should never be retried by @scan_repeat.""" @@ -224,6 +235,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools # tomo_scan()); use tomo_progress_reset() to explicitly clear stale # progress without starting a new scan. self._progress_proxy = _ProgressProxy(self.client) + self._alignment_scan_progress_proxy = _AlignmentScanProgressProxy(self.client) self._init_tomo_queue() from csaxs_bec.bec_ipython_client.plugins.LamNI.LamNI_webpage_generator import ( @@ -392,6 +404,21 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self._progress_proxy.reset() print("Tomo progress reset.") + @property + def alignment_scan_progress(self) -> _AlignmentScanProgressProxy: + """Proxy dict backed by the BEC global variable ``alignment_scan_progress``. + + Tracks tomo_alignment_scan()'s own progress (angle N/total) -- + deliberately separate from ``progress``/``tomo_progress`` (the real + tomogram's state), so the two can never be confused by anything + watching one or the other. + + Readable from any BEC client session via:: + + client.get_global_var("alignment_scan_progress") + """ + return self._alignment_scan_progress_proxy + @staticmethod def _format_duration(seconds: float) -> str: """Format a duration in seconds as a human-readable string, e.g. '2h 03m 15s'.""" @@ -408,7 +435,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools # X-ray eye alignment entry points # ------------------------------------------------------------------ - def xrayeye_alignment_start(self, keep_shutter_open: bool = False): + def xrayeye_alignment_start(self, keep_shutter_open: bool = False, force: bool = False): """Run the BEC GUI-based X-ray eye alignment procedure. Creates a fresh :class:`XrayEyeAlignGUI` instance, which resets the @@ -418,7 +445,19 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools Args: keep_shutter_open: If True the shutter is left open between angle steps so the sample remains visible in live view. + force: skip the rotation-center-calibration check below without + prompting. """ + if self.client.get_global_var("lamni_center_found_at") is None: + if not self._confirm_sequence_override( + "No rotation-center calibration has been recorded yet " + "(xrayeye_rotation_center_calibration_isolated/extended/" + "smear_experimental()). X-ray-eye alignment is normally done " + "after finding the rotation centre.", + force, + ): + print("Aborting X-ray eye alignment.") + return aligner = XrayEyeAlignGUI(self.client, self) try: aligner.align(keep_shutter_open=keep_shutter_open) @@ -1183,7 +1222,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools x_vals = [] for angle in angles: - x, _y = self.lamni_compute_additional_correction_xeye_mu(angle) + x, _y = self.lamni_compute_additional_correction_xeye_mu(angle, verbose=False) x_vals.append(x) zeros = [0] * len(angles) @@ -1196,7 +1235,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools f.write(" ".join(f"{x:.2f}" for x in x_vals) + "\n") f.write(" ".join(map(str, x_vals)) + "\n") - def tomo_alignment_scan(self) -> None: + def tomo_alignment_scan(self, force: bool = False) -> None: """Perform a laminogram alignment scan: a quick ptychography scan at 12 angles evenly spaced across the full 360 degrees, using whatever tomo_parameters() are currently set (FOV/step/counting time -- @@ -1207,10 +1246,19 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools ~/data/raw/logs/ptychotomoalign_scannum.txt for BEC_ptycho_align, prints them, and creates a scilog entry summarising the alignment scan numbers. + + Args: + force: skip the X-ray-eye-alignment check below without prompting. """ if self.client.get_global_var("tomo_fit_xray_eye") is None: - print("It appears that the xrayeye alignment was not performed or loaded. Aborting.") - return + if not self._confirm_sequence_override( + "No X-ray-eye alignment fit is loaded (or it was invalidated " + "by a rotation-center calibration run since). The alignment " + "scan's per-angle offsets would then all be zero.", + force, + ): + print("Aborting alignment scan.") + return bec = builtins.__dict__.get("bec") dev = builtins.__dict__.get("dev") @@ -1222,7 +1270,11 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools angles = list(np.linspace(0, 360, num=12, endpoint=False)) alignment_scan_numbers = [] - for angle in angles: + self.alignment_scan_progress.reset() + self.alignment_scan_progress.update(total_angles=len(angles), angle_index=0, angle=angles[0]) + self.lamnigui_show_alignment_progress() + + for idx, angle in enumerate(angles): successful = False print(f"Starting LamNI scan for angle {angle}") while not successful: @@ -1242,6 +1294,9 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools successful = True + self.alignment_scan_progress.update(angle_index=idx + 1, angle=angle) + self._lamnigui_update_alignment_progress() + umv(dev.lsamrot, 0) self.OMNYTools.printgreenbold( "\n\nAlignment scan finished. Please run BEC_ptycho_align and load the new fit" @@ -1575,13 +1630,27 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools ) return angle, subtomo_number - def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None): + def tomo_scan( + self, + subtomo_start=1, + start_angle=None, + projection_number=None, + force: bool = False, + interactive: bool = True, + ): """Start a tomo scan. Args: subtomo_start (int): For tomo_type 1, the sub-tomogram to start from. Defaults to 1. start_angle (float, optional): Override starting angle of the first sub-tomogram. projection_number (int, optional): For tomo_types 2 and 3, resume from this index. + force: skip the fine-alignment check below entirely (no warning at all). + interactive: if True (default, normal CLI use), the fine-alignment + check prompts and can abort. If False (used by + tomo_queue_execute() for unattended queued runs, where + input() would just hang forever with nobody watching), the + check instead prints a bold warning, waits 10s, and always + proceeds -- it never aborts or raises. """ self.lamnigui_show_progress() @@ -1598,23 +1667,40 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools or (self.tomo_type == 2 and projection_number is None) or (self.tomo_type == 3 and projection_number is None) ): - # bec.active_account is already a plain str, not bytes -- .decode() - # crashes with AttributeError. Also guard against no active - # e-account (empty string, e.g. a dev/sim session not logged into - # a real account) rather than trying to register a sample under - # one -- mirrors Flomni.tomo_scan()'s equivalent check exactly. - if bec.active_account != "": - self.tomo_id = self.add_sample_database( - self.sample_name, - str(datetime.date.today()), - bec.active_account, - bec.queue.next_scan_number, - "lamni", - "test additional info", - "BEC", + if not self.corr_pos_x and not force: + warning = ( + "No fine (ptycho) alignment correction is loaded -- the " + "sample centre will drift across projection angles " + "uncorrected. Fine for a large FOV that doesn't need it; " + "otherwise run tomo_alignment_scan() and " + "read_additional_correction() first." ) - else: - self.tomo_id = 0 + if interactive: + if not self._confirm_sequence_override(warning, force=False): + print("Aborting tomo scan.") + return + else: + self.OMNYTools.printredbold(f"WARNING: {warning}") + self.OMNYTools.printredbold( + "Proceeding automatically in 10 s (unattended/queued run)..." + ) + time.sleep(10) + # bec.active_account is already a plain str, not bytes -- .decode() + # crashes with AttributeError. Always attempt registration (even + # for an empty/test account) and let add_sample_database() -> + # TomoIDManager.register() decide production vs. test-server vs. + # genuine-failure fallback -- this used to short-circuit straight + # to tomo_id=0 for an empty account, which also skipped the + # test-server registration path entirely. + self.tomo_id = self.add_sample_database( + self.sample_name, + str(datetime.date.today()), + bec.active_account or "", + bec.queue.next_scan_number, + "lamni", + "test additional info", + "BEC", + ) self.write_pdf_report() self.progress["tomo_start_time"] = datetime.datetime.now().isoformat() # reset stale estimates from any previous scan, otherwise the GUI @@ -2027,6 +2113,31 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools def _get_val(msg: str, default_value, data_type): return data_type(input(f"{msg} ({default_value}): ") or default_value) + @staticmethod + def _confirm_sequence_override(warning: str, force: bool) -> bool: + """Print *warning* and ask whether to proceed anyway. + + Used by the alignment-sequence gates (xrayeye_alignment_start(), + tomo_alignment_scan(), tomo_scan()) -- these check whether the + expected prior step (rotation-center calibration / X-ray-eye + alignment / fine alignment) is still valid, but never hard-block: + an operator can always choose to continue, or pass force=True to + skip the prompt entirely (needed for non-interactive/queued use, + e.g. tomo-queue command jobs, where input() isn't viable). + + Unlike most confirmation prompts in this codebase (which default to + "yes" on Enter), this defaults to "no" -- skipping a real sequence + check should be a deliberate choice, not an accidental Enter. + + Returns: + bool: True if the caller should proceed. + """ + if force: + return True + print(warning) + answer = input("Continue anyway? [y/N]: ").strip().lower() + return answer in ("y", "yes") + # ------------------------------------------------------------------ # PDF report # ------------------------------------------------------------------ @@ -2034,17 +2145,15 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools def write_pdf_report(self): """Create and write the PDF report with current LamNI settings.""" dev = builtins.__dict__.get("dev") - header = ( - " \n" * 3 - + " ::: ::: ::: ::: :::: ::: ::::::::::: \n" - + " :+: :+: :+: :+:+: :+:+: :+:+: :+: :+: \n" - + " +:+ +:+ +:+ +:+ +:+:+ +:+ :+:+:+ +:+ +:+ \n" - + " +#+ +#++:++#++: +#+ +:+ +#+ +#+ +:+ +#+ +#+ \n" - + " +#+ +#+ +#+ +#+ +#+ +#+ +#+#+# +#+ \n" - + " #+# #+# #+# #+# #+# #+# #+#+# #+# \n" - + " ########## ### ### ### ### ### #### ########### \n" - ) - padding = 20 + # LamNI.png (not the previously-referenced, nonexistent + # "LamNI_logo.png" -- that typo silently broke the scilog logo + # attachment below, since the resulting FileNotFoundError was caught + # by the generic try/except and never surfaced). + logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LamNI.png") + # Widest label below ("Number of individual sub-tomograms:") is 36 + # chars; left-justify both label and value (no right-justify) so + # short values don't leave a big ragged gap after the label. + padding = 38 piezo_range = f"{self.lamni_piezo_range_x:.2f}/{self.lamni_piezo_range_y:.2f}" stitching = f"{self.lamni_stitch_x:.2f}/{self.lamni_stitch_y:.2f}" dataset_id = str(self.client.queue.next_dataset_number) @@ -2054,46 +2163,90 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools # configs). Read it defensively so a missing/misbehaving device # doesn't crash the whole report instead of just omitting one line. try: - energy_str = f"{dev.ccm_energy.read(cached=True)[dev.ccm_energy.name]['value']:.4f}" + energy_kev = dev.ccm_energy.read(cached=True)[dev.ccm_energy.name]["value"] + energy_str = f"{energy_kev:.4f}" except Exception: + energy_kev = None energy_str = "N/A" + # FZP diameter/outermost-zone-width: userParameter on loptx (see + # device config) -- independent of the energy read above, so a + # failed energy read shouldn't blank these out too. + try: + fzp_diameter_um = self._get_user_param_safe("loptx", "fzp_diameter") + fzp_zone_width_nm = self._get_user_param_safe("loptx", "fzp_outermost_zone_width") + except Exception: + fzp_diameter_um = fzp_zone_width_nm = "N/A" + # FZP focal distance: same formula as lfzp_info(), from the values + # above and the current photon energy -- only this needs energy_kev. + try: + wavelength_m = 1.2398e-9 / energy_kev + focal_distance_mm = ( + fzp_diameter_um * 1e-6 * fzp_zone_width_nm * 1e-9 / wavelength_m * 1000 + ) + focal_distance_str = f"{focal_distance_mm:.2f}" + except Exception: + focal_distance_str = "N/A" + # FZP focus-to-sample distance: same live z-stage-based calculation + # lfzp_info() already uses -- not a stored value. + try: + loptz_val = dev.loptz.read()["loptz"]["value"] + fzp_sample_distance_str = f"{-loptz_val + 85.6 + 52:.1f}" + except Exception: + fzp_sample_distance_str = "N/A" + # Sample-to-detector distance: userParameter on loptx, defaults to + # -1 (unknown) until someone measures and sets it. + detector_distance = self._get_user_param_safe("loptx", "detector_distance") + detector_distance_str = ( + f"{detector_distance:.1f}" if detector_distance and detector_distance > 0 else "N/A" + ) content = [ - f"{'Sample Name:':<{padding}}{self.sample_name:>{padding}}\n", - f"{'Measurement ID:':<{padding}}{str(self.tomo_id):>{padding}}\n", - f"{'Dataset ID:':<{padding}}{dataset_id:>{padding}}\n", - f"{'Sample Info:':<{padding}}{'Sample Info':>{padding}}\n", - f"{'e-account:':<{padding}}{str(self.client.username):>{padding}}\n", - f"{'Number of projections:':<{padding}}{report_total_projections:>{padding}}\n", - f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number:>{padding}}\n", - f"{'Last scan number approx.:':<{padding}}{self.client.queue.next_scan_number + report_total_projections + 10:>{padding}}\n", - f"{'Current photon energy:':<{padding}}{energy_str:>{padding}}\n", - f"{'Exposure time:':<{padding}}{self.tomo_countingtime:>{padding}.2f}\n", - f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:>{padding}.2f}\n", - f"{'Piezo range (FOV sample plane):':<{padding}}{piezo_range:>{padding}}\n", - f"{'Restriction to circular FOV:':<{padding}}{self.tomo_circfov:>{padding}.2f}\n", - f"{'Stitching:':<{padding}}{stitching:>{padding}}\n", - f"{'Number of individual sub-tomograms:':<{padding}}{8:>{padding}}\n", - f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:>{padding}.2f}\n", - f"{'Tomo type:':<{padding}}{self.tomo_type:>{padding}}\n", + f"{'Sample Name:':<{padding}}{self.sample_name}\n", + f"{'Measurement ID:':<{padding}}{self.tomo_id}\n", + f"{'Dataset ID:':<{padding}}{dataset_id}\n", + f"{'Sample Info:':<{padding}}Sample Info\n", + f"{'e-account:':<{padding}}{self.client.username}\n", + f"{'Number of projections:':<{padding}}{report_total_projections}\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 + report_total_projections + 10}\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"{'Piezo range (FOV sample plane):':<{padding}}{piezo_range}\n", + f"{'Restriction to circular FOV:':<{padding}}{self.tomo_circfov:.2f}\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"{'Tomo type:':<{padding}}{self.tomo_type}\n", + f"{'FZP diameter:':<{padding}}{fzp_diameter_um} microns\n", + f"{'FZP outermost zone width:':<{padding}}{fzp_zone_width_nm} nm\n", + f"{'FZP focal distance:':<{padding}}{focal_distance_str} mm\n", + f"{'FZP focus-to-sample distance:':<{padding}}{fzp_sample_distance_str} mm\n", + f"{'Sample-to-detector distance:':<{padding}}{detector_distance_str} mm\n", ] hook_description = self._describe_active_hook() if hook_description: - content.append(f"{'At-each-angle hook:':<{padding}}{hook_description:>{padding}}\n") + content.append(f"{'At-each-angle hook:':<{padding}}{hook_description}\n") content = "".join(content) hook_source = self._active_hook_source() user_target = os.path.expanduser(f"~/data/raw/documentation/tomo_scan_ID_{self.tomo_id}.pdf") with PDFWriter(user_target) as file: - file.write(header) + self._add_psi_footer(file) + # PDFWriter (bec_lib) has no public image API -- reach into its + # underlying fpdf object directly. logo_w chosen to keep the + # header modest relative to the A4 page width (210mm). + if os.path.exists(logo_path): + logo_w = 50 + file._pdf.image(logo_path, x=(210 - logo_w) / 2, w=logo_w) + file._pdf.ln(5) file.write(content) if hook_source: file.write( f"\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}" ) - # upload_last_pon.sh no longer works and needs a rewrite -- disabled - # for now (mirrors Flomni, which already has this commented out). - # subprocess.run( - # "xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True - # ) + # Replaces the old upload_last_pon.sh script (broken, never rewritten -- + # see git history) with a direct HTTP upload to the samples web folder. + self._upload_pdf_report_to_samples(user_target) # Same tolerance as write_to_scilog(): a session without scilog/logbook # configured (e.g. a dev/sim session) must not crash report generation # over the logbook upload -- the PDF itself is already written above. @@ -2104,7 +2257,6 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools f"\n\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}" ) msg = bec.logbook.LogbookMessage() - logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LamNI_logo.png") msg.add_file(logo_path).add_text(scilog_text.replace("\n", "

")).add_tag( ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "LamNI", self.sample_name] ) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py index 03a1575..e404710 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py @@ -248,15 +248,24 @@ class LamNIAlignmentMixin: f" Y: A={fit[1][0]:.4f}, B={fit[1][1]:.4f}, C={fit[1][2]:.4f}" ) - def lamni_compute_additional_correction_xeye_mu(self, angle): + def lamni_compute_additional_correction_xeye_mu(self, angle, verbose: bool = True): """Evaluate the sinusoidal X-ray eye correction at *angle* degrees. + Args: + verbose: if True (default), print the computed correction. Pass + False for bulk/lookahead uses (e.g. write_alignment_scan_numbers()) + that just need the numbers and would otherwise print the same + values a second time, ahead of and redundant with the + per-projection print that happens when this is actually + applied during the scan. + Returns: tuple: ``(correction_x_mm, correction_y_mm)`` """ tomo_fit_xray_eye = self.client.get_global_var("tomo_fit_xray_eye") if tomo_fit_xray_eye is None: - print("Not applying any X-ray eye correction. No fit data available.") + if verbose: + print("Not applying any X-ray eye correction. No fit data available.") return (0, 0) correction_x = ( @@ -272,10 +281,11 @@ class LamNIAlignmentMixin: + tomo_fit_xray_eye[1][2] ) / 1000 - print( - f"Xeye correction x={correction_x:.6f} mm," - f" y={correction_y:.6f} mm @ angle={angle}" - ) + if verbose: + print( + f"Xeye correction x={correction_x:.6f} mm," + f" y={correction_y:.6f} mm @ angle={angle}" + ) return (correction_x, correction_y) # ------------------------------------------------------------------ diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py index 5cbf41b..981d526 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py @@ -7,7 +7,7 @@ from rich.console import Console from rich.table import Table from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_put -from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import OMNYTools +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools dev = builtins.__dict__.get("dev") bec = builtins.__dict__.get("bec") @@ -417,7 +417,7 @@ class LamNIOpticsMixin: ) table.add_row( f"{diameter*1e6:.2f} microns", - f"{focal_distance:.2f} mm", + f"{focal_distance*1000:.2f} mm", f"{beam_size:.2f} microns", ) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py index 2b633de..0b1b41a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py @@ -200,6 +200,21 @@ class XrayEyeAlign: print(f"Alignment GUI: {msg}") self.gui.user_message = msg + def _sync_sample_name(self, prompt: bool = False): + """Push lamni.sample_name into the XRayEye GUI's sample_name field. + + If prompt=True, first ask for it via the same _get_val() pattern + tomo_parameters() uses (Enter keeps the current value) -- used at + the rotation-center steps, which are often the first alignment + action for a new sample, before tomo_parameters() has necessarily + run. + """ + if prompt: + self.lamni.sample_name = self.lamni._get_val( + "sample name", self.lamni.sample_name, str + ) + self.gui.sample_name = self.lamni.sample_name + # ------------------------------------------------------------------ # Main alignment procedure # ------------------------------------------------------------------ @@ -262,6 +277,7 @@ class XrayEyeAlign: then load fit parameters into the global variable store. """ self.lamni.lamnigui_show_xeyealign() + self._sync_sample_name() self.gui.set_dap_params_forwarding(True) self.send_message("Getting things ready. Please wait...") @@ -561,26 +577,25 @@ class XrayEyeAlign: row 1: x offsets [um] row 2: y offsets [um] - Also writes a timestamped HDF5 file alongside the archival text file, - containing the full raw record of the alignment run: alignment_values - (FZP centre + all 8 angle clicks, in mm), alignment_images (one frame - per update_frame() call), roi_pixel_data (raw pixel coords/size at - each submit), and this same fit array as alignment_fit. + Writes a timestamped HDF5 file containing the full raw record of the + alignment run: alignment_values (FZP centre + all 8 angle clicks, in + mm), alignment_images (one frame per update_frame() call), + roi_pixel_data (raw pixel coords/size at each submit), and this same + fit array as alignment_fit. """ - # Archival text file (backward compatible with any external scripts) - file = os.path.expanduser("~/data/raw/logs/xrayeye_alignmentvalues") - os.makedirs(os.path.dirname(file), exist_ok=True) - with open(file, "w") as f: - f.write("angle\thorizontal\tvertical\n") - for k in range(2, 10): - angle_deg = LAMNI_ALIGNMENT_ANGLES[k - 2] - x_off = (self.alignment_values[0][0] - self.alignment_values[k][0]) * 1000 - y_off = (self.alignment_values[k][1] - self.alignment_values[0][1]) * 1000 - f.write(f"{angle_deg}\t{x_off:.4f}\t{y_off:.4f}\n") - print( - f" Angle {angle_deg:3d} deg: " - f"x_offset={x_off:.2f} um, y_offset={y_off:.2f} um" - ) + # NOTE: this used to also write a plain-text archival file at + # ~/data/raw/logs/xrayeye_alignmentvalues for external fitting + # scripts. That's no longer needed, and xrayeye_alignmentvalues is + # now a directory (holding the timestamped HDF5 files below), so + # writing a flat file at that same path would clash with it. + for k in range(2, 10): + angle_deg = LAMNI_ALIGNMENT_ANGLES[k - 2] + x_off = (self.alignment_values[0][0] - self.alignment_values[k][0]) * 1000 + y_off = (self.alignment_values[k][1] - self.alignment_values[0][1]) * 1000 + print( + f" Angle {angle_deg:3d} deg: " + f"x_offset={x_off:.2f} um, y_offset={y_off:.2f} um" + ) angles = np.array(LAMNI_ALIGNMENT_ANGLES, dtype=float) x_offsets = np.array( @@ -849,6 +864,7 @@ class XrayEyeAlign: tuple: (new_lsamx_center, new_lsamy_center) in mm. """ self.lamni.lamnigui_show_xeyealign() + self._sync_sample_name(prompt=True) self.gui.set_dap_params_forwarding(False) self._reset_init_values() self.alignment_images = [] @@ -945,6 +961,7 @@ class XrayEyeAlign: if answer in ("", "y", "yes"): dev.lsamx.update_user_parameter({"center": float(new_lsamx)}) dev.lsamy.update_user_parameter({"center": float(new_lsamy)}) + self._mark_center_found_and_invalidate_downstream() print( f"[rotation-center][smear] lsamx.user_parameter['center'] = " f"{dev.lsamx.user_parameter.get('center')}, " @@ -1100,6 +1117,27 @@ class XrayEyeAlign: f"interferometer rtx/rty now read ({rtx_after:.2f}, {rty_after:.2f}) um" ) + def _mark_center_found_and_invalidate_downstream(self): + """Record that the rotation centre was just (re-)established, and + invalidate any X-ray-eye/fine-alignment state calibrated around the + previous centre. + + Called right after a new lsamx/lsamy centre is actually applied (by + both find_rotation_center() and find_rotation_center_smear_experimental()). + Reuses the LamNI alignment mixin's own reset methods rather than + introducing separate invalidation logic -- see + lamni.xrayeye_alignment_start()/tomo_alignment_scan()/tomo_scan(), + which gate on lamni_center_found_at / tomo_fit_xray_eye / corr_pos_x + respectively. + """ + import datetime + + self.client.set_global_var( + "lamni_center_found_at", datetime.datetime.now().isoformat() + ) + self.lamni.reset_xray_eye_correction() + self.lamni.reset_correction() + def find_rotation_center( self, sample_type: str = "isolated", keep_shutter_open: bool = False, apply: bool = True ): @@ -1137,6 +1175,7 @@ class XrayEyeAlign: ) self.lamni.lamnigui_show_xeyealign() + self._sync_sample_name(prompt=True) self.gui.set_dap_params_forwarding(False) self._reset_init_values() self.alignment_images = [] @@ -1254,6 +1293,7 @@ class XrayEyeAlign: if answer in ("", "y", "yes"): dev.lsamx.update_user_parameter({"center": float(new_lsamx)}) dev.lsamy.update_user_parameter({"center": float(new_lsamy)}) + self._mark_center_found_and_invalidate_downstream() print( f"[rotation-center] lsamx.user_parameter['center'] = " f"{dev.lsamx.user_parameter.get('center')}, " diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py similarity index 87% rename from csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py rename to csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py index 78e9892..8f8de0d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py @@ -4,7 +4,6 @@ import fcntl import json import os import socket -import subprocess import sys import termios import threading @@ -36,10 +35,6 @@ def umvr(*args): return scans.umv(*args, relative=True) -class OMNYToolsError(Exception): - pass - - class OMNYTools: HEADER = "\033[95m" @@ -68,6 +63,9 @@ class OMNYTools: def printgreenbold(self, string: str): print(self.BOLD + self.OKGREEN + string + self.ENDC) + def printredbold(self, string: str): + print(self.BOLD + self.FAIL + string + self.ENDC) + def yesno(self, message: str, default="none", autoconfirm=0) -> bool: if autoconfirm and default == "y": self.printgreen(message + " Automatically confirming default: yes") @@ -329,8 +327,12 @@ class TomoIDManager: """Registers a tomography measurement in the OMNY sample database and returns its assigned tomo ID. - Falls back to tomo ID 0 for non-production accounts (e.g. test - accounts like "gac-x01dc") which the server rejects. + 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. Usage: id_manager = TomoIDManager() @@ -346,7 +348,7 @@ class TomoIDManager: """ OMNY_URL = "https://v1p0zyg2w9n2k9c1.myfritz.net/samples/newmeasurement.php" - TMP_FILE = "~/currsamplesnr.txt" + TEST_OMNY_URL = "https://omny-test.psi.ch/samples/newmeasurement.php" FALLBACK_TOMO_ID = 0 @staticmethod @@ -366,38 +368,54 @@ class TomoIDManager: ) -> int: """Register a new measurement and return the assigned tomo ID. - Returns FALLBACK_TOMO_ID (0) if the account is not a real e-account - or if the server cannot be reached / returns an unusable response. + 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. """ - if not self._is_valid_eaccount(eaccount): + if self._is_valid_eaccount(eaccount): + omny_url = self.OMNY_URL + else: + omny_url = self.TEST_OMNY_URL logger.warning( - f"Account '{eaccount}' is not a valid e-account; " - f"skipping OMNY registration, using tomo ID {self.FALLBACK_TOMO_ID}." + 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." + ) + + params = { + "sample": sample_name, + "date": date, + "eaccount": eaccount, + "scannr": scan_number, + "setup": setup, + "additional": additional_info, + "user": user, + } + + try: + import requests + import urllib3 + + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except ImportError as exc: + logger.warning( + f"Could not obtain tomo ID from OMNY database ('requests' library not " + f"installed: {exc}); falling back to tomo ID {self.FALLBACK_TOMO_ID}." ) return self.FALLBACK_TOMO_ID - url = ( - f"{self.OMNY_URL}" - f"?sample={sample_name}" - f"&date={date}" - f"&eaccount={eaccount}" - f"&scannr={scan_number}" - f"&setup={setup}" - f"&additional={additional_info}" - f"&user={user}" - ) - - tmp_file = os.path.expanduser(self.TMP_FILE) try: - result = subprocess.run(f"wget -q -O {tmp_file} '{url}'", shell=True, timeout=30) - if result.returncode != 0: - raise OMNYToolsError( - f"wget failed (exit code {result.returncode}) fetching tomo ID from {self.OMNY_URL}" - ) - with open(tmp_file) as f: - content = f.read().strip() - return int(content) - except (subprocess.TimeoutExpired, FileNotFoundError, ValueError, OMNYToolsError) as exc: + response = requests.get( + omny_url, + params=params, + timeout=30, + verify=False, # accept self-signed certs + allow_redirects=False, # SSRF hardening + ) + response.raise_for_status() + return int(response.text.strip()) + except (requests.RequestException, ValueError) as exc: logger.warning( f"Could not obtain tomo ID from OMNY database ({exc}); " f"falling back to tomo ID {self.FALLBACK_TOMO_ID}." diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/psi_logo.svg b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/psi_logo.svg new file mode 100644 index 0000000..1ca31d4 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/psi_logo.svg @@ -0,0 +1,119 @@ + 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 f351f5d..6a0f9cb 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 @@ -39,6 +39,8 @@ import builtins import datetime import inspect import json +import os +import threading import uuid from typing import Callable @@ -390,6 +392,129 @@ class TomoQueueMixin: user=user, ) + # Only omny-test.psi.ch, not v1p0zyg2w9n2k9c1.myfritz.net (the host + # TomoIDManager.OMNY_URL registers measurements against, and the + # webpage generators mirror their own content to): only + # omny-test.psi.ch has the sample counter that actually matches + # self.tomo_id. + _SAMPLES_UPLOAD_HOSTS = ("https://omny-test.psi.ch",) + + def _upload_pdf_report_to_samples(self, pdf_path: str) -> None: + """Upload a just-written PDF report to the OMNY samples web folder + (see _SAMPLES_UPLOAD_HOSTS). Identical for every setup that uses + ``self.tomo_id`` (set via add_sample_database() just before + write_pdf_report() calls this) -- shared here so LamNI/Flomni don't + each carry their own copy. Replaces the old, broken + upload_last_pon.sh script both used to shell out to. + + POSTs to /samples/upload.php with filename="new.pdf" so the + server assigns the number itself from its own counter file + (countersaver.txt) -- the same counter newmeasurement.php + (TomoIDManager.register()) already incremented and returned as + self.tomo_id. Any other filename would be saved under samples/png/ + instead of directly in samples/, per upload.php's own branching, so + "new.pdf" is the only way to land the PDF at samples/.pdf + (matching the link newmeasurement.php writes into + the samples listing). + + Runs in a background daemon thread so a slow/unreachable host never + delays the calling tomo_scan(). + """ + + def _run(): + # tomo_id_manager.FALLBACK_TOMO_ID (0) means add_sample_database() + # never actually registered anything server-side (no valid + # e-account) -- there's no real "new.pdf" slot reserved for this + # session, so uploading would just claim whatever number + # countersaver.txt currently happens to hold, i.e. some unrelated + # real measurement's slot. Skip rather than risk clobbering it. + if self.tomo_id == self.tomo_id_manager.FALLBACK_TOMO_ID: + print( + f"Skipping PDF upload: tomo_id={self.tomo_id} means no real " + "measurement was registered (not a valid e-account) -- " + "there's no samples-folder slot to upload into." + ) + return + + try: + import base64 + + import requests + import urllib3 + + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except ImportError: + print("Could not upload PDF report: 'requests' library not installed.") + return + + try: + with open(pdf_path, "rb") as f: + filedata = base64.b64encode(f.read()).decode("ascii") + except OSError as exc: + print(f"Could not read PDF report for upload ({pdf_path}): {exc}") + return + + expected_name = f"{self.tomo_id}.pdf" + for host in self._SAMPLES_UPLOAD_HOSTS: + url = f"{host}/samples/upload.php" + try: + r = requests.post( + url, + data={"filename": "new.pdf", "filedata": filedata}, + timeout=20, + verify=False, # accept self-signed certs + allow_redirects=False, # SSRF hardening + ) + if r.status_code != 200: + print(f"PDF upload to {url} -> HTTP {r.status_code}: {r.text[:400]}") + continue + if expected_name not in r.text: + print( + f"PDF upload to {url} succeeded but the server-assigned " + f"filename doesn't match tomo_id {self.tomo_id} (response: " + f"{r.text[:400]!r}) -- a concurrent measurement " + "registration may have raced this upload." + ) + else: + print(f"Uploaded PDF report to {url} as {expected_name}.") + except Exception as exc: + print(f"PDF upload to {url} failed: {exc}") + + threading.Thread(target=_run, name="PdfUpload", daemon=True).start() + + def _add_psi_footer(self, pdf_writer) -> None: + """Add the PSI logo to every page's footer of a PDFWriter report. + + PDFWriter/BECPDF (bec_lib, a separate repo) has no public API for + this, and its footer() is fpdf's own automatic per-page callback + (unlike the header logo, which is drawn once inline right after + opening the PDFWriter) -- a report can span multiple pages (e.g. a + long at_each_angle_hook source dump), so the logo needs to repeat + on each one. Fully replaces bec_lib's own footer() (rather than + calling it and adding to it) so the timestamp can drop the + microseconds str(datetime.datetime.now()) includes -- same visual + layout/font otherwise (see bec_lib/pdf_writer.py's BECPDF.footer()). + """ + psi_logo = os.path.join(os.path.dirname(os.path.abspath(__file__)), "psi_logo.svg") + if not os.path.exists(psi_logo): + return + from fpdf import XPos, YPos + + pdf = pdf_writer._pdf + + def _footer_with_logo(): + pdf.set_y(-15) + pdf.image(psi_logo, x=pdf.l_margin, y=pdf.h - 22, h=6) + pdf.set_font("Courier", "", 8) + pdf.set_text_color(128) + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + pdf.cell(0, 10, f"BEC, {timestamp}", 0, new_x=XPos.RIGHT, new_y=YPos.TOP, align="L") + pdf.cell( + 0, 10, "Page " + str(pdf.page_no()), 0, new_x=XPos.RIGHT, new_y=YPos.TOP, align="R" + ) + + pdf.footer = _footer_with_logo + # ── command-job action registry / dispatch ────────────────────────────── def _validate_action_kwargs(self, action_name: str, kwargs: dict) -> None: @@ -777,7 +902,10 @@ class TomoQueueMixin: if resume_job: self.tomo_scan_resume() else: - self.tomo_scan() + # interactive=False: an unattended queued run must + # never block on input() -- see LamNI.tomo_scan()'s + # fine-alignment check. + self.tomo_scan(interactive=False) except Exception as exc: self._tomo_queue_proxy.update_by_id(job_id, status="incomplete") print(f"Tomo queue job '{label}' did not complete: {exc}") diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py index 42325de..da74795 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py @@ -11,7 +11,7 @@ from csaxs_bec.bec_ipython_client.plugins.cSAXS.intensity_map_predict_gap import from csaxs_bec.bec_ipython_client.plugins.cSAXS.slits import cSAXSSlits from csaxs_bec.bec_ipython_client.plugins.cSAXS.smaract import cSAXSInitSmaractStages from csaxs_bec.bec_ipython_client.plugins.cSAXS.smaract import cSAXSSmaract -from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import OMNYTools +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools logger = bec_logger.logger diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index cdd3958..1471c24 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -3,7 +3,6 @@ import datetime import json import os import random -import subprocess import time from pathlib import Path @@ -24,7 +23,7 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import ( TomoQueueMixin, _GlobalVarParam, ) -from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import ( +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import ( OMNYTools, PtychoReconstructor, TomoIDManager, @@ -2490,8 +2489,16 @@ class Flomni( for scan_nr in range(start_scan_number, end_scan_number): self._write_tomo_scan_number(scan_nr, angle, subtomo_number=0) - def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None): - """start a tomo scan""" + def tomo_scan( + self, subtomo_start=1, start_angle=None, projection_number=None, interactive: bool = True + ): + """start a tomo scan + + 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. + """ if not self._check_eye_out_and_optics_in(): print( @@ -2520,19 +2527,19 @@ class Flomni( ): # pylint: disable=undefined-variable - if bec.active_account != "": - self.tomo_id = self.add_sample_database( - self.sample_name, - str(datetime.date.today()), - bec.active_account, - bec.queue.next_scan_number, - "flomni", - "test additional info", - "BEC", - ) - self.write_pdf_report() - else: - self.tomo_id = 0 + # Always attempt registration (even for an empty/test account) + # and let add_sample_database() -> TomoIDManager.register() + # decide production vs. test-server vs. genuine-failure fallback + # -- see LamNI.tomo_scan()'s equivalent change for why. + self.tomo_id = self.add_sample_database( + self.sample_name, + str(datetime.date.today()), + bec.active_account or "", + bec.queue.next_scan_number, + "flomni", + "test additional info", + "BEC", + ) self.write_pdf_report() self.progress["tomo_start_time"] = datetime.datetime.now().isoformat() # reset stale estimates from any previous scan, otherwise the GUI @@ -3763,19 +3770,19 @@ class Flomni( def write_pdf_report(self): """create and write the pdf report with the current flomni settings""" dev = builtins.__dict__.get("dev") - # header = "" - header = ( - " \n" * 3 - + " .d888 888 .d88888b. 888b d888 888b 888 8888888 \n" - + ' d88P" 888 d88P" "Y88b 8888b d8888 8888b 888 888 \n' - + " 888 888 888 888 88888b.d88888 88888b 888 888 \n" - + " 888888 888 888 888 888Y88888P888 888Y88b 888 888 \n" - + " 888 888 888 888 888 Y888P 888 888 Y88b888 888 \n" - + " 888 888 888 888 888 Y8P 888 888 Y88888 888 \n" - + ' 888 888 Y88b. .d88P 888 " 888 888 Y8888 888 \n' - + ' 888 888 "Y88888P" 888 888 888 Y888 8888888 \n' - ) - padding = 20 + import csaxs_bec + + # Ensure this is a Path object, not a string + csaxs_bec_basepath = Path(csaxs_bec.__file__) + logo_file_rel = "flOMNI.png" + # Build the absolute path correctly + logo_file = ( + csaxs_bec_basepath.parent / "bec_ipython_client" / "plugins" / "flomni" / logo_file_rel + ).resolve() + # Widest label below ("Number of individual sub-tomograms:") is 36 + # chars; left-justify both label and value (no right-justify) so + # short values don't leave a big ragged gap after the label. + padding = 38 fovxy = f"{self.fovx:.1f}/{self.fovy:.1f}" stitching = f"{self.stitch_x:.0f}/{self.stitch_y:.0f}" dataset_id = str(self.client.queue.next_dataset_number) @@ -3784,61 +3791,94 @@ class Flomni( # _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() + # Same device ffzp_info() already reads. Defensive: may not be + # configured/available in every session (e.g. simulated configs). + try: + energy_kev = dev.ccm_energy.get().user_readback + energy_str = f"{energy_kev:.4f}" + except Exception: + energy_kev = None + energy_str = "N/A" + # FZP diameter/outermost-zone-width: userParameter on foptx (see + # device config) -- independent of the energy read above, so a + # failed energy read shouldn't blank these out too. + try: + fzp_diameter_um = self._get_user_param_safe("foptx", "fzp_diameter") + fzp_zone_width_nm = self._get_user_param_safe("foptx", "fzp_outermost_zone_width") + except Exception: + fzp_diameter_um = fzp_zone_width_nm = "N/A" + # FZP focal distance: same formula as ffzp_info(), from the values + # above and the current photon energy -- only this needs energy_kev. + try: + wavelength_m = 1.2398e-9 / energy_kev + focal_distance_mm = ( + fzp_diameter_um * 1e-6 * fzp_zone_width_nm * 1e-9 / wavelength_m * 1000 + ) + focal_distance_str = f"{focal_distance_mm:.2f}" + except Exception: + focal_distance_str = "N/A" + # FZP focus-to-sample distance: same live z-stage-based calculation + # ffzp_info() already uses -- not a stored value. + try: + foptz_val = dev.foptz.readback.get() + fzp_sample_distance_str = f"{-foptz_val + 43.15 + 36.7:.1f}" + except Exception: + fzp_sample_distance_str = "N/A" + # Sample-to-detector distance: userParameter on foptx, defaults to + # -1 (unknown) until someone measures and sets it. + detector_distance = self._get_user_param_safe("foptx", "detector_distance") + detector_distance_str = ( + f"{detector_distance:.1f}" if detector_distance and detector_distance > 0 else "N/A" + ) content = [ - f"{'Sample Name:':<{padding}}{self.sample_name:>{padding}}\n", - f"{'Measurement ID:':<{padding}}{str(self.tomo_id):>{padding}}\n", - f"{'Dataset ID:':<{padding}}{dataset_id:>{padding}}\n", - f"{'Sample Info:':<{padding}}{'Sample Info':>{padding}}\n", - f"{'e-account:':<{padding}}{str(account):>{padding}}\n", - f"{'Number of projections:':<{padding}}{tomo_type1_total_projections:>{padding}}\n", - f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number:>{padding}}\n", - f"{'Last scan number approx.:':<{padding}}{self.client.queue.next_scan_number + tomo_type1_total_projections + 10:>{padding}}\n", - f"{'Current photon energy:':<{padding}}To be implemented\n", - # f"{'Current photon energy:':<{padding}}{dev.mokev.read()['mokev']['value']:>{padding}.4f}\n", - f"{'Exposure time:':<{padding}}{self.tomo_countingtime:>{padding}.2f}\n", - f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:>{padding}.2f}\n", - f"{'FOV:':<{padding}}{fovxy:>{padding}}\n", - f"{'Stitching:':<{padding}}{stitching:>{padding}}\n", - f"{'Number of individual sub-tomograms:':<{padding}}{8:>{padding}}\n", - f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:>{padding}.2f}\n", + f"{'Sample Name:':<{padding}}{self.sample_name}\n", + f"{'Measurement ID:':<{padding}}{self.tomo_id}\n", + 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"{'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"{'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"{'FZP diameter:':<{padding}}{fzp_diameter_um} microns\n", + f"{'FZP outermost zone width:':<{padding}}{fzp_zone_width_nm} nm\n", + f"{'FZP focal distance:':<{padding}}{focal_distance_str} mm\n", + f"{'FZP focus-to-sample distance:':<{padding}}{fzp_sample_distance_str} mm\n", + f"{'Sample-to-detector distance:':<{padding}}{detector_distance_str} mm\n", ] hook_description = self._describe_active_hook() if hook_description: - content.append(f"{'At-each-angle hook:':<{padding}}{hook_description:>{padding}}\n") + content.append(f"{'At-each-angle hook:':<{padding}}{hook_description}\n") content = "".join(content) hook_source = self._active_hook_source() user_target = os.path.expanduser( f"~/data/raw/documentation/tomo_scan_ID_{self.tomo_id}.pdf" ) with PDFWriter(user_target) as file: - file.write(header) + self._add_psi_footer(file) + # PDFWriter (bec_lib) has no public image API -- reach into its + # underlying fpdf object directly. logo_w chosen to keep the + # header modest relative to the A4 page width (210mm). + if logo_file.exists(): + logo_w = 50 + file._pdf.image(str(logo_file), x=(210 - logo_w) / 2, w=logo_w) + file._pdf.ln(5) file.write(content) if hook_source: file.write( f"\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}" ) - # subprocess.run( - # "xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True - # ) - # status = subprocess.run(f"cp /tmp/spec-e20131-specES1.pdf {user_target}", shell=True) - # msg = bec.tomo_progress.tomo_progressMessage() - # logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LamNI_logo.png") - # msg.add_file(logo_path).add_text("".join(content).replace("\n", "

")).add_tag( - # ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "flOMNI", self.sample_name] - # ) - # self.client.tomo_progress.send_tomo_progress_message("~/data/raw/documentation/tomo_scan_ID_{self.tomo_id}.pdf").send() - import csaxs_bec + # Replaces the old upload_last_pon.sh script (broken, never rewritten) + # with a direct HTTP upload to the samples web folder. + self._upload_pdf_report_to_samples(user_target) - # Ensure this is a Path object, not a string - csaxs_bec_basepath = Path(csaxs_bec.__file__) - - logo_file_rel = "flOMNI.png" - - # Build the absolute path correctly - logo_file = ( - csaxs_bec_basepath.parent / "bec_ipython_client" / "plugins" / "flomni" / logo_file_rel - ).resolve() - print(logo_file) scilog = getattr(bec.messaging, "scilog", None) if scilog is None or not getattr(scilog, "_enabled", False): logger.warning("SciLog is not enabled; skipping PDF report entry.") diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py index a6bcb36..68c49de 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py @@ -14,7 +14,7 @@ from typeguard import typechecked from csaxs_bec.bec_ipython_client.plugins.cSAXS import cSAXSBeamlineChecks from csaxs_bec.bec_ipython_client.plugins.omny.gui_tools import OMNYGuiTools from csaxs_bec.bec_ipython_client.plugins.omny.omny_alignment_mixin import OMNYAlignmentMixin -from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import OMNYTools +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools from csaxs_bec.bec_ipython_client.plugins.omny.omny_optics_mixin import OMNYOpticsMixin from csaxs_bec.bec_ipython_client.plugins.omny.omny_rt import OMNY_rt_client from csaxs_bec.bec_ipython_client.plugins.omny.omny_sample_transfer_mixin import ( diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 7f4ed37..f58fe23 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -848,6 +848,13 @@ class TomoParamsWidget(BECWidget, QWidget): rshift = params.get("single_point_random_shift_max", 0.0) if not 0 <= rshift <= 10: return "single_point_random_shift_max must be between 0 and 10 µm" + count, min_positions = self._profile["compute_fermat_positions"](params) + if count < min_positions: + return ( + f"Estimated Fermat scan points ({count}) is below the minimum of " + f"{min_positions} required for the scan to run -- increase FOV, " + "reduce step size, or adjust stitch/piezo range before submitting." + ) return None # ── type visibility ─────────────────────────────────────────────────────── @@ -911,8 +918,10 @@ class TomoParamsWidget(BECWidget, QWidget): edited fov/step/stitch/piezo-range fields (see self._profile["compute_fermat_positions"], which calls the real scan class's own position-generation algorithm -- not a - reimplementation). Warning-only: flags orange below the scan - server's own minimum, never blocks Submit/Add-to-queue.""" + reimplementation). This is just the live preview cue (flags orange + below the scan server's own minimum); the actual minimum is + enforced as a hard block in _validate(), shared by both + submit_params() and add_edited_to_queue().""" params = {} for key in self._profile["fermat_position_fields"]: widget = self._pw.get(key) diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index b8a3f26..69fe470 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -91,6 +91,9 @@ foptx: #250 micron, 30 nm, Tomas structures # in: -14.5490625 # out: -14.1809 + fzp_diameter: 170 # microns + fzp_outermost_zone_width: 60 # nm + detector_distance: -1 # mm, sample-to-detector; unknown for now deviceTags: - ptycho_flomni diff --git a/csaxs_bec/device_configs/ptycho_lamni.yaml b/csaxs_bec/device_configs/ptycho_lamni.yaml index 7c23157..2ca412d 100644 --- a/csaxs_bec/device_configs/ptycho_lamni.yaml +++ b/csaxs_bec/device_configs/ptycho_lamni.yaml @@ -62,6 +62,10 @@ loptx: userParameter: in: -0.244 out: -0.699 + # 170 micron, 60 nm + fzp_diameter: 170 # microns + fzp_outermost_zone_width: 60 # nm + detector_distance: -1 # mm, sample-to-detector; unknown for now deviceTags: - ptycho_lamni lopty: @@ -296,6 +300,7 @@ cam_xeye: transpose: false force_monochrome: true m_n_colormode: 1 + live_mode_poll_interval_s: 0.02 enabled: true onFailure: buffer readOnly: false diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml index 6e4e185..32b7db8 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml @@ -130,6 +130,9 @@ foptx: #170 micron, 60 nm in: -13.831 out: -13.831 + fzp_diameter: 170 # microns + fzp_outermost_zone_width: 60 # nm + detector_distance: -1 # mm, sample-to-detector; unknown for now deviceTags: - simulated_flomni diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml index f4c39a4..ab07a1e 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml @@ -74,6 +74,10 @@ loptx: userParameter: in: -0.244 out: -0.699 + # 170 micron, 60 nm + fzp_diameter: 170 # microns + fzp_outermost_zone_width: 60 # nm + detector_distance: -1 # mm, sample-to-detector; unknown for now deviceTags: - simulated_lamni lopty: diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index 2ea5bdc..e429c91 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -88,6 +88,7 @@ class IDSCamera(PSIDeviceBase): num_rotation_90: int = 0, transpose: bool = False, force_monochrome: bool = False, + live_mode_poll_interval_s: float = 0.2, **kwargs, ): """Initialize the IDS Camera. @@ -100,10 +101,15 @@ class IDSCamera(PSIDeviceBase): m_n_colormode (Literal[0, 1, 2, 3]): Color mode for the camera. bits_per_pixel (Literal[8, 24]): Number of bits per pixel for the camera. live_mode (bool): Whether to enable live mode for the camera. + live_mode_poll_interval_s (float): Delay between frame grabs in + the live-mode loop. Lower this for cameras/use cases that + need a higher live-mode push rate; the achievable rate is + still bounded by the camera's own exposure/acquisition time. """ super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs) self._live_mode_thread: threading.Thread | None = None self._stop_live_mode_event: threading.Event = threading.Event() + self._live_mode_poll_interval_s = live_mode_poll_interval_s # Rolling buffer of push timestamps from _live_mode_loop, used to # measure the actual live-mode frame rate (see get_live_fps()). self._live_frame_times: deque[float] = deque(maxlen=10) @@ -206,7 +212,7 @@ class IDSCamera(PSIDeviceBase): logger.error(f"Error in live mode loop: {e}") break self._live_frame_times.append(time.time()) - stop_event.wait(0.2) # 5 Hz + stop_event.wait(self._live_mode_poll_interval_s) self.cam.set_camera_rate_limiting(False) def get_live_fps(self) -> float | None: diff --git a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py index 3a3f6b0..c9c70f8 100644 --- a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py @@ -328,6 +328,15 @@ class RtLamniController(Controller): ) # we set all three outputs of the traj. gen. although in LamNI case only 0,1 are used self.clear_trajectory_generator() + lsamrot_current = self.device_manager.devices.lsamrot.obj.readback.get() + if abs(lsamrot_current) > 10: + self.device_manager.connector.send_client_info( + f"lsamrot is at {lsamrot_current:.1f} deg -- rotating back to 0 deg as part " + "of the interferometer feedback reset. This is a long move and may take a " + "while...", + scope="feedback_enable_with_reset", + show_asap=True, + ) self.device_manager.devices.lsamrot.obj.move(0, wait=True) galil_controller_rt_status = ( @@ -413,14 +422,12 @@ class RtLamniReadbackSignal(RtLamniSignalRO): float: Readback value after adjusting for sign and motor resolution. """ return_table = (self.controller.socket_put_and_receive(f"J4")).split(",") - print(return_table) if self.parent.axis_Id_numeric == 0: readback_index = 2 elif self.parent.axis_Id_numeric == 1: readback_index = 1 else: raise RtLamniError("Currently, only two axes are supported.") - print(return_table) current_pos = float(return_table[readback_index]) current_pos *= self.parent.sign diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index 569c935..3fd1292 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -61,12 +61,6 @@ This opens the X-ray eye widget automatically. The procedure collects the sample With LamNI it can be difficult to relocate the sample between rotations. To keep the shutter open throughout, pass: `lamni.xrayeye_alignment_start(keep_shutter_open=True)` -To manually reload the fit parameters after the procedure has completed: -`lamni.read_xray_eye_correction_from_gui()` -**Note:** this reads from the live GUI widget via the `omny_xray_gui` device. It only works as long as the XRayEye GUI window remains open. If the window has been closed, reload from the archived text files instead: -`lamni.read_xray_eye_correction()` -(these files are written to `~/Data10/specES1/internal/xrayeye_alignmentvalues` at the end of every alignment run) - The correction is applied at each projection angle via `lamni.lamni_compute_additional_correction_xeye_mu(angle)` which is called automatically inside `lamni.tomo_scan_projection()`. @@ -75,9 +69,9 @@ To capture a single fresh frame without running the full alignment: `lamni.xrayeye_update_frame()` or with the shutter left open: `lamni.xrayeye_update_frame(keep_shutter_open=True)` -* If slits were opened during alignment, close the slits: `slits 1` to around 0.3 +* If slits were opened during alignment, close the slits: `slits` to around 0.3 * `lamni.leye_out()` remove the X-ray eye and move the flight tube in -* *possibly check slit0wh, idgap* +* *possibly check slit1, idgap* #### Fine alignment 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 2fff068..258dc92 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 @@ -13,7 +13,11 @@ import numpy as np import pytest import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module -from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import ( + LamNI, + _AlignmentScanProgressProxy, + _ProgressProxy, +) class FakeClient: @@ -54,12 +58,16 @@ def make_lamni(monkeypatch, xray_eye_fit=None): obj = object.__new__(LamNI) obj.client = FakeClient() obj._progress_proxy = _ProgressProxy(obj.client) + obj._alignment_scan_progress_proxy = _AlignmentScanProgressProxy(obj.client) obj.tomo_id = -1 obj.sample_name = "test" obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None) obj._scilog_calls = [] obj.write_to_scilog = lambda content, tags: obj._scilog_calls.append((content, tags)) obj.leye_out = lambda: None + obj.lamnigui_show_alignment_progress = lambda: None + obj._lamnigui_update_alignment_progress = lambda: None + obj._confirm_sequence_override = lambda *a, **k: True if xray_eye_fit is not None: obj.client.set_global_var("tomo_fit_xray_eye", xray_eye_fit) @@ -75,6 +83,7 @@ def make_lamni(monkeypatch, xray_eye_fit=None): def test_tomo_alignment_scan_aborts_without_xray_eye_fit(monkeypatch): lamni = make_lamni(monkeypatch, xray_eye_fit=None) + lamni._confirm_sequence_override = lambda *a, **k: False calls = [] lamni.tomo_scan_projection = lambda angle: calls.append(angle) diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py index 0246079..0b42093 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py @@ -169,6 +169,10 @@ def make_lamni_for_tomo_scan( obj.lamnigui_show_progress = lambda: None obj.at_each_angle_hook = None obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None) + # These tests exercise tomo_scan()'s account-handling/heartbeat/progress-GUI + # logic, not the fine-alignment confirmation gate -- bypass it so it never + # blocks on input(). + obj._confirm_sequence_override = lambda *a, **k: True monkeypatch.setitem( builtins.__dict__, "bec", @@ -184,18 +188,24 @@ def make_lamni_for_tomo_scan( return obj -def test_tomo_scan_skips_sample_database_when_no_active_account(monkeypatch): - """Empty active_account (e.g. a dev/sim session) must not crash and must - not try to register a sample -- tomo_id falls back to 0, mirroring - Flomni.tomo_scan()'s identical guard.""" +def test_tomo_scan_registers_sample_even_without_active_account(monkeypatch): + """Empty active_account (e.g. a dev/sim session) must not crash -- it is + still passed through to add_sample_database() (as ""), letting + TomoIDManager.register() decide the outcome (test-server registration) + instead of pre-empting it with a hardcoded tomo_id=0.""" lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="") - lamni.add_sample_database = lambda *a, **k: (_ for _ in ()).throw( - AssertionError("add_sample_database must not be called with no active account") - ) + recorded = {} + + def _fake_add_sample_database(samplename, date, eaccount, scan_number, setup, info, user): + recorded["eaccount"] = eaccount + return 7 + + lamni.add_sample_database = _fake_add_sample_database lamni.tomo_scan() - assert lamni.tomo_id == 0 + assert recorded["eaccount"] == "" + assert lamni.tomo_id == 7 def test_tomo_scan_registers_sample_with_plain_string_account(monkeypatch): 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 3e5a233..67c9692 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py @@ -122,7 +122,7 @@ def test_tomo_queue_execute_runs_fresh_job_then_marks_done(): lamni.tomo_queue_add(label="job1") lamni.tomo_queue_execute() - assert calls == [("scan", (), {})] + assert calls == [("scan", (), {"interactive": False})] job = lamni._tomo_queue_proxy.as_list()[0] assert job["status"] == "done" diff --git a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py index 3bbc25a..4404582 100644 --- a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py +++ b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py @@ -158,6 +158,10 @@ def _make_calibration_align(client): align.lamni.loptics_out = mock.MagicMock() align.lamni.losa_out = mock.MagicMock() align.lamni.lamnigui_show_xeyealign = mock.MagicMock() + # _sync_sample_name(prompt=True) calls lamni._get_val(), which reads from + # input() -- keep the current default (as if Enter was pressed) instead of + # blocking on stdin. + align.lamni._get_val = lambda msg, default_value, data_type: default_value # Replace the real Scans proxy (which would try to talk to a live scan # server) with a plain mock -- these tests only care that # lamni_move_to_scan_center is *called* with the right kwargs.