Compare commits

...

19 Commits

Author SHA1 Message Date
x12sa
2f6539c5d6 Automatic backup triggered by new deployment
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m54s
2026-03-24 17:11:33 +01:00
x12sa
9e84b8c510 first version of webpage
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m56s
2026-03-24 17:06:08 +01:00
x12sa
88df4781ec tags added
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m54s
2026-03-24 15:36:25 +01:00
x12sa
3b474c89c8 removed write subtomo to scilog 2026-03-24 15:34:36 +01:00
x12sa
68cc13e1d3 alignment scans to scilog 2026-03-24 15:33:14 +01:00
x12sa
700f3f9bb9 scilog tag added 2026-03-24 15:27:25 +01:00
x12sa
15a4d45f68 moved tomo_reconstruct to tomo_scan_projection
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m59s
2026-03-24 15:00:17 +01:00
x12sa
7c7f877d78 new logos for logbook 2026-03-24 14:59:50 +01:00
x12sa
5d61d756c9 logo and scilog newline fixed
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m56s
2026-03-23 17:00:28 +01:00
x12sa
b37ae3ef57 wip message to scilog when tomo starts
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m55s
2026-03-23 16:29:30 +01:00
x12sa
76ed858e5c added heartbeat, start and remaining time to progress
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m57s
2026-03-23 15:58:54 +01:00
x12sa
a0555def4d changed progress dict to global variable
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m56s
2026-03-23 15:48:17 +01:00
x12sa
c1ad2fc4c3 pdf status report fixes
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m57s
2026-03-23 12:38:37 +01:00
x12sa
9eee4ee1f7 minor fixes during testing
All checks were successful
Read the Docs Deploy Trigger / trigger-rtd-webhook (push) Successful in 2s
CI for csaxs_bec / test (push) Successful in 1m55s
2026-03-19 11:17:41 +01:00
c97b00cc8c fix: flomni async readout
All checks were successful
Read the Docs Deploy Trigger / trigger-rtd-webhook (push) Successful in 2s
CI for csaxs_bec / test (push) Successful in 1m57s
2026-03-19 11:13:26 +01:00
d6a4fd37fc fix(mcs): fix _progress_udpate
Some checks failed
Read the Docs Deploy Trigger / trigger-rtd-webhook (push) Successful in 2s
CI for csaxs_bec / test (push) Has been cancelled
2026-03-19 11:11:54 +01:00
6d4c9d90fc fix(mcs): omit_mca_callbacks if stop is called. 2026-03-19 11:11:54 +01:00
87163cc3f1 docs: run build on py 3-12
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m57s
Read the Docs Deploy Trigger / trigger-rtd-webhook (push) Successful in 4s
CI for csaxs_bec / test (push) Successful in 1m56s
2026-03-18 14:35:42 +01:00
7c17a3ae40 ci: add rtd workflow
Some checks failed
CI for csaxs_bec / test (push) Has been cancelled
2026-03-18 14:34:52 +01:00
16 changed files with 1399 additions and 222 deletions

View File

@@ -0,0 +1,21 @@
name: Read the Docs Deploy Trigger
on:
push:
branches:
- main
workflow_dispatch:
jobs:
trigger-rtd-webhook:
runs-on: ubuntu-latest
steps:
- name: Trigger Read the Docs webhook
env:
RTD_TOKEN: ${{ secrets.RTD_TOKEN }}
run: |
curl --fail --show-error --silent \
-X POST \
-d "branches=${{ github.ref_name }}" \
-d "token=${RTD_TOKEN}" \
"https://readthedocs.org/api/v2/webhook/sls-csaxs/270162/"

View File

@@ -8,15 +8,14 @@ version: 2
build:
os: ubuntu-20.04
tools:
python: "3.10"
python: "3.12"
jobs:
pre_install:
- pip install .
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
configuration: docs/conf.py
# If using Sphinx, optionally build your docs in additional formats such as PDF
# formats:
@@ -24,6 +23,5 @@ sphinx:
# Optionally declare the Python requirements required to build your docs
python:
install:
- requirements: docs/requirements.txt
install:
- requirements: docs/requirements.txt

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -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__":

View File

@@ -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")

View File

@@ -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)

View 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> &middot; STATUS</div>
<div id="last-update">updating&hellip;</div>
</header>
<div class="status-card">
<div class="status-pill" id="status-pill">-</div>
<div class="status-detail" id="status-detail">Loading&hellip;</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&hellip;</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 &middot; projection ' + d.progress.projection + ' of ' + d.progress.total_projections + ' &middot; ' + d.progress.tomo_type,
running: d => 'Queue active &middot; outside tomo heartbeat window (alignment or inter-scan gap)',
idle_short: d => 'Finished normally &middot; idle for <strong>' + d.idle_for_human + '</strong>',
idle_long: d => 'Idle for <strong>' + d.idle_for_human + '</strong> &mdash; no tomo scan running',
error: d => 'Queue stopped unexpectedly &middot; idle for <strong>' + (d.idle_for_human || '?') + '</strong>',
unknown: d => 'Status unknown &middot; waiting for first data&hellip;',
};
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>
"""

View File

@@ -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")

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

View File

@@ -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 ############################
############################################################

View File

@@ -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:
"""

View File

@@ -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

View File

@@ -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()

View File

@@ -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