Fix/minor fixes lamni #278
@@ -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
|
||||
|
||||
@@ -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", "</p><p>")).add_tag(
|
||||
["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "LamNI", self.sample_name]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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')}, "
|
||||
|
||||
+52
-34
@@ -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}."
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 50 KiB |
@@ -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 <host>/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/<tomo_id>.pdf
|
||||
(matching the <A HREF="%d.pdf"> 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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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", "</p><p>")).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.")
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user