Fix/minor fixes lamni #278

Merged
holler merged 29 commits from fix/minor-fixes-lamni into main 2026-07-28 09:56:28 +02:00
23 changed files with 823 additions and 218 deletions
@@ -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')}, "
@@ -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:
+7 -1
View File
@@ -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:
+9 -2
View File
@@ -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
+2 -8
View File
@@ -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.