Compare commits
17 Commits
fix/flomni
...
feat/flomn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f6539c5d6 | ||
|
|
9e84b8c510 | ||
|
|
88df4781ec | ||
|
|
3b474c89c8 | ||
|
|
68cc13e1d3 | ||
|
|
700f3f9bb9 | ||
|
|
15a4d45f68 | ||
|
|
7c7f877d78 | ||
|
|
5d61d756c9 | ||
|
|
b37ae3ef57 | ||
|
|
76ed858e5c | ||
|
|
a0555def4d | ||
|
|
c1ad2fc4c3 | ||
|
|
9eee4ee1f7
|
||
|
c97b00cc8c
|
|||
|
d6a4fd37fc
|
|||
|
6d4c9d90fc
|
BIN
csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI.png
Normal file
BIN
csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 562 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
BIN
csaxs_bec/bec_ipython_client/plugins/flomni/flOMNI.png
Normal file
BIN
csaxs_bec/bec_ipython_client/plugins/flomni/flOMNI.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
@@ -21,6 +21,14 @@ from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import (
|
||||
TomoIDManager,
|
||||
)
|
||||
|
||||
from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import (
|
||||
WebpageGenerator,
|
||||
VERBOSITY_SILENT, # 0 — no output
|
||||
VERBOSITY_NORMAL, # 1 — startup / stop messages only (default)
|
||||
VERBOSITY_VERBOSE, # 2 — one-line summary per cycle
|
||||
VERBOSITY_DEBUG, # 3 — full JSON payload per cycle
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if builtins.__dict__.get("bec") is not None:
|
||||
@@ -778,7 +786,9 @@ class FlomniSampleTransferMixin:
|
||||
dev.ftransy.controller.socket_put_confirmed("confirm=1")
|
||||
else:
|
||||
print("Stopping.")
|
||||
exit
|
||||
raise FlomniError(
|
||||
"User abort sample transfer."
|
||||
)
|
||||
|
||||
def ftransfer_gripper_is_open(self) -> bool:
|
||||
status = bool(float(dev.ftransy.controller.socket_put_and_receive("MG @OUT[9]").strip()))
|
||||
@@ -801,7 +811,8 @@ class FlomniSampleTransferMixin:
|
||||
def ftransfer_gripper_move(self, position: int):
|
||||
self.check_position_is_valid(position)
|
||||
|
||||
self._ftransfer_shiftx = -0.2
|
||||
#this is not used for sample stage position!
|
||||
self._ftransfer_shiftx = -0.15
|
||||
self._ftransfer_shiftz = -0.5
|
||||
|
||||
fsamx_pos = dev.fsamx.readback.get()
|
||||
@@ -821,7 +832,7 @@ class FlomniSampleTransferMixin:
|
||||
self.check_tray_in()
|
||||
|
||||
if position == 0:
|
||||
umv(dev.ftransx, 10.715 + 0.2, dev.ftransz, 3.5950)
|
||||
umv(dev.ftransx, 11, dev.ftransz, 3.5950)
|
||||
if position == 1:
|
||||
umv(
|
||||
dev.ftransx,
|
||||
@@ -966,8 +977,6 @@ class FlomniSampleTransferMixin:
|
||||
|
||||
class FlomniAlignmentMixin:
|
||||
import csaxs_bec
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure this is a Path object, not a string
|
||||
csaxs_bec_basepath = Path(csaxs_bec.__file__)
|
||||
@@ -1208,6 +1217,76 @@ class FlomniAlignmentMixin:
|
||||
return additional_correction_shift
|
||||
|
||||
|
||||
class _ProgressProxy:
|
||||
"""Dict-like proxy that persists the flOMNI progress dict as a BEC global variable.
|
||||
|
||||
Every read (`proxy["key"]`) fetches the current dict from the global var store,
|
||||
and every write (`proxy["key"] = val`) fetches, updates, and saves it back.
|
||||
This makes the progress state visible to all BEC client sessions via
|
||||
``client.get_global_var("tomo_progress")``.
|
||||
"""
|
||||
|
||||
_GLOBAL_VAR_KEY = "tomo_progress"
|
||||
_DEFAULTS: dict = {
|
||||
"subtomo": 0,
|
||||
"subtomo_projection": 0,
|
||||
"subtomo_total_projections": 1,
|
||||
"projection": 0,
|
||||
"total_projections": 1,
|
||||
"angle": 0,
|
||||
"tomo_type": 0,
|
||||
"tomo_start_time": None,
|
||||
"estimated_remaining_time": None,
|
||||
"heartbeat": None,
|
||||
}
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _load(self) -> dict:
|
||||
val = self._client.get_global_var(self._GLOBAL_VAR_KEY)
|
||||
if val is None:
|
||||
return dict(self._DEFAULTS)
|
||||
return val
|
||||
|
||||
def _save(self, data: dict) -> None:
|
||||
self._client.set_global_var(self._GLOBAL_VAR_KEY, data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dict-like interface
|
||||
# ------------------------------------------------------------------
|
||||
def __getitem__(self, key):
|
||||
return self._load()[key]
|
||||
|
||||
def __setitem__(self, key, value) -> None:
|
||||
data = self._load()
|
||||
data[key] = value
|
||||
self._save(data)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self._load()!r})"
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._load().get(key, default)
|
||||
|
||||
def update(self, *args, **kwargs) -> None:
|
||||
"""Update multiple fields in a single round-trip."""
|
||||
data = self._load()
|
||||
data.update(*args, **kwargs)
|
||||
self._save(data)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all progress fields to their default values."""
|
||||
self._save(dict(self._DEFAULTS))
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
"""Return a plain copy of the current progress state."""
|
||||
return self._load()
|
||||
|
||||
|
||||
class Flomni(
|
||||
FlomniInitStagesMixin,
|
||||
FlomniSampleTransferMixin,
|
||||
@@ -1230,14 +1309,14 @@ class Flomni(
|
||||
self.corr_angle_y = []
|
||||
self.corr_pos_y_2 = []
|
||||
self.corr_angle_y_2 = []
|
||||
self.progress = {}
|
||||
self.progress["subtomo"] = 0
|
||||
self.progress["subtomo_projection"] = 0
|
||||
self.progress["subtomo_total_projections"] = 1
|
||||
self.progress["projection"] = 0
|
||||
self.progress["total_projections"] = 1
|
||||
self.progress["angle"] = 0
|
||||
self.progress["tomo_type"] = 0
|
||||
self._progress_proxy = _ProgressProxy(self.client)
|
||||
self._progress_proxy.reset()
|
||||
self._webpage_gen = WebpageGenerator(
|
||||
bec_client=client,
|
||||
output_dir="~/data/raw/webpage/", # adjust to your staging path
|
||||
verbosity=VERBOSITY_NORMAL,
|
||||
)
|
||||
self._webpage_gen.start()
|
||||
self.OMNYTools = OMNYTools(self.client)
|
||||
self.reconstructor = PtychoReconstructor(self.ptycho_reconstruct_foldername)
|
||||
self.tomo_id_manager = TomoIDManager()
|
||||
@@ -1293,6 +1372,42 @@ class Flomni(
|
||||
self.special_angles = []
|
||||
self.special_angle_repeats = 1
|
||||
|
||||
@property
|
||||
def progress(self) -> _ProgressProxy:
|
||||
"""Proxy dict backed by the BEC global variable ``tomo_progress``.
|
||||
|
||||
Readable from any BEC client session via::
|
||||
|
||||
client.get_global_var("tomo_progress")
|
||||
|
||||
Individual fields can be read and written just like a regular dict::
|
||||
|
||||
flomni.progress["projection"] # read
|
||||
flomni.progress["projection"] = 42 # write (persists immediately)
|
||||
|
||||
To update multiple fields atomically use :py:meth:`_ProgressProxy.update`::
|
||||
|
||||
flomni.progress.update(projection=42, angle=90.0)
|
||||
|
||||
To reset all fields to their defaults::
|
||||
|
||||
flomni.progress.reset()
|
||||
"""
|
||||
return self._progress_proxy
|
||||
|
||||
@progress.setter
|
||||
def progress(self, val: dict) -> None:
|
||||
"""Replace the entire progress dict.
|
||||
|
||||
Accepts a plain :class:`dict` and persists it to the global var store.
|
||||
Example::
|
||||
|
||||
flomni.progress = {"projection": 0, "total_projections": 100, ...}
|
||||
"""
|
||||
if not isinstance(val, dict):
|
||||
raise TypeError(f"progress must be a dict, got {type(val).__name__!r}")
|
||||
self._progress_proxy._save(val)
|
||||
|
||||
@property
|
||||
def tomo_shellstep(self):
|
||||
val = self.client.get_global_var("tomo_shellstep")
|
||||
@@ -1479,21 +1594,11 @@ class Flomni(
|
||||
def sample_name(self):
|
||||
return self.sample_get_name(0)
|
||||
|
||||
def write_to_scilog(self, content, tags: list = None):
|
||||
try:
|
||||
if tags is not None:
|
||||
tags.append("BEC")
|
||||
else:
|
||||
tags = ["BEC"]
|
||||
msg = bec.logbook.LogbookMessage()
|
||||
msg.add_text(content).add_tag(tags)
|
||||
self.client.logbook.send_logbook_message(msg)
|
||||
except Exception:
|
||||
logger.warning("Failed to write to scilog.")
|
||||
|
||||
def tomo_alignment_scan(self):
|
||||
"""
|
||||
Performs a tomogram alignment scan.
|
||||
Collects all scan numbers acquired during the alignment, prints them at the end,
|
||||
and creates a BEC scilog text entry summarising the alignment scan numbers.
|
||||
"""
|
||||
if self.get_alignment_offset(0) == (0, 0, 0):
|
||||
print("It appears that the xrayeye alignemtn was not performend or loaded. Aborting.")
|
||||
@@ -1503,11 +1608,9 @@ class Flomni(
|
||||
|
||||
self.feye_out()
|
||||
tags = ["BEC_alignment_tomo", self.sample_name]
|
||||
self.write_to_scilog(
|
||||
f"Starting alignment scan. First scan number: {bec.queue.next_scan_number}.", tags
|
||||
)
|
||||
|
||||
start_angle = 0
|
||||
alignment_scan_numbers = []
|
||||
|
||||
angle_end = start_angle + 180
|
||||
for angle in np.linspace(start_angle, angle_end, num=int(180 / 45) + 1, endpoint=True):
|
||||
@@ -1519,7 +1622,6 @@ class Flomni(
|
||||
try:
|
||||
start_scan_number = bec.queue.next_scan_number
|
||||
self.tomo_scan_projection(angle)
|
||||
self.tomo_reconstruct()
|
||||
error_caught = False
|
||||
except AlarmBase as exc:
|
||||
if exc.alarm_type == "TimeoutError":
|
||||
@@ -1533,24 +1635,27 @@ class Flomni(
|
||||
|
||||
end_scan_number = bec.queue.next_scan_number
|
||||
for scan_nr in range(start_scan_number, end_scan_number):
|
||||
self._write_tomo_scan_number(scan_nr, angle, 0)
|
||||
#self._write_tomo_scan_number(scan_nr, angle, 0)
|
||||
alignment_scan_numbers.append(scan_nr)
|
||||
|
||||
umv(dev.fsamroy, 0)
|
||||
self.OMNYTools.printgreenbold(
|
||||
"\n\nAlignment scan finished. Please run SPEC_ptycho_align and load the new fit."
|
||||
"\n\nAlignment scan finished. Please run SPEC_ptycho_align and load the new fit by flomni.read_alignment_offset() ."
|
||||
)
|
||||
|
||||
def _write_subtomo_to_scilog(self, subtomo_number):
|
||||
dev = builtins.__dict__.get("dev")
|
||||
bec = builtins.__dict__.get("bec")
|
||||
if self.tomo_id > 0:
|
||||
tags = ["BEC_subtomo", self.sample_name, f"tomo_id_{self.tomo_id}"]
|
||||
else:
|
||||
tags = ["BEC_subtomo", self.sample_name]
|
||||
self.write_to_scilog(
|
||||
f"Starting subtomo: {subtomo_number}. First scan number: {bec.queue.next_scan_number}.",
|
||||
tags,
|
||||
# summary of alignment scan numbers
|
||||
scan_list_str = ", ".join(str(s) for s in alignment_scan_numbers)
|
||||
#print(f"\nAlignment scan numbers ({len(alignment_scan_numbers)} total): {scan_list_str}")
|
||||
|
||||
# BEC scilog entry (no logo)
|
||||
scilog_content = (
|
||||
f"Alignment scan finished.\n"
|
||||
f"Sample: {self.sample_name}\n"
|
||||
f"Number of alignment scans: {len(alignment_scan_numbers)}\n"
|
||||
f"Alignment scan numbers: {scan_list_str}\n"
|
||||
)
|
||||
print(scliog_content)
|
||||
bec.messaging.scilog.new().add_text(scilog_content.replace("\n", "<br>")).add_tags("alignmentscan").send()
|
||||
|
||||
def sub_tomo_scan(self, subtomo_number, start_angle=None):
|
||||
"""
|
||||
@@ -1559,18 +1664,6 @@ class Flomni(
|
||||
subtomo_number (int): The sub tomogram number.
|
||||
start_angle (float, optional): The start angle of the scan. Defaults to None.
|
||||
"""
|
||||
# dev = builtins.__dict__.get("dev")
|
||||
# bec = builtins.__dict__.get("bec")
|
||||
# if self.tomo_id > 0:
|
||||
# tags = ["BEC_subtomo", self.sample_name, f"tomo_id_{self.tomo_id}"]
|
||||
# else:
|
||||
# tags = ["BEC_subtomo", self.sample_name]
|
||||
# self.write_to_scilog(
|
||||
# f"Starting subtomo: {subtomo_number}. First scan number: {bec.queue.next_scan_number}.",
|
||||
# tags,
|
||||
# )
|
||||
|
||||
self._write_subtomo_to_scilog(subtomo_number)
|
||||
|
||||
if start_angle is not None:
|
||||
print(f"Sub tomo scan with start angle {start_angle} requested.")
|
||||
@@ -1670,6 +1763,7 @@ class Flomni(
|
||||
successful = False
|
||||
error_caught = False
|
||||
if 0 <= angle < 180.05:
|
||||
self.progress["heartbeat"] = datetime.datetime.now().isoformat()
|
||||
print(f"Starting flOMNI scan for angle {angle} in subtomo {subtomo_number}")
|
||||
self._print_progress()
|
||||
while not successful:
|
||||
@@ -1713,9 +1807,9 @@ class Flomni(
|
||||
)
|
||||
if self.OMNYTools.yesno("Shall I continue?", "n"):
|
||||
print("OK")
|
||||
else:
|
||||
print("Stopping.")
|
||||
return
|
||||
else:
|
||||
print("Stopping.")
|
||||
return
|
||||
|
||||
self.flomnigui_show_progress()
|
||||
|
||||
@@ -1743,6 +1837,8 @@ class Flomni(
|
||||
# self.write_pdf_report()
|
||||
# else:
|
||||
self.tomo_id = 0
|
||||
self.write_pdf_report()
|
||||
self.progress["tomo_start_time"] = datetime.datetime.now().isoformat()
|
||||
|
||||
with scans.dataset_id_on_hold:
|
||||
if self.tomo_type == 1:
|
||||
@@ -1762,7 +1858,6 @@ class Flomni(
|
||||
while True:
|
||||
angle, subtomo_number = self._golden(ii, self.golden_ratio_bunch_size, 180, 1)
|
||||
if previous_subtomo_number != subtomo_number:
|
||||
self._write_subtomo_to_scilog(subtomo_number)
|
||||
if (
|
||||
subtomo_number % 2 == 1
|
||||
and ii > 10
|
||||
@@ -1810,7 +1905,6 @@ class Flomni(
|
||||
ii, int(180 / self.tomo_angle_stepsize), 180, 1, 0
|
||||
)
|
||||
if previous_subtomo_number != subtomo_number:
|
||||
self._write_subtomo_to_scilog(subtomo_number)
|
||||
if (
|
||||
subtomo_number % 2 == 1
|
||||
and ii > 10
|
||||
@@ -1852,14 +1946,42 @@ class Flomni(
|
||||
self._print_progress()
|
||||
self.OMNYTools.printgreenbold("Tomoscan finished")
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(seconds: float) -> str:
|
||||
"""Format a duration in seconds as a human-readable string, e.g. '2h 03m 15s'."""
|
||||
seconds = int(seconds)
|
||||
h, remainder = divmod(seconds, 3600)
|
||||
m, s = divmod(remainder, 60)
|
||||
if h > 0:
|
||||
return f"{h}h {m:02d}m {s:02d}s"
|
||||
if m > 0:
|
||||
return f"{m}m {s:02d}s"
|
||||
return f"{s}s"
|
||||
|
||||
def _print_progress(self):
|
||||
# --- compute and store estimated remaining time -----------------------
|
||||
start_str = self.progress.get("tomo_start_time")
|
||||
projection = self.progress["projection"]
|
||||
total = self.progress["total_projections"]
|
||||
if start_str is not None and total > 0 and projection > 9:
|
||||
elapsed = (
|
||||
datetime.datetime.now() - datetime.datetime.fromisoformat(start_str)
|
||||
).total_seconds()
|
||||
rate = projection / elapsed # projections per second
|
||||
remaining_s = (total - projection) / rate
|
||||
self.progress["estimated_remaining_time"] = remaining_s
|
||||
eta_str = self._format_duration(remaining_s)
|
||||
else:
|
||||
eta_str = "N/A"
|
||||
# ----------------------------------------------------------------------
|
||||
print("\x1b[95mProgress report:")
|
||||
print(f"Tomo type: ....................... {self.progress['tomo_type']}")
|
||||
print(f"Projection: ...................... {self.progress['projection']:.0f}")
|
||||
print(f"Total projections expected ....... {self.progress['total_projections']}")
|
||||
print(f"Angle: ........................... {self.progress['angle']}")
|
||||
print(f"Current subtomo: ................. {self.progress['subtomo']}")
|
||||
print(f"Current projection within subtomo: {self.progress['subtomo_projection']}\x1b[0m")
|
||||
print(f"Current projection within subtomo: {self.progress['subtomo_projection']}")
|
||||
print(f"Estimated remaining time: ........ {eta_str}\x1b[0m")
|
||||
self._flomnigui_update_progress()
|
||||
|
||||
def add_sample_database(
|
||||
@@ -1883,7 +2005,6 @@ class Flomni(
|
||||
return
|
||||
|
||||
self.tomo_scan_projection(angle)
|
||||
self.tomo_reconstruct()
|
||||
|
||||
def _golden(self, ii, howmany_sorted, maxangle, reverse=False):
|
||||
"""returns the iis golden ratio angle of sorted bunches of howmany_sorted and its subtomo number"""
|
||||
@@ -1988,7 +2109,7 @@ class Flomni(
|
||||
f"{str(datetime.datetime.now())}: flomni scan projection at angle {angle}, scan"
|
||||
f" number {bec.queue.next_scan_number}.\n"
|
||||
)
|
||||
# self.write_to_scilog(log_message, ["BEC_scans", self.sample_name])
|
||||
|
||||
scans.flomni_fermat_scan(
|
||||
fovx=self.fovx,
|
||||
fovy=self.fovy,
|
||||
@@ -2001,6 +2122,9 @@ class Flomni(
|
||||
corridor_size=corridor_size,
|
||||
)
|
||||
|
||||
self.tomo_reconstruct()
|
||||
|
||||
|
||||
def tomo_parameters(self):
|
||||
"""print and update the tomo parameters"""
|
||||
print("Current settings:")
|
||||
@@ -2139,19 +2263,21 @@ class Flomni(
|
||||
+ ' 888 888 "Y88888P" 888 888 888 Y888 8888888 \n'
|
||||
)
|
||||
padding = 20
|
||||
fovxy = f"{self.fovx:.2f}/{self.fovy:.2f}"
|
||||
stitching = f"{self.stitch_x:.2f}/{self.stitch_y:.2f}"
|
||||
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)
|
||||
account = bec.active_account
|
||||
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"{'e-account:':<{padding}}{str(account):>{padding}}\n",
|
||||
f"{'Number of projections:':<{padding}}{int(180 / self.tomo_angle_stepsize * 8):>{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 + int(180 / self.tomo_angle_stepsize * 8) + 10:>{padding}}\n",
|
||||
f"{'Current photon energy:':<{padding}}{dev.mokev.read()['mokev']['value']:>{padding}.4f}\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",
|
||||
@@ -2160,20 +2286,38 @@ class Flomni(
|
||||
f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:>{padding}.2f}\n",
|
||||
]
|
||||
content = "".join(content)
|
||||
user_target = os.path.expanduser(f"~/Data10/documentation/tomo_scan_ID_{self.tomo_id}.pdf")
|
||||
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)
|
||||
file.write(content)
|
||||
subprocess.run(
|
||||
"xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True
|
||||
)
|
||||
# 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.logbook.LogbookMessage()
|
||||
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}", "LamNI", self.sample_name]
|
||||
)
|
||||
self.client.logbook.send_logbook_message(msg)
|
||||
# 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
|
||||
|
||||
|
||||
# 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)
|
||||
bec.messaging.scilog.new().add_attachment(logo_file).add_text(content.replace("\n", "<br>")).add_tags("tomoscan").send()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -50,8 +50,6 @@ class FlomniOpticsMixin:
|
||||
# move both axes to the desired "in" positions
|
||||
umv(dev.feyex, feyex_in, dev.feyey, feyey_in)
|
||||
|
||||
self.xrayeye_update_frame()
|
||||
|
||||
def _ffzp_in(self):
|
||||
foptx_in = self._get_user_param_safe("foptx", "in")
|
||||
fopty_in = self._get_user_param_safe("fopty", "in")
|
||||
|
||||
@@ -223,6 +223,14 @@ class flomniGuiTools:
|
||||
self._flomnigui_update_progress()
|
||||
|
||||
def _flomnigui_update_progress(self):
|
||||
"""Update the progress ring bar and center label from the current progress state.
|
||||
|
||||
``self.progress`` is backed by the BEC global variable ``tomo_progress``
|
||||
(see :class:`_ProgressProxy` in ``flomni.py``), so this method reflects
|
||||
the live state that is also accessible from other BEC client sessions via::
|
||||
|
||||
client.get_global_var("tomo_progress")
|
||||
"""
|
||||
main_progress_ring = self.progressbar.rings[0]
|
||||
subtomo_progress_ring = self.progressbar.rings[1]
|
||||
if self.progressbar is not None:
|
||||
@@ -235,6 +243,31 @@ class flomniGuiTools:
|
||||
main_progress_ring.set_value(progress)
|
||||
subtomo_progress_ring.set_value(subtomo_progress)
|
||||
|
||||
# --- format start time for display --------------------------------
|
||||
start_str = self.progress.get("tomo_start_time")
|
||||
if start_str is not None:
|
||||
import datetime as _dt
|
||||
start_display = _dt.datetime.fromisoformat(start_str).strftime("%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
start_display = "N/A"
|
||||
|
||||
# --- format estimated remaining time ------------------------------
|
||||
remaining_s = self.progress.get("estimated_remaining_time")
|
||||
if remaining_s is not None and remaining_s >= 0:
|
||||
import datetime as _dt
|
||||
remaining_s = int(remaining_s)
|
||||
h, rem = divmod(remaining_s, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
if h > 0:
|
||||
eta_display = f"{h}h {m:02d}m {s:02d}s"
|
||||
elif m > 0:
|
||||
eta_display = f"{m}m {s:02d}s"
|
||||
else:
|
||||
eta_display = f"{s}s"
|
||||
else:
|
||||
eta_display = "N/A"
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
text = (
|
||||
f"Progress report:\n"
|
||||
f" Tomo type: {self.progress['tomo_type']}\n"
|
||||
@@ -243,7 +276,9 @@ class flomniGuiTools:
|
||||
f" Angle: {self.progress['angle']:.1f}\n"
|
||||
f" Current subtomo: {self.progress['subtomo']}\n"
|
||||
f" Current projection within subtomo: {self.progress['subtomo_projection']}\n"
|
||||
f" Total projections per subtomo: {int(self.progress['subtomo_total_projections'])}"
|
||||
f" Total projections per subtomo: {int(self.progress['subtomo_total_projections'])}\n"
|
||||
f" Scan started: {start_display}\n"
|
||||
f" Est. remaining: {eta_display}"
|
||||
)
|
||||
self.progressbar.set_center_label(text)
|
||||
|
||||
|
||||
892
csaxs_bec/bec_ipython_client/plugins/flomni/webpage_generator.py
Normal file
892
csaxs_bec/bec_ipython_client/plugins/flomni/webpage_generator.py
Normal file
@@ -0,0 +1,892 @@
|
||||
"""
|
||||
webpage_generator.py
|
||||
====================
|
||||
Background thread that reads the flOMNI tomo progress from the BEC global
|
||||
variable store and writes a self-contained status.json + status.html to a
|
||||
configurable output directory. A separate upload process can copy those
|
||||
files to the web host.
|
||||
|
||||
Usage (inside Flomni.__init__, after self._progress_proxy.reset()):
|
||||
--------------------------------------------------------------------
|
||||
self._webpage_gen = WebpageGenerator(
|
||||
bec_client=client,
|
||||
output_dir="~/data/raw/webpage/",
|
||||
)
|
||||
self._webpage_gen.start()
|
||||
|
||||
Interactive commands (optional, in the iPython session):
|
||||
---------------------------------------------------------
|
||||
flomni._webpage_gen.status() # print current status
|
||||
flomni._webpage_gen.verbosity = 2 # switch to VERBOSE mid-session
|
||||
flomni._webpage_gen.stop() # release lock, let another session take over
|
||||
flomni._webpage_gen.start() # restart after stop()
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# BEC global-var key used as a distributed singleton lock
|
||||
_LOCK_VAR_KEY = "webpage_generator_lock"
|
||||
|
||||
# Heartbeat must be refreshed at least this often (seconds) or the lock
|
||||
# is considered stale and another session may take over.
|
||||
_LOCK_STALE_AFTER_S = 45
|
||||
|
||||
# How long between generator cycles (seconds)
|
||||
_CYCLE_INTERVAL_S = 15
|
||||
|
||||
# If the tomo progress heartbeat has not been updated for this long we
|
||||
# consider the tomo loop no longer actively running.
|
||||
_TOMO_HEARTBEAT_STALE_S = 90
|
||||
|
||||
# After finishing normally, stay in IDLE_SHORT for this long before
|
||||
# switching to IDLE_LONG (which triggers the audio warning).
|
||||
_IDLE_SHORT_WINDOW_S = 300 # 5 minutes
|
||||
|
||||
# Verbosity levels
|
||||
VERBOSITY_SILENT = 0 # no output at all
|
||||
VERBOSITY_NORMAL = 1 # startup/stop messages only
|
||||
VERBOSITY_VERBOSE = 2 # each cycle summary
|
||||
VERBOSITY_DEBUG = 3 # full detail each cycle
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _epoch() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
def _heartbeat_age_s(iso_str) -> float:
|
||||
"""Return seconds since the ISO-format heartbeat string, or infinity."""
|
||||
if iso_str is None:
|
||||
return float("inf")
|
||||
try:
|
||||
ts = datetime.datetime.fromisoformat(iso_str)
|
||||
return (datetime.datetime.now() - ts).total_seconds()
|
||||
except Exception:
|
||||
return float("inf")
|
||||
|
||||
|
||||
def _format_duration(seconds) -> str:
|
||||
if seconds is None:
|
||||
return "N/A"
|
||||
try:
|
||||
seconds = int(float(seconds))
|
||||
except (TypeError, ValueError):
|
||||
return "N/A"
|
||||
h, remainder = divmod(seconds, 3600)
|
||||
m, s = divmod(remainder, 60)
|
||||
if h > 0:
|
||||
return f"{h}h {m:02d}m {s:02d}s"
|
||||
if m > 0:
|
||||
return f"{m}m {s:02d}s"
|
||||
return f"{s}s"
|
||||
|
||||
|
||||
def _check_account_match(bec_client) -> bool:
|
||||
"""Return True if the BEC active account matches the system user."""
|
||||
try:
|
||||
active = bec_client.active_account # e.g. "p23092"
|
||||
system_user = os.getenv("USER") or os.getlogin() # e.g. "e23092"
|
||||
return active[1:] == system_user[1:]
|
||||
except Exception:
|
||||
return True # don't block on unknown accounts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status derivation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _derive_status(progress: dict, queue_has_active_scan: bool, idle_since) -> str:
|
||||
"""
|
||||
Derive a simple status string from available signals.
|
||||
|
||||
Returns one of:
|
||||
"scanning" - tomo heartbeat is fresh (tomo loop actively running)
|
||||
"running" - a scan is active but outside the tomo heartbeat window
|
||||
(alignment, other tasks, or brief inter-scan gap)
|
||||
"idle_short" - recently finished, within IDLE_SHORT_WINDOW_S
|
||||
"idle_long" - idle longer than IDLE_SHORT_WINDOW_S (trigger warning)
|
||||
"unknown" - cannot determine yet
|
||||
"""
|
||||
hb_age = _heartbeat_age_s(progress.get("heartbeat"))
|
||||
tomo_active = hb_age < _TOMO_HEARTBEAT_STALE_S
|
||||
|
||||
if tomo_active:
|
||||
return "scanning"
|
||||
|
||||
if queue_has_active_scan:
|
||||
return "running"
|
||||
|
||||
if idle_since is not None:
|
||||
idle_s = _epoch() - idle_since
|
||||
return "idle_short" if idle_s < _IDLE_SHORT_WINDOW_S else "idle_long"
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main generator class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WebpageGenerator:
|
||||
"""
|
||||
Singleton-safe background thread that generates the experiment status
|
||||
page by reading BEC global variables.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bec_client : BECClient
|
||||
The active BEC client instance (``bec`` in the iPython session).
|
||||
output_dir : str | Path
|
||||
Directory where ``status.json`` and ``status.html`` are written.
|
||||
Created if it does not exist.
|
||||
cycle_interval : float
|
||||
Seconds between update cycles. Default: 15 s.
|
||||
verbosity : int
|
||||
VERBOSITY_SILENT / VERBOSITY_NORMAL / VERBOSITY_VERBOSE / VERBOSITY_DEBUG.
|
||||
Default: VERBOSITY_NORMAL.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bec_client,
|
||||
output_dir: str = "~/data/raw/webpage/",
|
||||
cycle_interval: float = _CYCLE_INTERVAL_S,
|
||||
verbosity: int = VERBOSITY_NORMAL,
|
||||
):
|
||||
self._bec = bec_client
|
||||
self._output_dir = Path(output_dir).expanduser().resolve()
|
||||
self._cycle_interval = cycle_interval
|
||||
self._verbosity = verbosity
|
||||
|
||||
self._thread = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Rolling state kept between cycles
|
||||
self._idle_since = None
|
||||
self._owner_id = f"{socket.gethostname()}:{os.getpid()}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> bool:
|
||||
"""
|
||||
Start the generator thread if this session wins the singleton lock.
|
||||
Returns True if started, False if another session already owns it.
|
||||
"""
|
||||
if not _check_account_match(self._bec):
|
||||
self._log(
|
||||
VERBOSITY_NORMAL,
|
||||
"WebpageGenerator: BEC account does not match system user. "
|
||||
"Not starting to avoid writing data to the wrong account.",
|
||||
level="warning",
|
||||
)
|
||||
return False
|
||||
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
self._log(VERBOSITY_NORMAL, "WebpageGenerator already running in this session.")
|
||||
return True
|
||||
|
||||
if not self._acquire_lock():
|
||||
return False
|
||||
|
||||
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="WebpageGenerator",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
self._log(
|
||||
VERBOSITY_NORMAL,
|
||||
f"WebpageGenerator started (owner: {self._owner_id}, "
|
||||
f"output: {self._output_dir}, interval: {self._cycle_interval}s)",
|
||||
)
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the generator thread and release the singleton lock."""
|
||||
self._stop_event.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=self._cycle_interval + 5)
|
||||
self._release_lock()
|
||||
self._log(VERBOSITY_NORMAL, "WebpageGenerator stopped.")
|
||||
|
||||
@property
|
||||
def verbosity(self) -> int:
|
||||
return self._verbosity
|
||||
|
||||
@verbosity.setter
|
||||
def verbosity(self, val: int) -> None:
|
||||
self._verbosity = val
|
||||
self._log(VERBOSITY_NORMAL, f"WebpageGenerator verbosity set to {val}.")
|
||||
|
||||
def status(self) -> None:
|
||||
"""Print a human-readable status summary to the console."""
|
||||
lock = self._read_lock()
|
||||
running = self._thread is not None and self._thread.is_alive()
|
||||
print(
|
||||
f"WebpageGenerator\n"
|
||||
f" This session running : {running}\n"
|
||||
f" Lock owner : {lock.get('owner_id', 'none')}\n"
|
||||
f" Lock heartbeat : {lock.get('heartbeat', 'never')}\n"
|
||||
f" Output dir : {self._output_dir}\n"
|
||||
f" Cycle interval : {self._cycle_interval}s\n"
|
||||
f" Verbosity : {self._verbosity}\n"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Singleton lock helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _acquire_lock(self) -> bool:
|
||||
lock = self._read_lock()
|
||||
if lock:
|
||||
age = _heartbeat_age_s(lock.get("heartbeat"))
|
||||
if age < _LOCK_STALE_AFTER_S:
|
||||
self._log(
|
||||
VERBOSITY_NORMAL,
|
||||
f"WebpageGenerator already owned by "
|
||||
f"'{lock.get('owner_id')}' "
|
||||
f"(heartbeat {age:.0f}s ago). Not starting.",
|
||||
)
|
||||
return False
|
||||
self._log(
|
||||
VERBOSITY_NORMAL,
|
||||
f"Stale lock found (owner: '{lock.get('owner_id')}', "
|
||||
f"{age:.0f}s ago). Taking over.",
|
||||
)
|
||||
|
||||
self._write_lock()
|
||||
return True
|
||||
|
||||
def _write_lock(self) -> None:
|
||||
self._bec.set_global_var(
|
||||
_LOCK_VAR_KEY,
|
||||
{
|
||||
"owner_id": self._owner_id,
|
||||
"heartbeat": _now_iso(),
|
||||
"pid": os.getpid(),
|
||||
"hostname": socket.gethostname(),
|
||||
},
|
||||
)
|
||||
|
||||
def _read_lock(self) -> dict:
|
||||
val = self._bec.get_global_var(_LOCK_VAR_KEY)
|
||||
return val if isinstance(val, dict) else {}
|
||||
|
||||
def _release_lock(self) -> None:
|
||||
lock = self._read_lock()
|
||||
if lock.get("owner_id") == self._owner_id:
|
||||
self._bec.delete_global_var(_LOCK_VAR_KEY)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
cycle_start = _epoch()
|
||||
try:
|
||||
self._cycle()
|
||||
except Exception as exc:
|
||||
self._log(
|
||||
VERBOSITY_NORMAL,
|
||||
f"WebpageGenerator cycle error: {exc}",
|
||||
level="warning",
|
||||
)
|
||||
# Refresh the singleton heartbeat
|
||||
try:
|
||||
self._write_lock()
|
||||
except Exception:
|
||||
pass
|
||||
# Sleep for the remainder of the interval
|
||||
elapsed = _epoch() - cycle_start
|
||||
sleep_time = max(0.0, self._cycle_interval - elapsed)
|
||||
self._stop_event.wait(sleep_time)
|
||||
|
||||
def _cycle(self) -> None:
|
||||
"""One generator cycle: read state -> derive status -> write outputs."""
|
||||
|
||||
# --- Read progress from global var (readable from any session) -------
|
||||
progress = self._bec.get_global_var("tomo_progress") or {}
|
||||
|
||||
# --- Read queue status -----------------------------------------------
|
||||
# NOTE: queue status is always 'RUNNING' while BEC is alive.
|
||||
# An actual scan is executing only when info is non-empty AND
|
||||
# active_request_block is set on the first entry.
|
||||
try:
|
||||
queue_info = self._bec.queue.queue_storage.current_scan_queue
|
||||
primary = queue_info.get("primary")
|
||||
queue_status = primary.status if primary is not None else "unknown"
|
||||
queue_has_active_scan = (
|
||||
primary is not None
|
||||
and len(primary.info) > 0
|
||||
and primary.info[0].active_request_block is not None
|
||||
)
|
||||
except Exception:
|
||||
queue_status = "unknown"
|
||||
queue_has_active_scan = False
|
||||
|
||||
# --- Track idle onset ------------------------------------------------
|
||||
# Use both the tomo heartbeat and the queue active-scan flag.
|
||||
# This handles the brief COMPLETED gap between individual scans
|
||||
# while a tomo is still running.
|
||||
hb_age = _heartbeat_age_s(progress.get("heartbeat"))
|
||||
tomo_active = hb_age < _TOMO_HEARTBEAT_STALE_S
|
||||
|
||||
if tomo_active or queue_has_active_scan:
|
||||
self._idle_since = None
|
||||
elif self._idle_since is None:
|
||||
self._idle_since = _epoch()
|
||||
|
||||
# --- Derive experiment status ----------------------------------------
|
||||
exp_status = _derive_status(progress, queue_has_active_scan, self._idle_since)
|
||||
|
||||
# --- Build payload ---------------------------------------------------
|
||||
idle_for_s = None if self._idle_since is None else (_epoch() - self._idle_since)
|
||||
|
||||
payload = {
|
||||
"generated_at": _now_iso(),
|
||||
"generated_at_epoch": _epoch(),
|
||||
"experiment_status": exp_status,
|
||||
"queue_status": queue_status,
|
||||
"queue_has_active_scan": queue_has_active_scan,
|
||||
"idle_for_s": idle_for_s,
|
||||
"idle_for_human": _format_duration(idle_for_s),
|
||||
"progress": {
|
||||
"tomo_type": progress.get("tomo_type", "N/A"),
|
||||
"projection": progress.get("projection", 0),
|
||||
"total_projections": progress.get("total_projections", 0),
|
||||
"subtomo": progress.get("subtomo", 0),
|
||||
"subtomo_projection": progress.get("subtomo_projection", 0),
|
||||
"subtomo_total_projections": progress.get("subtomo_total_projections", 1),
|
||||
"angle": progress.get("angle", 0),
|
||||
"tomo_start_time": progress.get("tomo_start_time"),
|
||||
"estimated_remaining_s": progress.get("estimated_remaining_time"),
|
||||
"estimated_remaining_human": _format_duration(
|
||||
progress.get("estimated_remaining_time")
|
||||
),
|
||||
"heartbeat": progress.get("heartbeat"),
|
||||
"heartbeat_age_s": round(hb_age, 1) if hb_age != float("inf") else None,
|
||||
},
|
||||
"generator": {
|
||||
"owner_id": self._owner_id,
|
||||
"cycle_interval_s": self._cycle_interval,
|
||||
},
|
||||
}
|
||||
|
||||
# --- Write outputs ---------------------------------------------------
|
||||
json_path = self._output_dir / "status.json"
|
||||
json_path.write_text(json.dumps(payload, indent=2, default=str))
|
||||
|
||||
html_path = self._output_dir / "status.html"
|
||||
html_path.write_text(_render_html())
|
||||
|
||||
# --- Console feedback ------------------------------------------------
|
||||
self._log(
|
||||
VERBOSITY_VERBOSE,
|
||||
f"[{_now_iso()}] status={exp_status} active_scan={queue_has_active_scan} "
|
||||
f"proj={payload['progress']['projection']}/"
|
||||
f"{payload['progress']['total_projections']} "
|
||||
f"hb_age={payload['progress']['heartbeat_age_s']}s "
|
||||
f"idle={_format_duration(idle_for_s)}",
|
||||
)
|
||||
self._log(
|
||||
VERBOSITY_DEBUG,
|
||||
f" full payload:\n{json.dumps(payload, indent=4, default=str)}",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Logging helper
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _log(self, min_verbosity: int, msg: str, level: str = "info") -> None:
|
||||
if self._verbosity < min_verbosity:
|
||||
return
|
||||
if level == "warning":
|
||||
logger.warning(msg)
|
||||
elif level == "error":
|
||||
logger.error(msg)
|
||||
else:
|
||||
print(msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML template (static shell - the page fetches status.json on load/refresh)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_html() -> str:
|
||||
"""Return the full HTML for the status page."""
|
||||
return r"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>flOMNI - Experiment Status</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;600&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #0d0f14;
|
||||
--surface: #161a23;
|
||||
--surface2: #1e2330;
|
||||
--border: #2a3045;
|
||||
--text: #cdd6f4;
|
||||
--text-dim: #6c7a9c;
|
||||
--mono: 'Space Mono', monospace;
|
||||
--sans: 'DM Sans', sans-serif;
|
||||
--c-scanning: #89dceb;
|
||||
--c-running: #a6e3a1;
|
||||
--c-idle-short: #f9e2af;
|
||||
--c-idle-long: #fab387;
|
||||
--c-error: #f38ba8;
|
||||
--c-unknown: #6c7a9c;
|
||||
--status-color: var(--c-unknown);
|
||||
}
|
||||
|
||||
body.scanning { --status-color: var(--c-scanning); }
|
||||
body.running { --status-color: var(--c-running); }
|
||||
body.idle_short { --status-color: var(--c-idle-short);}
|
||||
body.idle_long { --status-color: var(--c-idle-long); }
|
||||
body.error { --status-color: var(--c-error); }
|
||||
body.unknown { --status-color: var(--c-unknown); }
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-weight: 300;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
transition: background 0.6s ease;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed; inset: 0;
|
||||
background: radial-gradient(ellipse 80% 50% at 50% -10%,
|
||||
color-mix(in srgb, var(--status-color) 8%, transparent), transparent);
|
||||
pointer-events: none;
|
||||
transition: background 0.8s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.layout {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.logo {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.logo span { color: var(--status-color); transition: color 0.6s; }
|
||||
#last-update { font-family: var(--mono); font-size: 0.7rem; color: var(--text-dim); }
|
||||
|
||||
.status-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--status-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
transition: border-color 0.6s;
|
||||
}
|
||||
.status-pill {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bg);
|
||||
background: var(--status-color);
|
||||
padding: 0.3rem 0.9rem;
|
||||
border-radius: 100px;
|
||||
white-space: nowrap;
|
||||
transition: background 0.6s;
|
||||
}
|
||||
.status-detail { flex: 1; font-size: 0.9rem; color: var(--text-dim); line-height: 1.6; }
|
||||
.status-detail strong { color: var(--text); font-weight: 600; }
|
||||
|
||||
.progress-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 1.5rem 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rings-wrap { position: relative; width: 120px; height: 120px; flex-shrink: 0; }
|
||||
.rings-wrap svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
||||
.ring-track { fill: none; stroke: var(--surface2); }
|
||||
.ring-outer {
|
||||
fill: none; stroke: var(--status-color); stroke-linecap: round; opacity: 0.9;
|
||||
transition: stroke-dashoffset 0.8s cubic-bezier(.4,0,.2,1), stroke 0.6s;
|
||||
}
|
||||
.ring-inner {
|
||||
fill: none;
|
||||
stroke: color-mix(in srgb, var(--status-color) 55%, var(--surface2));
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.8s cubic-bezier(.4,0,.2,1), stroke 0.6s;
|
||||
}
|
||||
.ring-label {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.ring-label .pct { font-size: 1.3rem; font-weight: 700; color: var(--text); }
|
||||
.ring-label .sublbl { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.06em; }
|
||||
|
||||
.progress-info { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem 2rem; }
|
||||
.info-item { display: flex; flex-direction: column; gap: 0.15rem; }
|
||||
.info-item .label {
|
||||
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
}
|
||||
.info-item .value { font-size: 0.95rem; font-weight: 600; color: var(--text); }
|
||||
|
||||
.bar-wrap { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.bar-label {
|
||||
display: flex; justify-content: space-between;
|
||||
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
}
|
||||
.bar-track { height: 6px; background: var(--surface2); border-radius: 99px; overflow: hidden; }
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--status-color) 70%, var(--surface2));
|
||||
border-radius: 99px;
|
||||
transition: width 0.8s cubic-bezier(.4,0,.2,1), background 0.6s;
|
||||
}
|
||||
|
||||
.audio-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 1.25rem 2rem; display: flex; align-items: center;
|
||||
justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
||||
}
|
||||
.audio-info { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.audio-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim); transition: background 0.3s; }
|
||||
.audio-dot.active { background: var(--c-scanning); box-shadow: 0 0 6px var(--c-scanning); }
|
||||
.audio-text { font-size: 0.85rem; color: var(--text-dim); }
|
||||
.audio-controls { display: flex; gap: 0.75rem; }
|
||||
|
||||
button {
|
||||
font-family: var(--mono); font-size: 0.7rem; font-weight: 700;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
border: 1px solid var(--border); background: var(--surface2); color: var(--text);
|
||||
padding: 0.4rem 1rem; border-radius: 6px; cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
button:hover { background: var(--border); }
|
||||
button.active { background: var(--status-color); border-color: var(--status-color); color: var(--bg); }
|
||||
|
||||
footer {
|
||||
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
||||
border-top: 1px solid var(--border); padding-top: 1rem;
|
||||
display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#audio-gate {
|
||||
position: fixed; inset: 0; background: rgba(13,15,20,0.85);
|
||||
backdrop-filter: blur(6px); display: flex; align-items: center;
|
||||
justify-content: center; z-index: 100; cursor: pointer;
|
||||
}
|
||||
#audio-gate.hidden { display: none; }
|
||||
.gate-box {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 2.5rem 3rem; text-align: center; max-width: 400px;
|
||||
}
|
||||
.gate-box h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||
.gate-box p { font-size: 0.85rem; color: var(--text-dim); margin-bottom: 1.5rem; }
|
||||
.gate-btn {
|
||||
font-family: var(--mono); font-size: 0.8rem; font-weight: 700;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
background: var(--status-color); color: var(--bg); border: none;
|
||||
padding: 0.7rem 2rem; border-radius: 8px; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="unknown">
|
||||
|
||||
<div id="audio-gate">
|
||||
<div class="gate-box">
|
||||
<h2>flOMNI Status Page</h2>
|
||||
<p>Click to enable the page. Audio warnings require a user interaction to activate.</p>
|
||||
<button class="gate-btn" onclick="unlockAudio()">Open Status Page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
|
||||
<header>
|
||||
<div class="logo">fl<span>OMNY</span> · STATUS</div>
|
||||
<div id="last-update">updating…</div>
|
||||
</header>
|
||||
|
||||
<div class="status-card">
|
||||
<div class="status-pill" id="status-pill">-</div>
|
||||
<div class="status-detail" id="status-detail">Loading…</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-card">
|
||||
<div class="rings-wrap">
|
||||
<svg viewBox="0 0 120 120">
|
||||
<circle class="ring-track" cx="60" cy="60" r="50" stroke-width="8"/>
|
||||
<circle class="ring-outer" id="ring-outer"
|
||||
cx="60" cy="60" r="50" stroke-width="8"
|
||||
stroke-dasharray="314.16" stroke-dashoffset="314.16"/>
|
||||
<circle class="ring-track" cx="60" cy="60" r="37" stroke-width="7"/>
|
||||
<circle class="ring-inner" id="ring-inner"
|
||||
cx="60" cy="60" r="37" stroke-width="7"
|
||||
stroke-dasharray="232.48" stroke-dashoffset="232.48"/>
|
||||
</svg>
|
||||
<div class="ring-label">
|
||||
<span class="pct" id="ring-pct">0%</span>
|
||||
<span class="sublbl">OVERALL</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-info">
|
||||
<div class="info-item">
|
||||
<span class="label">Projection</span>
|
||||
<span class="value" id="pi-proj">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Sub-tomo</span>
|
||||
<span class="value" id="pi-subtomo">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Angle</span>
|
||||
<span class="value" id="pi-angle">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Tomo type</span>
|
||||
<span class="value" id="pi-type">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">ETA</span>
|
||||
<span class="value" id="pi-eta">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Started</span>
|
||||
<span class="value" id="pi-start">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bar-wrap">
|
||||
<div class="bar-label">
|
||||
<span>Sub-tomo progress</span>
|
||||
<span id="bar-sub-label">-</span>
|
||||
</div>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" id="bar-sub-fill" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audio-card">
|
||||
<div class="audio-info">
|
||||
<div class="audio-dot" id="audio-dot"></div>
|
||||
<span class="audio-text" id="audio-text">Audio warnings: initialising…</span>
|
||||
</div>
|
||||
<div class="audio-controls">
|
||||
<button id="btn-toggle-audio" onclick="toggleAudio()">Enable</button>
|
||||
<button onclick="testSound()">Test sound</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<span id="footer-gen">generator: -</span>
|
||||
<span id="footer-queue">queue active: -</span>
|
||||
<span id="footer-hb">heartbeat: -</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const STATUS_JSON = 'status.json';
|
||||
const POLL_INTERVAL = 15000;
|
||||
const WARN_STATUSES = new Set(['idle_long', 'error', 'unknown']);
|
||||
|
||||
// Audio
|
||||
let audioCtx = null;
|
||||
let audioEnabled = (localStorage.getItem('audioEnabled') !== 'false');
|
||||
let warningTimer = null;
|
||||
|
||||
function getCtx() {
|
||||
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
return audioCtx;
|
||||
}
|
||||
|
||||
function beep(freq, dur, vol) {
|
||||
try {
|
||||
const ctx = getCtx(), osc = ctx.createOscillator(), gain = ctx.createGain();
|
||||
osc.connect(gain); gain.connect(ctx.destination);
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, ctx.currentTime);
|
||||
gain.gain.setValueAtTime(vol, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
|
||||
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + dur);
|
||||
} catch(e) { console.warn('Audio:', e); }
|
||||
}
|
||||
|
||||
function warningChime() { beep(660, 0.3, 0.4); setTimeout(() => beep(440, 0.4, 0.4), 350); }
|
||||
function testSound() { beep(880,0.15,0.4); setTimeout(()=>beep(1100,0.15,0.4),180); setTimeout(()=>beep(880,0.3,0.4),360); }
|
||||
|
||||
function toggleAudio() {
|
||||
audioEnabled = !audioEnabled;
|
||||
localStorage.setItem('audioEnabled', audioEnabled);
|
||||
updateAudioUI();
|
||||
if (!audioEnabled) stopWarning();
|
||||
}
|
||||
|
||||
function updateAudioUI() {
|
||||
const btn = document.getElementById('btn-toggle-audio');
|
||||
const dot = document.getElementById('audio-dot');
|
||||
const txt = document.getElementById('audio-text');
|
||||
if (audioEnabled) {
|
||||
btn.textContent = 'Disable'; btn.classList.add('active');
|
||||
dot.classList.add('active'); txt.textContent = 'Audio warnings: enabled';
|
||||
} else {
|
||||
btn.textContent = 'Enable'; btn.classList.remove('active');
|
||||
dot.classList.remove('active'); txt.textContent = 'Audio warnings: disabled';
|
||||
}
|
||||
}
|
||||
|
||||
function startWarning() {
|
||||
if (warningTimer) return;
|
||||
if (audioEnabled) warningChime();
|
||||
warningTimer = setInterval(() => { if (audioEnabled) warningChime(); }, 30000);
|
||||
}
|
||||
function stopWarning() { if (warningTimer) { clearInterval(warningTimer); warningTimer = null; } }
|
||||
|
||||
function unlockAudio() {
|
||||
getCtx().resume();
|
||||
document.getElementById('audio-gate').classList.add('hidden');
|
||||
updateAudioUI();
|
||||
poll();
|
||||
}
|
||||
|
||||
// Rendering
|
||||
const LABELS = {
|
||||
scanning: 'SCANNING',
|
||||
running: 'RUNNING',
|
||||
idle_short: 'IDLE',
|
||||
idle_long: 'IDLE - CHECK',
|
||||
error: 'STOPPED',
|
||||
unknown: 'UNKNOWN',
|
||||
};
|
||||
const DETAILS = {
|
||||
scanning: d => 'Tomo scan in progress · projection ' + d.progress.projection + ' of ' + d.progress.total_projections + ' · ' + d.progress.tomo_type,
|
||||
running: d => 'Queue active · outside tomo heartbeat window (alignment or inter-scan gap)',
|
||||
idle_short: d => 'Finished normally · idle for <strong>' + d.idle_for_human + '</strong>',
|
||||
idle_long: d => 'Idle for <strong>' + d.idle_for_human + '</strong> — no tomo scan running',
|
||||
error: d => 'Queue stopped unexpectedly · idle for <strong>' + (d.idle_for_human || '?') + '</strong>',
|
||||
unknown: d => 'Status unknown · waiting for first data…',
|
||||
};
|
||||
|
||||
function setRing(id, circ, pct) {
|
||||
document.getElementById(id).style.strokeDashoffset = circ * (1 - Math.min(pct, 1));
|
||||
}
|
||||
function fmtAngle(v) { const n = parseFloat(v); return isNaN(n) ? '-' : n.toFixed(2) + '\u00b0'; }
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '-';
|
||||
try { return new Date(iso).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); }
|
||||
catch { return iso; }
|
||||
}
|
||||
|
||||
function render(d) {
|
||||
const s = d.experiment_status || 'unknown';
|
||||
const p = d.progress || {};
|
||||
|
||||
document.body.className = s;
|
||||
document.getElementById('status-pill').textContent = LABELS[s] || s.toUpperCase();
|
||||
document.getElementById('status-detail').innerHTML = (DETAILS[s] || (()=>s))(d);
|
||||
|
||||
const oPct = p.total_projections > 0 ? p.projection / p.total_projections : 0;
|
||||
const sPct = p.subtomo_total_projections > 0 ? p.subtomo_projection / p.subtomo_total_projections : 0;
|
||||
setRing('ring-outer', 314.16, oPct);
|
||||
setRing('ring-inner', 232.48, sPct);
|
||||
document.getElementById('ring-pct').textContent = Math.round(oPct * 100) + '%';
|
||||
|
||||
document.getElementById('pi-proj').textContent = (p.projection || 0) + ' / ' + (p.total_projections || 0);
|
||||
document.getElementById('pi-subtomo').textContent = p.subtomo || '-';
|
||||
document.getElementById('pi-angle').textContent = fmtAngle(p.angle);
|
||||
document.getElementById('pi-type').textContent = p.tomo_type || '-';
|
||||
document.getElementById('pi-eta').textContent = p.estimated_remaining_human || '-';
|
||||
document.getElementById('pi-start').textContent = fmtTime(p.tomo_start_time);
|
||||
|
||||
document.getElementById('bar-sub-label').textContent = (p.subtomo_projection || 0) + ' / ' + (p.subtomo_total_projections || 0);
|
||||
document.getElementById('bar-sub-fill').style.width = (sPct * 100).toFixed(1) + '%';
|
||||
|
||||
document.getElementById('last-update').textContent = 'updated ' + new Date(d.generated_at).toLocaleTimeString();
|
||||
document.getElementById('footer-gen').textContent = 'generator: ' + (d.generator && d.generator.owner_id || '-');
|
||||
document.getElementById('footer-queue').textContent = 'queue active: ' + d.queue_has_active_scan;
|
||||
document.getElementById('footer-hb').textContent = 'heartbeat: ' + (p.heartbeat_age_s != null ? p.heartbeat_age_s + 's ago' : 'none');
|
||||
|
||||
WARN_STATUSES.has(s) ? startWarning() : stopWarning();
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const r = await fetch(STATUS_JSON + '?t=' + Date.now());
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
render(await r.json());
|
||||
} catch(e) {
|
||||
console.warn('Fetch failed:', e);
|
||||
document.getElementById('last-update').textContent = 'fetch failed - retrying...';
|
||||
}
|
||||
}
|
||||
|
||||
updateAudioUI();
|
||||
setInterval(poll, POLL_INTERVAL);
|
||||
// First poll triggered by unlockAudio() after the gate is dismissed
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -253,6 +253,8 @@ class XrayEyeAlign:
|
||||
|
||||
umv(dev.rtx, 0)
|
||||
print("You are ready to remove the xray eye and start ptychography scans.")
|
||||
print("Fine alignment: flomni.tomo_parameters() , then flomni.tomo_alignment_scan()")
|
||||
print("After that, run the fit in Matlab and load the new fit flomni.read_alignment_offset()")
|
||||
|
||||
def write_output(self):
|
||||
file = os.path.expanduser("~/Data10/specES1/internal/xrayeye_alignmentvalues")
|
||||
|
||||
BIN
csaxs_bec/bec_ipython_client/plugins/omny/OMNY.png
Normal file
BIN
csaxs_bec/bec_ipython_client/plugins/omny/OMNY.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 359 KiB |
@@ -395,6 +395,16 @@ rtz:
|
||||
readoutPriority: on_request
|
||||
connectionTimeout: 20
|
||||
|
||||
# rt_flyer:
|
||||
# deviceClass: csaxs_bec.devices.omny.rt.rt_flomni_ophyd.RtFlomniFlyer
|
||||
# deviceConfig:
|
||||
# host: mpc2844.psi.ch
|
||||
# port: 2222
|
||||
# readoutPriority: async
|
||||
# connectionTimeout: 20
|
||||
# enabled: true
|
||||
# readOnly: False
|
||||
|
||||
############################################################
|
||||
####################### Cameras ############################
|
||||
############################################################
|
||||
|
||||
@@ -317,8 +317,6 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard):
|
||||
try:
|
||||
scan_done = bool(value == self._num_total_triggers)
|
||||
self.progress.put(value=value, max_value=self._num_total_triggers, done=scan_done)
|
||||
if scan_done:
|
||||
self._scan_done_event.set()
|
||||
except Exception:
|
||||
content = traceback.format_exc()
|
||||
logger.info(f"Device {self.name} error: {content}")
|
||||
@@ -393,6 +391,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard):
|
||||
self._current_data_index = 0
|
||||
|
||||
# NOTE Make sure that the signal that omits mca callbacks is cleared
|
||||
# DO NOT REMOVE!!
|
||||
self._omit_mca_callbacks.clear()
|
||||
|
||||
# For a fly scan we need to start the mcs card ourselves
|
||||
@@ -563,8 +562,9 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard):
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""Hook called when the device is stopped. In addition, any status that is registered through cancel_on_stop will be cancelled here."""
|
||||
self.stop_all.put(1)
|
||||
self.erase_all.put(1)
|
||||
with suppress_mca_callbacks(self):
|
||||
self.stop_all.put(1)
|
||||
self.erase_all.put(1)
|
||||
|
||||
def mcs_recovery(self, timeout: int = 1) -> None:
|
||||
"""
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import threading
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger, messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib import bec_logger
|
||||
from ophyd import Component as Cpt
|
||||
from ophyd import Device, PositionerBase, Signal
|
||||
from ophyd.status import wait as status_wait
|
||||
from ophyd.utils import LimitError
|
||||
from ophyd_devices import AsyncMultiSignal, DeviceStatus, ProgressSignal
|
||||
from ophyd_devices.utils.controller import Controller, threadlocked
|
||||
from ophyd_devices.utils.socket import SocketIO, raise_if_disconnected
|
||||
from prettytable import PrettyTable
|
||||
|
||||
from csaxs_bec.devices.omny.rt.rt_ophyd import (
|
||||
BECConfigError,
|
||||
RtCommunicationError,
|
||||
RtError,
|
||||
RtReadbackSignal,
|
||||
@@ -432,27 +430,6 @@ class RtFlomniController(Controller):
|
||||
t.add_row([i, self.read_ssi_interferometer(i)])
|
||||
print(t)
|
||||
|
||||
def _get_signals_from_table(self, return_table) -> dict:
|
||||
self.average_stdeviations_x_st_fzp += float(return_table[4])
|
||||
self.average_stdeviations_y_st_fzp += float(return_table[7])
|
||||
signals = {
|
||||
"target_x": {"value": float(return_table[2])},
|
||||
"average_x_st_fzp": {"value": float(return_table[3])},
|
||||
"stdev_x_st_fzp": {"value": float(return_table[4])},
|
||||
"target_y": {"value": float(return_table[5])},
|
||||
"average_y_st_fzp": {"value": float(return_table[6])},
|
||||
"stdev_y_st_fzp": {"value": float(return_table[7])},
|
||||
"average_rotz": {"value": float(return_table[8])},
|
||||
"stdev_rotz": {"value": float(return_table[9])},
|
||||
"average_stdeviations_x_st_fzp": {
|
||||
"value": self.average_stdeviations_x_st_fzp / (int(return_table[0]) + 1)
|
||||
},
|
||||
"average_stdeviations_y_st_fzp": {
|
||||
"value": self.average_stdeviations_y_st_fzp / (int(return_table[0]) + 1)
|
||||
},
|
||||
}
|
||||
return signals
|
||||
|
||||
@threadlocked
|
||||
def start_scan(self):
|
||||
if not self.feedback_is_running():
|
||||
@@ -492,91 +469,6 @@ class RtFlomniController(Controller):
|
||||
current_position_in_scan = int(float(return_table[2]))
|
||||
return (mode, number_of_positions_planned, current_position_in_scan)
|
||||
|
||||
def read_positions_from_sampler(self):
|
||||
# this was for reading after the scan completed
|
||||
number_of_samples_to_read = 1 # self.get_scan_status()[1] #number of valid samples, will be updated upon first data read
|
||||
|
||||
read_counter = 0
|
||||
|
||||
self.average_stdeviations_x_st_fzp = 0
|
||||
self.average_stdeviations_y_st_fzp = 0
|
||||
self.average_lamni_angle = 0
|
||||
|
||||
mode, number_of_positions_planned, current_position_in_scan = self.get_scan_status()
|
||||
|
||||
# if not (mode==2 or mode==3):
|
||||
# error
|
||||
self.device_manager.connector.set(
|
||||
MessageEndpoints.device_status("rt_scan"),
|
||||
messages.DeviceStatusMessage(
|
||||
device="rt_scan", status=1, metadata=self.readout_metadata
|
||||
),
|
||||
)
|
||||
# while scan is running
|
||||
while mode > 0:
|
||||
|
||||
# TODO here?: scan abortion if no progress in scan *raise error
|
||||
|
||||
# logger.info(f"Current scan position {current_position_in_scan} out of {number_of_positions_planned}")
|
||||
mode, number_of_positions_planned, current_position_in_scan = self.get_scan_status()
|
||||
time.sleep(0.01)
|
||||
if current_position_in_scan > 5:
|
||||
while current_position_in_scan > read_counter + 1:
|
||||
return_table = (self.socket_put_and_receive(f"r{read_counter}")).split(",")
|
||||
# logger.info(f"{return_table}")
|
||||
logger.info(f"Read {read_counter} out of {number_of_positions_planned}")
|
||||
|
||||
read_counter = read_counter + 1
|
||||
|
||||
signals = self._get_signals_from_table(return_table)
|
||||
|
||||
self.publish_device_data(signals=signals, point_id=int(return_table[0]))
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
# read the last samples even though scan is finished already
|
||||
while number_of_positions_planned > read_counter:
|
||||
return_table = (self.socket_put_and_receive(f"r{read_counter}")).split(",")
|
||||
logger.info(f"Read {read_counter} out of {number_of_positions_planned}")
|
||||
# logger.info(f"{return_table}")
|
||||
read_counter = read_counter + 1
|
||||
|
||||
signals = self._get_signals_from_table(return_table)
|
||||
self.publish_device_data(signals=signals, point_id=int(return_table[0]))
|
||||
|
||||
self.device_manager.connector.set(
|
||||
MessageEndpoints.device_status("rt_scan"),
|
||||
messages.DeviceStatusMessage(
|
||||
device="rt_scan", status=0, metadata=self.readout_metadata
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Flomni statistics: Average of all standard deviations: x"
|
||||
f" {self.average_stdeviations_x_st_fzp/read_counter*1000:.1f}, y"
|
||||
f" {self.average_stdeviations_y_st_fzp/read_counter*1000:.1f}"
|
||||
)
|
||||
|
||||
def publish_device_data(self, signals, point_id):
|
||||
self.device_manager.connector.set_and_publish(
|
||||
MessageEndpoints.device_read("rt_flomni"),
|
||||
messages.DeviceMessage(
|
||||
signals=signals, metadata={"point_id": point_id, **self.readout_metadata}
|
||||
),
|
||||
)
|
||||
|
||||
def start_readout(self):
|
||||
readout = threading.Thread(target=self.read_positions_from_sampler)
|
||||
readout.start()
|
||||
|
||||
def kickoff(self, metadata):
|
||||
self.readout_metadata = metadata
|
||||
while not self._min_scan_buffer_reached:
|
||||
time.sleep(0.001)
|
||||
self.start_scan()
|
||||
time.sleep(0.1)
|
||||
self.start_readout()
|
||||
|
||||
|
||||
class RtFlomniReadbackSignal(RtReadbackSignal):
|
||||
@retry_once
|
||||
@@ -844,6 +736,185 @@ class RtFlomniMotor(Device, PositionerBase):
|
||||
return super().stop(success=success)
|
||||
|
||||
|
||||
class RtFlomniFlyer(Device):
|
||||
USER_ACCESS = ["controller"]
|
||||
data = Cpt(
|
||||
AsyncMultiSignal,
|
||||
name="data",
|
||||
signals=[
|
||||
"target_x",
|
||||
"average_x_st_fzp",
|
||||
"stdev_x_st_fzp",
|
||||
"target_y",
|
||||
"average_y_st_fzp",
|
||||
"stdev_y_st_fzp",
|
||||
"average_rotz",
|
||||
"stdev_rotz",
|
||||
"average_stdeviations_x_st_fzp",
|
||||
"average_stdeviations_y_st_fzp",
|
||||
],
|
||||
ndim=1,
|
||||
async_update={"type": "add", "max_shape": [None]},
|
||||
max_size=1000,
|
||||
)
|
||||
progress = Cpt(
|
||||
ProgressSignal, doc="ProgressSignal indicating the progress of the device during a scan."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prefix="",
|
||||
*,
|
||||
name,
|
||||
kind=None,
|
||||
read_attrs=None,
|
||||
configuration_attrs=None,
|
||||
parent=None,
|
||||
host="mpc2844.psi.ch",
|
||||
port=2222,
|
||||
socket_cls=SocketIO,
|
||||
device_manager=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(prefix=prefix, name=name, parent=parent, **kwargs)
|
||||
self.shutdown_event = threading.Event()
|
||||
self.controller = RtFlomniController(
|
||||
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
|
||||
)
|
||||
self.average_stdeviations_x_st_fzp = 0
|
||||
self.average_stdeviations_y_st_fzp = 0
|
||||
self.average_lamni_angle = 0
|
||||
self.readout_thread = None
|
||||
self.scan_done_event = threading.Event()
|
||||
self.scan_done_event.set()
|
||||
|
||||
def read_positions_from_sampler(self, status: DeviceStatus):
|
||||
"""
|
||||
Read the positions from the sampler and update the data signal.
|
||||
This function runs in a separate thread and continuously checks the
|
||||
scan status.
|
||||
|
||||
Args:
|
||||
status (DeviceStatus): The status object to update when the readout is complete.
|
||||
"""
|
||||
read_counter = 0
|
||||
self.average_stdeviations_x_st_fzp = 0
|
||||
self.average_stdeviations_y_st_fzp = 0
|
||||
self.average_lamni_angle = 0
|
||||
|
||||
mode, number_of_positions_planned, current_position_in_scan = (
|
||||
self.controller.get_scan_status()
|
||||
)
|
||||
|
||||
# while scan is running
|
||||
while mode > 0 and not self.shutdown_event.wait(0.01):
|
||||
# logger.info(f"Current scan position {current_position_in_scan} out of {number_of_positions_planned}")
|
||||
mode, number_of_positions_planned, current_position_in_scan = (
|
||||
self.controller.get_scan_status()
|
||||
)
|
||||
if current_position_in_scan > 5:
|
||||
while current_position_in_scan > read_counter + 1:
|
||||
return_table = (
|
||||
self.controller.socket_put_and_receive(f"r{read_counter}")
|
||||
).split(",")
|
||||
logger.info(f"Read {read_counter} out of {number_of_positions_planned}")
|
||||
self.progress.put(
|
||||
value=read_counter, max_value=number_of_positions_planned, done=False
|
||||
)
|
||||
read_counter = read_counter + 1
|
||||
signals = self._get_signals_from_table(return_table)
|
||||
self.data.set(signals)
|
||||
|
||||
if self.shutdown_event.wait(0.05):
|
||||
logger.info("Shutdown event set, stopping readout.")
|
||||
# if we are here, the shutdown_event is set. We can exit the readout loop.
|
||||
status.set_finished()
|
||||
return
|
||||
|
||||
# read the last samples even though scan is finished already
|
||||
while number_of_positions_planned > read_counter and not self.shutdown_event.is_set():
|
||||
return_table = (self.controller.socket_put_and_receive(f"r{read_counter}")).split(",")
|
||||
logger.info(f"Read {read_counter} out of {number_of_positions_planned}")
|
||||
self.progress.put(value=read_counter, max_value=number_of_positions_planned, done=False)
|
||||
read_counter = read_counter + 1
|
||||
|
||||
signals = self._get_signals_from_table(return_table)
|
||||
self.data.set(signals)
|
||||
|
||||
# NOTE: No need to set the status to failed if the shutdown_event is set.
|
||||
# The stop() method will take care of that.
|
||||
status.set_finished()
|
||||
self.progress.put(value=read_counter, max_value=number_of_positions_planned, done=True)
|
||||
|
||||
logger.info(
|
||||
"Flomni statistics: Average of all standard deviations: x"
|
||||
f" {self.average_stdeviations_x_st_fzp/read_counter*1000:.1f}, y"
|
||||
f" {self.average_stdeviations_y_st_fzp/read_counter*1000:.1f}"
|
||||
)
|
||||
|
||||
def _get_signals_from_table(self, return_table) -> dict:
|
||||
self.average_stdeviations_x_st_fzp += float(return_table[4])
|
||||
self.average_stdeviations_y_st_fzp += float(return_table[7])
|
||||
signals = {
|
||||
"target_x": {"value": float(return_table[2])},
|
||||
"average_x_st_fzp": {"value": float(return_table[3])},
|
||||
"stdev_x_st_fzp": {"value": float(return_table[4])},
|
||||
"target_y": {"value": float(return_table[5])},
|
||||
"average_y_st_fzp": {"value": float(return_table[6])},
|
||||
"stdev_y_st_fzp": {"value": float(return_table[7])},
|
||||
"average_rotz": {"value": float(return_table[8])},
|
||||
"stdev_rotz": {"value": float(return_table[9])},
|
||||
"average_stdeviations_x_st_fzp": {
|
||||
"value": self.average_stdeviations_x_st_fzp / (int(return_table[0]) + 1)
|
||||
},
|
||||
"average_stdeviations_y_st_fzp": {
|
||||
"value": self.average_stdeviations_y_st_fzp / (int(return_table[0]) + 1)
|
||||
},
|
||||
}
|
||||
return signals
|
||||
|
||||
def stage(self):
|
||||
self.shutdown_event.clear()
|
||||
self.scan_done_event.set()
|
||||
return super().stage()
|
||||
|
||||
def start_readout(self, status: DeviceStatus):
|
||||
self.readout_thread = threading.Thread(
|
||||
target=self.read_positions_from_sampler, args=(status,)
|
||||
)
|
||||
self.readout_thread.start()
|
||||
|
||||
def kickoff(self) -> DeviceStatus:
|
||||
self.shutdown_event.clear()
|
||||
self.scan_done_event.clear()
|
||||
while not self.controller._min_scan_buffer_reached and not self.shutdown_event.wait(0.001):
|
||||
...
|
||||
self.controller.start_scan()
|
||||
self.shutdown_event.wait(0.1)
|
||||
status = DeviceStatus(self)
|
||||
status.set_finished()
|
||||
return status
|
||||
|
||||
def complete(self) -> DeviceStatus:
|
||||
"""Wait until the flyer is done."""
|
||||
if self.scan_done_event.is_set():
|
||||
# if the scan_done_event is already set, we can return a finished status immediately
|
||||
status = DeviceStatus(self)
|
||||
status.set_finished()
|
||||
return status
|
||||
status = DeviceStatus(self)
|
||||
self.start_readout(status)
|
||||
status.add_callback(lambda *args, **kwargs: self.scan_done_event.set())
|
||||
return status
|
||||
|
||||
def stop(self, *, success=False):
|
||||
self.shutdown_event.set()
|
||||
self.scan_done_event.set()
|
||||
if self.readout_thread is not None:
|
||||
self.readout_thread.join()
|
||||
return super().stop(success=success)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
rtcontroller = RtFlomniController(
|
||||
socket_cls=SocketIO, socket_host="mpc2844.psi.ch", socket_port=2222, device_manager=None
|
||||
|
||||
@@ -27,20 +27,19 @@ from bec_lib import bec_logger, messages
|
||||
from bec_lib.alarm_handler import Alarms
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_server.scan_server.errors import ScanAbortion
|
||||
from bec_server.scan_server.scans import SyncFlyScanBase
|
||||
from bec_server.scan_server.scans import AsyncFlyScanBase
|
||||
|
||||
from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import TRIGGERSOURCE
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class FlomniFermatScan(SyncFlyScanBase):
|
||||
class FlomniFermatScan(AsyncFlyScanBase):
|
||||
scan_name = "flomni_fermat_scan"
|
||||
scan_type = "fly"
|
||||
required_kwargs = ["fovx", "fovy", "exp_time", "step", "angle"]
|
||||
arg_input = {}
|
||||
arg_bundle_size = {"bundle": len(arg_input), "min": None, "max": None}
|
||||
use_scan_progress_report = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -104,6 +103,14 @@ class FlomniFermatScan(SyncFlyScanBase):
|
||||
self.zshift = -100
|
||||
self.flomni_rotation_status = None
|
||||
|
||||
def scan_report_instructions(self):
|
||||
"""Scan report instructions for the progress bar"""
|
||||
yield from self.stubs.scan_report_instruction({"device_progress": ["rt_flyer"]})
|
||||
|
||||
@property
|
||||
def monitor_sync(self) -> str:
|
||||
return "rt_flyer"
|
||||
|
||||
def initialize(self):
|
||||
self.scan_motors = []
|
||||
self.update_readout_priority()
|
||||
@@ -113,10 +120,6 @@ class FlomniFermatScan(SyncFlyScanBase):
|
||||
self.positions, corridor_size=self.optim_trajectory_corridor
|
||||
)
|
||||
|
||||
@property
|
||||
def monitor_sync(self):
|
||||
return "rt_flomni"
|
||||
|
||||
def reverse_trajectory(self):
|
||||
"""
|
||||
Reverse the trajectory. Every other scan should be reversed to
|
||||
@@ -290,26 +293,18 @@ class FlomniFermatScan(SyncFlyScanBase):
|
||||
return np.array(positions)
|
||||
|
||||
def scan_core(self):
|
||||
# use a device message to receive the scan number and
|
||||
# scan ID before sending the message to the device server
|
||||
yield from self.stubs.kickoff(device="rtx")
|
||||
while True:
|
||||
yield from self.stubs.read(group="monitored")
|
||||
status = self.connector.get(MessageEndpoints.device_status("rt_scan"))
|
||||
if status:
|
||||
status_id = status.content.get("status", 1)
|
||||
request_id = status.metadata.get("RID")
|
||||
if status_id == 0 and self.metadata.get("RID") == request_id:
|
||||
break
|
||||
if status_id == 2 and self.metadata.get("RID") == request_id:
|
||||
raise ScanAbortion(
|
||||
"An error occured during the flomni readout:"
|
||||
f" {status.metadata.get('error')}"
|
||||
)
|
||||
# send off the flyer
|
||||
yield from self.stubs.kickoff(device="rt_flyer")
|
||||
|
||||
# start the readout loop of the flyer
|
||||
status = yield from self.stubs.complete(device="rt_flyer", wait=False)
|
||||
|
||||
# read the monitors until the flyer is done
|
||||
while not status.done:
|
||||
yield from self.stubs.read(group="monitored", point_id=self.point_id)
|
||||
self.point_id += 1
|
||||
time.sleep(1)
|
||||
logger.debug("reading monitors")
|
||||
# yield from self.device_rpc("rtx", "controller.kickoff")
|
||||
|
||||
def move_to_start(self):
|
||||
"""return to the start position"""
|
||||
@@ -336,6 +331,7 @@ class FlomniFermatScan(SyncFlyScanBase):
|
||||
yield from self.read_scan_motors()
|
||||
self.prepare_positions()
|
||||
yield from self._prepare_setup()
|
||||
yield from self.scan_report_instructions()
|
||||
yield from self.open_scan()
|
||||
yield from self.stage()
|
||||
yield from self.run_baseline_reading()
|
||||
|
||||
@@ -217,6 +217,16 @@ def test_mcs_card_csaxs_complete_and_stop(mock_mcs_csaxs: MCSCardCSAXS):
|
||||
assert not mcs._start_monitor_async_data_emission.is_set()
|
||||
|
||||
|
||||
def test_mcs_on_stop(mock_mcs_csaxs: MCSCardCSAXS):
|
||||
"""Test that on stop sets the omit_mca_callbacks flag. Also test that on stage clears the omit_mca_callbacks flag."""
|
||||
mcs = mock_mcs_csaxs
|
||||
assert mcs._omit_mca_callbacks.is_set() is False
|
||||
mcs.stop()
|
||||
assert mcs._omit_mca_callbacks.is_set() is True
|
||||
mcs.stage()
|
||||
assert mcs._omit_mca_callbacks.is_set() is False
|
||||
|
||||
|
||||
def test_mcs_recovery(mock_mcs_csaxs: MCSCardCSAXS):
|
||||
mcs = mock_mcs_csaxs
|
||||
# Simulate ongoing acquisition
|
||||
|
||||
Reference in New Issue
Block a user