diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/covering_letter_cSAXS_HR01_and_HR02.pdf b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/covering_letter_cSAXS_HR01_and_HR02.pdf new file mode 100644 index 0000000..43c3f99 Binary files /dev/null and b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/covering_letter_cSAXS_HR01_and_HR02.pdf differ diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 9da179b..af0f580 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -380,7 +380,7 @@ class FlomniInitStagesMixin: umv(dev.fsamy, flomni_samy_in) # after init reduce vertical stage speed - dev.fsamy.controller.socket_put_confirmed("axspeed[5]=20000") + dev.fsamy.controller.socket_put_confirmed("axspeed[5]=5000") umv(dev.feyey, -8) @@ -1751,9 +1751,12 @@ class Flomni( start_angle (float, optional): The start angle of the scan. Defaults to None. """ - if start_angle is not None: + explicit_start_angle = start_angle is not None + if explicit_start_angle: print(f"Sub tomo scan with start angle {start_angle} requested.") + max_allowed_angle = self.tomo_angle_range + 0.05 + self.tomo_angle_stepsize + if start_angle is None: if subtomo_number == 1: start_angle = 0 @@ -1772,22 +1775,31 @@ class Flomni( elif subtomo_number == 8: start_angle = self.tomo_angle_stepsize / 8.0 * 7 + if not subtomo_number % 2: # even = reverse + # The table above gives the LOW end of this sub-tomogram's + # angular phase (same convention as the forward/odd + # sub-tomograms - it's what makes the combined 8 sub-tomograms + # interlace into one fine angular grid). A reverse sweep must + # begin at the HIGH end of that span and descend, so shift the + # freshly-computed phase up by one full angular range. This + # step is skipped when start_angle is given explicitly (i.e. + # we are resuming mid sub-tomogram), since then the value is + # already the literal current angle. + start_angle = min(start_angle + self.tomo_angle_range, max_allowed_angle) + # _tomo_shift_angles (potential global variable) _tomo_shift_angles = 0 # compute number of projections start = start_angle + _tomo_shift_angles - if subtomo_number % 2: # odd = forward - max_allowed_angle = self.tomo_angle_range + 0.05 + self.tomo_angle_stepsize - proposed_end = start + self.tomo_angle_range - angle_end = min(proposed_end, max_allowed_angle) + if subtomo_number % 2: # odd = forward: low -> high + angle_end = min(start + self.tomo_angle_range, max_allowed_angle) span = angle_end - start - else: # even = reverse + else: # even = reverse: high -> low min_allowed_angle = 0 - proposed_end = start - self.tomo_angle_range - angle_end = max(proposed_end, min_allowed_angle) + angle_end = max(start - self.tomo_angle_range, min_allowed_angle) span = start - angle_end # number of projections needed to maintain step size @@ -1795,22 +1807,6 @@ class Flomni( angles = np.linspace(start, angle_end, num=N, endpoint=True) - if subtomo_number % 2: # odd subtomos → forward direction - # clamp end angle to max allowed - max_allowed_angle = self.tomo_angle_range + 0.05 + self.tomo_angle_stepsize - proposed_end = start + self.tomo_angle_range - angle_end = min(proposed_end, max_allowed_angle) - - angles = np.linspace(start, angle_end, num=N, endpoint=True) - - else: # even subtomos → reverse direction - # go FROM start_angle down toward 0 - min_allowed_angle = 0 - proposed_end = start - self.tomo_angle_range - angle_end = max(proposed_end, min_allowed_angle) - - angles = np.linspace(start, angle_end, num=N, endpoint=True) - for i, angle in enumerate(angles): self.progress["subtomo"] = subtomo_number @@ -1818,17 +1814,16 @@ class Flomni( # --- NEW LOGIC FOR OFFSET WHEN start_angle IS SPECIFIED --- if i == 0: step = self.tomo_angle_stepsize - sa = start_angle - if start_angle is None: + if not explicit_start_angle: # normal operation: always start at zero self._subtomo_offset = 0 else: if subtomo_number % 2: # odd = forward direction - self._subtomo_offset = round(sa / step) + self._subtomo_offset = round(start_angle / step) else: # even = reverse direction - self._subtomo_offset = round((self.tomo_angle_range - sa) / step) + self._subtomo_offset = round((self.tomo_angle_range - start_angle) / step) # progress index must always increase self.progress["subtomo_projection"] = self._subtomo_offset + i @@ -2147,13 +2142,17 @@ class Flomni( return angle, subtomo_number - def tomo_reconstruct(self, base_path="~/data/raw/logs/reconstruction_queue"): + + def tomo_reconstruct( + self, base_path="~/data/raw/logs/reconstruction_queue", probe_propagation: float | None = None + ): """write the tomo reconstruct file for the reconstruction queue""" bec = builtins.__dict__.get("bec") self.reconstructor.write( scan_list=self._current_scan_list, next_scan_number=bec.queue.next_scan_number, base_path=base_path, + probe_file_propagation=probe_propagation, ) def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None: @@ -2183,6 +2182,9 @@ class Flomni( + self.manual_shift_y ) sum_offset_z = offsets[2] + #TODO this fix is while the tracker z is broken + probe_propagation = -sum_offset_z * 1e-6 + sum_offset_z = 0 self._current_scan_list = [] @@ -2221,7 +2223,7 @@ class Flomni( scans.flomni_fermat_scan(**scan_kwargs) - self.tomo_reconstruct() + self.tomo_reconstruct(probe_propagation=probe_propagation) def tomo_acquire_at_angle(self, angle: float, frames_per_trigger: int | None = None): """ @@ -2357,6 +2359,7 @@ class Flomni( self.frames_per_trigger = self._get_val( "Frames per trigger (burst)", self.frames_per_trigger, int ) + self.manual_shift_y = self._get_val(" um", self.manual_shift_y, float) self.single_point_instead_of_fermat_scan = bool( self._get_val( "Single point instead of fermat scan (acquire at angle) 1/0?", diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py index 510294b..0ac68d8 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py @@ -30,12 +30,14 @@ class FlomniOpticsMixin: # move rotation stage to zero to avoid problems with wires umv(dev.fsamroy, 0) - # umv(dev.fttrx1, 9.2) + fttrx_in = self._get_user_param_safe("feyex", "fttrx_in") + umv(dev.fttrx1, fttrx_in) def feye_in(self): bec.queue.next_dataset_number += 1 - # umv(dev.fttrx1, -17) - + fttrx_out = self._get_user_param_safe("feyex", "fttrx_out") + umv(dev.fttrx1, fttrx_out) + feyex_in = self._get_user_param_safe("feyex", "in") feyey_in = self._get_user_param_safe("feyey", "in") @@ -148,7 +150,6 @@ class FlomniOpticsMixin: # --- expected IN positions --- foptx_in = self._get_user_param_safe("foptx", "in") fopty_in = self._get_user_param_safe("fopty", "in") - foptz_in = self._get_user_param_safe("foptz", "in") # --- expected OUT condition for the X-ray eye --- # eye is OUT when it is *not within tolerance* of its IN position @@ -159,7 +160,6 @@ class FlomniOpticsMixin: cx_foptx = dev.foptx.readback.get() cx_fopty = dev.fopty.readback.get() - cx_foptz = dev.foptz.readback.get() # --- check eye OUT --- eye_out = ( @@ -169,8 +169,7 @@ class FlomniOpticsMixin: # --- check optics IN --- optics_in = ( np.isclose(cx_foptx, foptx_in, atol=tol) and - np.isclose(cx_fopty, fopty_in, atol=tol) and - np.isclose(cx_foptz, foptz_in, atol=tol) + np.isclose(cx_fopty, fopty_in, atol=tol) ) fosax_in = self._get_user_param_safe("fosax", "in") @@ -205,7 +204,7 @@ class FlomniOpticsMixin: if mokev_val == -1: try: - mokev_val = dev.mokev.readback.get() + mokev_val = dev.ccm_energy.get().user_readback except: print( "Device mokev does not exist. You can specify the energy in keV as an argument instead." diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 6ea3b10..e6694d1 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -179,6 +179,102 @@ def _gvar(bec_client, key, fmt=None, suffix=""): except Exception: return str(val) +def _read_beamline_states_info(bec_client) -> dict: + """Return a diagnostic snapshot of all configured beamline states, for + display only (NOT used to derive the 'blocked' experiment status — that + is computed from the queue's own locks via _read_queue_locks(), which is + the authoritative source; see its docstring for why). + + Returns: + { + "enabled": bool | None, # bec.builtin_actors.scan_interlock.enabled + "states": [ + { + "name": str, + "status": str, # 'valid' | 'invalid' | 'warning' | 'unknown' + "label": str, + "watched": bool, # True if present in states_watched + "accepted": list[str] | None, # accepted statuses if watched + "mismatched": bool, # True if watched AND status not in accepted + }, + ... + ], + } + + Beamline states are registered dynamically and can differ from + experiment to experiment, so names are read from the manager's _states + dict (the same one show_all() iterates over internally — there is no + public "list names" method as of this BEC version). states_watched and + enabled are read through the public scan_interlock HLI properties. + """ + manager = getattr(bec_client, "beamline_states", None) + if manager is None: + return {"enabled": None, "states": []} + + try: + names = list(getattr(manager, "_states", {}).keys()) + except Exception: + names = [] + + try: + interlock = bec_client.builtin_actors.scan_interlock + enabled = interlock.enabled + states_watched = interlock.states_watched or {} + except Exception: + enabled = None + states_watched = {} + + states = [] + for name in names: + try: + state_obj = getattr(manager, name, None) + if state_obj is None: + continue + info = state_obj.get() # {"status": ..., "label": ...} + status = info.get("status", "unknown") + label = info.get("label", name) + except Exception: + status, label = "unknown", name + + accepted = states_watched.get(name) + watched = accepted is not None + mismatched = watched and status not in accepted + + states.append({ + "name": name, + "status": status, + "label": label, + "watched": watched, + "accepted": accepted, + "mismatched": mismatched, + }) + + return {"enabled": enabled, "states": states} + + +def _read_queue_locks(primary) -> list: + """Return a list of {"identifier": str, "reason": str} for every lock + currently applied to the primary scan queue. + + BEC's scan_queue.status becomes "LOCKED" (saving the prior status to + restore later) whenever any lock is added, e.g. by the ScanInterlockActor + when a watched beamline state goes out of spec (see scan_interlock.py: + add_queue_lock(queue="primary", reason=..., lock_id="ScanInterlockActor")). + Reading the lock list directly is more robust than re-deriving "blocked" + from beamline states ourselves: it reflects whatever is actually holding + the queue, regardless of which actor (interlock or otherwise) caused it, + and survives changes to which beamline states are configured/watched. + """ + if primary is None: + return [] + try: + return [ + {"identifier": lock.identifier, "reason": lock.reason} + for lock in (primary.locks or []) + ] + except Exception: + return [] + # --------------------------------------------------------------------------- # Status derivation @@ -189,19 +285,27 @@ def _derive_status( queue_has_active_scan: bool, last_active_time, had_activity: bool, + queue_locks: list | None = None, ) -> str: """ Returns one of: scanning -- tomo heartbeat fresh (< _TOMO_HEARTBEAT_STALE_S), OR heartbeat recently seen AND queue still has active scan (handles long individual projections > heartbeat timeout) - running -- queue has active scan but heartbeat has never been seen + blocked -- queue has active scan, heartbeat stale (beyond the long- + projection grace window), AND the primary queue has at + least one lock applied (e.g. by BEC's ScanInterlockActor + when a watched beamline state goes out of spec). Only + reported when it is actually preventing a queued/active + scan from progressing. + running -- queue has active scan but heartbeat has never been seen, + and the queue is not locked idle -- not scanning, last_active_time known unknown -- no activity ever seen since generator started 'unknown' is ONLY returned before any scan activity has been observed. Once activity has been seen the status goes directly: - scanning/running -> idle + scanning/running/blocked -> idle never through 'unknown'. """ hb_age = _heartbeat_age_s(progress.get("heartbeat")) @@ -213,6 +317,8 @@ def _derive_status( if queue_has_active_scan and hb_age < _TOMO_HEARTBEAT_STALE_S * 10: return "scanning" if queue_has_active_scan: + if queue_locks: + return "blocked" return "running" if last_active_time is not None or had_activity: return "idle" @@ -852,7 +958,10 @@ class WebpageGeneratorBase: progress = self._bec.get_global_var("tomo_progress") or {} # ── Queue ──────────────────────────────────────────────────── - # queue_status is always 'RUNNING' while BEC is alive. + # queue_status reflects BEC's ScanQueueStatus: 'RUNNING', 'PAUSED', + # or 'LOCKED' (the latter set whenever any lock — e.g. from + # ScanInterlockActor — is applied to the queue; the prior status is + # restored once the lock is released). # A scan is actually executing only when info is non-empty AND # active_request_block is set on the first entry. try: @@ -864,9 +973,30 @@ class WebpageGeneratorBase: and len(primary.info) > 0 and primary.info[0].active_request_block is not None ) + queue_locks = _read_queue_locks(primary) except Exception: queue_status = "unknown" queue_has_active_scan = False + queue_locks = [] + + # ── Beamline states (diagnostic display only; not used for the + # 'blocked' experiment_status decision — see _read_queue_locks) ── + try: + beamline_states_info = _read_beamline_states_info(self._bec) + except Exception as exc: + self._log(VERBOSITY_VERBOSE, + f"beamline_states read error: {exc}", level="warning") + beamline_states_info = {"enabled": None, "states": []} + + # Current scan number (the BEC scan in progress right now, e.g. for + # comparison against ptycho reconstruction filenames like S06770). + # Only meaningful while a scan is actually active; None otherwise. + current_scan_number = None + if queue_has_active_scan: + try: + current_scan_number = primary.info[0].active_request_block.scan_number + except Exception: + current_scan_number = None # ── Idle tracking ──────────────────────────────────────────── hb_age = _heartbeat_age_s(progress.get("heartbeat")) @@ -893,6 +1023,7 @@ class WebpageGeneratorBase: exp_status = _derive_status( progress, queue_has_active_scan, self._last_active_time, self._had_activity, + queue_locks, ) idle_for_s = ( None if self._last_active_time is None @@ -928,6 +1059,8 @@ class WebpageGeneratorBase: "queue_has_active_scan": queue_has_active_scan, "idle_for_s": idle_for_s, "idle_for_human": _format_duration(idle_for_s), + "queue_locks": queue_locks, + "beamline_states": beamline_states_info, "progress": { "tomo_type": progress.get("tomo_type", "N/A"), "projection": progress.get("projection", 0), @@ -937,10 +1070,13 @@ class WebpageGeneratorBase: "subtomo_total_projections": progress.get("subtomo_total_projections", 1), "angle": progress.get("angle", 0), "tomo_start_time": progress.get("tomo_start_time"), + "tomo_start_scan_number": progress.get("tomo_start_scan_number"), + "current_scan_number": current_scan_number, "estimated_remaining_s": progress.get("estimated_remaining_time"), "estimated_remaining_human": _format_duration( progress.get("estimated_remaining_time") ), + "estimated_finish_time": progress.get("estimated_finish_time"), "tomo_heartbeat": progress.get("heartbeat"), "tomo_heartbeat_age_s": round(hb_age, 1) if hb_age != float("inf") else None, }, @@ -985,12 +1121,16 @@ class WebpageGeneratorBase: self._uploader.upload_changed_async(self._output_dir) # ── Console feedback ────────────────────────────────────────── + blocked_summary = ( + f" locked_by={','.join(l['identifier'] for l in queue_locks)}" + if queue_locks else "" + ) self._log(VERBOSITY_VERBOSE, f"[{_now_iso()}] {exp_status:<12} active={queue_has_active_scan} " f"proj={payload['progress']['projection']}/" f"{payload['progress']['total_projections']} " f"hb={payload['progress']['tomo_heartbeat_age_s']}s " - f"idle={_format_duration(idle_for_s)}") + f"idle={_format_duration(idle_for_s)}" + blocked_summary) self._log(VERBOSITY_DEBUG, f" payload:\n{json.dumps(payload, indent=4, default=str)}") @@ -1331,6 +1471,8 @@ def _render_html(phone_numbers: list) -> str: --c-scanning: #89dceb; --c-running: #a6e3a1; --c-idle-short: #f9e2af; + --c-idle-long: #f38ba8; + --c-blocked: #fab387; --c-error: #f38ba8; --status-color: #6c7a9c; --ring-blend: #4a5568; @@ -1373,6 +1515,7 @@ def _render_html(phone_numbers: list) -> str: body.scanning {{ --status-color: var(--c-scanning); --ring-blend: #3a6b74; }} body.running {{ --status-color: var(--c-running); --ring-blend: #3a6644; }} body.idle {{ --status-color: var(--c-idle-short); --ring-blend: #7a6e44; }} + body.blocked {{ --status-color: var(--c-blocked); --ring-blend: #7a5a3a; }} body.error {{ --status-color: var(--c-error); --ring-blend: #7a3a44; }} body.unknown {{ --status-color: var(--text-dim); --ring-blend: #4a5568; }} @@ -1585,6 +1728,36 @@ def _render_html(phone_numbers: list) -> str: border-radius: 4px; box-shadow: 0 8px 40px rgba(0,0,0,0.6); }} + /* Help dialog */ + #help-dialog {{ + background: var(--surface); color: var(--text); + border: 1px solid var(--border); border-radius: 8px; + padding: 1.5rem 1.75rem; max-width: 560px; width: 90vw; + max-height: 80vh; overflow-y: auto; + font-family: var(--sans); font-weight: 300; + }} + #help-dialog::backdrop {{ background: rgba(0,0,0,0.6); }} + .help-header {{ + display: flex; align-items: center; justify-content: space-between; + border-bottom: 1px solid var(--border); padding-bottom: 0.75rem; margin-bottom: 1rem; + }} + .help-close {{ + background: none; border: none; color: var(--text-dim); + font-size: 1.4rem; line-height: 1; padding: 0 0.3rem; min-height: auto; + cursor: pointer; + }} + .help-close:hover {{ color: var(--text); background: none; }} + .help-body h3 {{ + font-family: var(--mono); font-size: 0.7rem; font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); + margin: 1.1rem 0 0.5rem; + }} + .help-body h3:first-child {{ margin-top: 0; }} + .help-body p {{ font-size: 0.85rem; line-height: 1.6; color: var(--text-dim); margin-bottom: 0.5rem; }} + .help-body ul {{ margin: 0 0 0.5rem 1.1rem; padding: 0; }} + .help-body li {{ font-size: 0.85rem; line-height: 1.6; color: var(--text-dim); margin-bottom: 0.4rem; }} + .help-body strong {{ color: var(--text); font-weight: 600; }} + /* ── Instrument details ── */ .instrument-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem 2rem; @@ -1602,6 +1775,38 @@ def _render_html(phone_numbers: list) -> str: .kv-table td:last-child {{ color: var(--text); font-weight: 600; text-align: right; }} .temp-null {{ color: var(--text-dim) !important; font-weight: 400 !important; }} + /* ── Beamline states ── */ + .blstates-summary {{ + font-size: 0.82rem; color: var(--text-dim); margin-bottom: 0.9rem; + }} + .blstates-summary strong {{ color: var(--text); font-weight: 600; }} + .blstates-table {{ width: 100%; border-collapse: collapse; }} + .blstates-table th {{ + font-family: var(--mono); font-size: 0.6rem; font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); + text-align: left; padding: 0 0.6rem 0.5rem 0; border-bottom: 1px solid var(--border); + }} + .blstates-table td {{ + padding: 0.45rem 0.6rem 0.45rem 0; font-size: 0.82rem; + border-bottom: 1px solid var(--border); vertical-align: middle; + }} + .blstates-table tr:last-child td {{ border-bottom: none; }} + .blstates-table tr.mismatched td {{ color: var(--c-blocked); }} + .bl-name {{ font-family: var(--mono); font-size: 0.78rem; color: var(--text); white-space: nowrap; }} + .bl-label {{ color: var(--text-dim); }} + .bl-badge {{ + display: inline-block; font-family: var(--mono); font-size: 0.6rem; font-weight: 700; + letter-spacing: 0.06em; text-transform: uppercase; padding: 0.15rem 0.5rem; + border-radius: 100px; white-space: nowrap; + }} + .bl-badge.bl-valid {{ background: color-mix(in srgb, var(--c-running) 22%, transparent); color: var(--c-running); }} + .bl-badge.bl-invalid {{ background: color-mix(in srgb, var(--c-error) 22%, transparent); color: var(--c-error); }} + .bl-badge.bl-warning {{ background: color-mix(in srgb, var(--c-idle-short) 25%, transparent); color: var(--c-idle-short); }} + .bl-badge.bl-unknown {{ background: var(--surface2); color: var(--text-dim); }} + .bl-badge.bl-watched-yes {{ background: color-mix(in srgb, var(--c-scanning) 18%, transparent); color: var(--c-scanning); }} + .bl-badge.bl-watched-no {{ background: var(--surface2); color: var(--text-dim); }} + .blstates-none {{ font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim); padding: 0.5rem 0; }} + /* ── Audio card ── */ .audio-card {{ display: flex; align-items: center; @@ -1628,6 +1833,9 @@ def _render_html(phone_numbers: list) -> str: .led.led-warning {{ background: var(--c-idle-long); border-color: var(--c-idle-long); box-shadow: 0 0 8px var(--c-idle-long); animation: led-pulse 1s ease-in-out infinite; }} + .led.led-blocked {{ background: var(--c-blocked); border-color: var(--c-blocked); + box-shadow: 0 0 8px var(--c-blocked); + animation: led-pulse 1s ease-in-out infinite; }} @keyframes led-pulse {{ 0%,100% {{ opacity: 1; }} 50% {{ opacity: 0.4; }} }} @@ -1716,6 +1924,7 @@ def _render_html(phone_numbers: list) -> str: · STATUS
+
Theme @@ -1753,7 +1962,8 @@ def _render_html(phone_numbers: list) -> str:
Sub-tomo-
Angle-
Tomo type-
-
ETA-
+
Remaining-
+
Finish time-
Started-
@@ -1765,6 +1975,39 @@ def _render_html(phone_numbers: list) -> str:
+ + +
+ About this page + +
+
+

Measurement status

+

The coloured pill at the top reflects what the instrument is currently doing:

+
    +
  • Scanning — a tomography scan is actively running (a heartbeat from the scan loop has been seen recently).
  • +
  • Running — the scan queue has an active item, but it is not a tomo scan with a heartbeat (e.g. an alignment scan).
  • +
  • Blocked — a scan is queued or active, but the scan queue itself has been locked (for example by BEC's scan interlock when a watched beamline condition, such as the shutter or ring current, is out of spec). This is usually external and self-resolves once the condition clears.
  • +
  • Idle — nothing is running or queued right now.
  • +
  • Unknown — shown only before any activity has been observed since the generator started.
  • +
+

Tomography progress

+

The rings show overall progress (outer) and progress within the current sub-tomogram (inner). Remaining is the estimated duration left; Finish time is the estimated wall-clock time the measurement will complete. The projection count is followed by the BEC scan number(s) in parentheses, e.g. (S06650→S06770), for direct comparison with reconstruction filenames.

+

Audio warnings

+
    +
  • System LED — on when audio is enabled on this device.
  • +
  • Watch LED — armed (green) while a scan is running; pulses orange if a scan stops unexpectedly, with a chime every 30s until confirmed.
  • +
  • Blocked LED — pulses orange while the queue is locked. A chime fires automatically after 60 continuous minutes blocked, then repeats hourly until cleared. No confirmation needed — it clears itself once the block resolves.
  • +
  • Live LED — green while this page's data feed is fresh; pulses orange if the feed goes stale or a fetch fails.
  • +
+

On iPhone/iPad, tap Enable once to unlock audio — this is a browser requirement and only needs to be done once per visit.

+

Beamline states

+

Lists every beamline condition currently registered with BEC, its live status (valid/invalid/warning/unknown), and whether the scan interlock is watching it. A watched state that is not in its accepted status is what actually causes the Blocked status above — states that are not watched (like an unrelated simulated shutter) can be invalid without blocking anything.

+

Other features

+

Cards below the fixed status/progress section can be dragged by their ⋮⋮ handle to reorder; the order is remembered on this device. The theme switcher (top right) follows your OS by default, or can be forced to light/dark.

+
+
+
@@ -1780,6 +2023,10 @@ def _render_html(phone_numbers: list) -> str:
Watch
+
+
+ Blocked +
Live @@ -1822,7 +2069,20 @@ def _render_html(phone_numbers: list) -> str:
- + +
+
⋮⋮
+
Beamline states
+
+ + + + + +
StateStatusWatchedLabel
+
+ +
⋮⋮
Contacts
@@ -1860,7 +2120,7 @@ function setTheme(t) {{ // ── Drag-and-drop card ordering ────────────────────────────────────────── const CARD_ORDER_KEY = 'cardOrder'; -const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','contacts']; +const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','blstates','contacts']; let _dragSrc = null; function savedOrder() {{ @@ -1932,6 +2192,9 @@ initDrag(); let audioCtx=null, audioEnabled=false; let audioArmed=false, warningActive=false, warningTimer=null, lastStatus=null; let staleActive=false, staleTimer=null, staleConfirmed=false; +let blockedSince=null, blockedWarningActive=false, blockedChimeTimer=null; +const BLOCKED_WARNING_DELAY_MS=60*60*1000; // first chime after 60 min continuously blocked +const BLOCKED_CHIME_REPEAT_MS=60*60*1000; // repeat every 60 min while still blocked function getCtx(){{ if(!audioCtx) audioCtx=new(window.AudioContext||window.webkitAudioContext)(); @@ -1967,6 +2230,12 @@ function staleChime(){{ setTimeout(()=>beep(1200,0.12,0.35),180); setTimeout(()=>beep(1200,0.25,0.35),360); }} +function blockedChime(){{ + // Distinct from warningChime/staleChime: a slow low->low->high triple knock. + beep(330,0.25,0.4); + setTimeout(()=>beep(330,0.25,0.4),300); + setTimeout(()=>beep(523,0.4, 0.4),600); +}} function testSound(){{ // Gesture handler — unlock first, then delay beeps 80ms for resume(). @@ -1986,8 +2255,10 @@ function toggleAudio(){{ document.getElementById('btn-confirm').style.display='none'; stopStaleWarning(); staleActive=false; staleConfirmed=false; document.getElementById('btn-confirm-stale').style.display='none'; + stopBlockedChime(); blockedWarningActive=false; }} else {{ if(lastStatus==='scanning' && !audioArmed) audioArmed=true; + if(blockedSince!==null && blockedChimeTimer===null) scheduleBlockedChime(); }} updateAudioUI(); }} @@ -2048,9 +2319,45 @@ function handleStale(isStale){{ }} }} +function stopBlockedChime(){{ + if(blockedChimeTimer){{clearTimeout(blockedChimeTimer);blockedChimeTimer=null;}} +}} + +function scheduleBlockedChime(){{ + // Fires once after BLOCKED_WARNING_DELAY_MS of continuous 'blocked' status, + // then repeats every BLOCKED_CHIME_REPEAT_MS for as long as it stays blocked. + // No confirm button — purely informational, auto-clears on recovery, since + // blocked states are external (beamline) and typically self-resolve. + blockedChimeTimer=setTimeout(()=>{{ + if(audioEnabled) blockedChime(); + blockedWarningActive=true; + updateAudioUI(); + scheduleBlockedChime(); + }}, blockedWarningActive ? BLOCKED_CHIME_REPEAT_MS : BLOCKED_WARNING_DELAY_MS); +}} + +function handleBlocked(isBlocked){{ + if(isBlocked){{ + if(blockedSince===null){{ + blockedSince=Date.now(); + blockedWarningActive=false; + stopBlockedChime(); + if(audioEnabled) scheduleBlockedChime(); + }} + }}else{{ + if(blockedSince!==null){{ + blockedSince=null; + blockedWarningActive=false; + stopBlockedChime(); + updateAudioUI(); + }} + }} +}} + function updateAudioUI(){{ const ledSys=document.getElementById('led-system'), ledWatch=document.getElementById('led-watch'), + ledBlocked=document.getElementById('led-blocked'), ledConn=document.getElementById('led-conn'), btn=document.getElementById('btn-toggle'), txt=document.getElementById('audio-text'); @@ -2060,6 +2367,7 @@ function updateAudioUI(){{ btn.classList.toggle('active',audioEnabled); ledConn.className='led'+(staleActive?' led-warning':' led-live'); + ledBlocked.className='led'+(blockedSince!==null?' led-blocked':''); const scanRunning=(lastStatus==='scanning'); if(!audioEnabled){{ @@ -2074,6 +2382,11 @@ function updateAudioUI(){{ }}else if(staleActive){{ ledWatch.className='led'; txt.textContent='Live feed lost \u2014 confirm to silence'; + }}else if(blockedSince!==null){{ + ledWatch.className='led'; + txt.textContent=blockedWarningActive + ? 'Beamline blocked \u2014 chiming hourly until cleared' + : 'Beamline blocked \u2014 will chime after 60 min if unresolved'; }}else if(audioArmed && scanRunning){{ ledWatch.className='led led-armed'; txt.textContent='Armed \u2014 will warn when measurement stops'; @@ -2107,6 +2420,17 @@ function handleAudioForStatus(status, prevStatus){{ }} }} +// ── Help dialog ────────────────────────────────────────────────────────── +function openHelp(){{ + document.getElementById('help-dialog').showModal(); +}} +function closeHelp(){{ + document.getElementById('help-dialog').close(); +}} +document.getElementById('help-dialog').addEventListener('click', e=>{{ + if(e.target.id==='help-dialog') closeHelp(); // click on backdrop area +}}); + // ── Ptychography images ──────────────────────────────────────────── let _ptychoScanId = null; @@ -2157,11 +2481,13 @@ function renderPtycho(ptycho){{ }} // ── Status rendering ────────────────────────────────────────────────────── -const LABELS={{scanning:'SCANNING',running:'RUNNING',idle:'IDLE',error:'STOPPED',unknown:'UNKNOWN'}}; +const LABELS={{scanning:'SCANNING',running:'RUNNING',idle:'IDLE',blocked:'BLOCKED',error:'STOPPED',unknown:'UNKNOWN'}}; const DETAILS={{ scanning: d=>'Tomo scan in progress · projection '+(d.progress.projection||0)+' of '+(d.progress.total_projections||0)+' · '+(d.progress.tomo_type||''), running: d=>'Queue active · outside tomo heartbeat window', idle: d=>'Idle for '+d.idle_for_human+'', + blocked: d=>'Queue locked'+((d.queue_locks&&d.queue_locks.length>1)?' by multiple locks':'')+': ' + +(d.queue_locks||[]).map(l=>esc(l.reason||l.identifier)).join('; ')+'', error: d=>'Queue stopped unexpectedly · idle for '+(d.idle_for_human||'?')+'', unknown: d=>'Waiting for first data\u2026', }}; @@ -2169,6 +2495,12 @@ const DETAILS={{ function setRing(id,circ,pct){{document.getElementById(id).style.strokeDashoffset=circ*(1-Math.min(Math.max(pct,0),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 fmtScan(n){{ + if(n==null) return null; + const i=parseInt(n,10); + if(isNaN(i)) return null; + return 'S'+String(i).padStart(5,'0'); +}} function esc(s){{return String(s==null?'N/A':s).replace(/&/g,'&').replace(//g,'>');}} function renderInstrument(setup){{ @@ -2192,6 +2524,39 @@ function renderInstrument(setup){{ grid.innerHTML=html; }} +function renderBeamlineStates(bl){{ + const summary=document.getElementById('blstates-summary'), + tbody=document.getElementById('blstates-tbody'); + const states=(bl&&bl.states)||[]; + const enabledTxt = bl&&bl.enabled===true ? 'enabled' : (bl&&bl.enabled===false ? 'disabled' : 'unknown'); + const mismatched = states.filter(s=>s.mismatched); + + if(states.length===0){{ + summary.innerHTML='Scan interlock '+enabledTxt+' · no beamline states configured'; + tbody.innerHTML='
No beamline states configured
'; + return; + }} + + summary.innerHTML = 'Scan interlock '+enabledTxt+'' + + (mismatched.length>0 + ? ' · '+mismatched.length+' watched state'+(mismatched.length>1?'s':'')+' currently blocking' + : ' · all watched states OK'); + + let html=''; + states.forEach(s=>{{ + const statusCls='bl-badge bl-'+(s.status||'unknown'); + const watchedCls='bl-badge '+(s.watched?'bl-watched-yes':'bl-watched-no'); + const rowCls=s.mismatched?' class="mismatched"':''; + html+='' + +''+esc(s.name)+'' + +''+esc(s.status)+'' + +''+(s.watched?'watched':'not watched')+'' + +''+esc(s.label)+'' + +''; + }}); + tbody.innerHTML=html; +}} + function render(d){{ const s=d.experiment_status||'unknown',p=d.progress||{{}}; document.body.className=s; @@ -2201,11 +2566,17 @@ function render(d){{ const sPct=p.subtomo_total_projections>0?p.subtomo_projection/p.subtomo_total_projections:0; setRing('ring-outer',289.03,oPct); setRing('ring-inner',213.63,sPct); document.getElementById('ring-pct').textContent=Math.round(oPct*100)+'%'; - document.getElementById('pi-proj').textContent=(p.projection||0)+' / '+(p.total_projections||0); + let projText=(p.projection||0)+' / '+(p.total_projections||0); + const startScan=fmtScan(p.tomo_start_scan_number), curScan=fmtScan(p.current_scan_number); + if(startScan && curScan && startScan!==curScan) projText+=' ('+startScan+'\u2192'+curScan+')'; + else if(curScan) projText+=' ('+curScan+')'; + else if(startScan) projText+=' ('+startScan+')'; + document.getElementById('pi-proj').textContent=projText; 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-remaining').textContent=p.estimated_remaining_human||'-'; + document.getElementById('pi-finish').textContent=fmtTime(p.estimated_finish_time); document.getElementById('pi-start').textContent=fmtTime(p.tomo_start_time); if(d.recon){{ @@ -2216,18 +2587,21 @@ function render(d){{ document.getElementById('recon-path').textContent=d.recon.folder_path||''; }} renderInstrument(d.setup); + renderBeamlineStates(d.beamline_states); renderPtycho(d.ptycho); document.getElementById('last-update').textContent='updated '+new Date(d.generated_at).toLocaleTimeString(); const ageS=(Date.now()/1000)-d.generated_at_epoch; const isStale=ageS>STALE_S; document.getElementById('outdated-banner').classList.toggle('visible',isStale); handleStale(isStale); + handleBlocked(s==='blocked'); document.getElementById('footer-gen').textContent='generator: '+((d.generator||{{}}).owner_id||'-'); document.getElementById('footer-hb').textContent='tomo_heartbeat: '+(p.tomo_heartbeat_age_s!=null?p.tomo_heartbeat_age_s+'s ago':'none'); const prevStatus=lastStatus; lastStatus=s; handleAudioForStatus(s, prevStatus); + updateAudioUI(); }} let _fetchFailCount=0; diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index 7f2ff2c..7dd643d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -59,7 +59,7 @@ class XrayEyeAlign: dev.fsh.fshopen() - time.sleep(0.5) + time.sleep(1) # stop live view if not keep_shutter_open: self.gui.on_live_view_enabled(False) @@ -94,6 +94,8 @@ class XrayEyeAlign: def align(self, keep_shutter_open=False): self.flomni.flomnigui_show_xeyealign() self.gui.set_dap_params_forwarding(True) + self.gui.reset_zoom() + try: self._align_impl(keep_shutter_open) finally: @@ -101,6 +103,10 @@ class XrayEyeAlign: self.gui.set_dap_params_forwarding(False) except Exception as exc: # pylint: disable=broad-except logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {exc}") + try: + self.gui.hide_crosshair() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to hide XRayEye alignment crosshair: {exc}") def _align_impl(self, keep_shutter_open=False): if not keep_shutter_open: @@ -145,12 +151,11 @@ class XrayEyeAlign: if not self.test_wo_movements: self.flomni.fosa_out() - + self.flomni.ffzp_in() + self.flomni.feedback_disable() fsamx_in = self.flomni._get_user_param_safe("fsamx", "in") umv(dev.fsamx, fsamx_in - 0.25) - self.flomni.ffzp_in() - self.update_frame(keep_shutter_open) self.gui.enable_submit_button(True) @@ -188,6 +193,15 @@ class XrayEyeAlign: self.flomni.feedback_enable_with_reset() self.update_frame(keep_shutter_open) + + # Mark the FZP center on the live view: it stays visible as a + # fixed reference while the sample is aligned at each + # subsequent rotation angle (steps 1-4 below). + fzp_center_x = dev.omny_xray_gui.xval_x_0.get() + fzp_center_y = dev.omny_xray_gui.yval_y_0.get() + self.gui.set_crosshair_position(fzp_center_x, fzp_center_y) + self.gui.show_crosshair() + self.send_message("Step 1/5: Adjust sample height and submit center") self.gui.enable_submit_button(True) self.movement_buttons_enabled(True, True) @@ -211,6 +225,7 @@ class XrayEyeAlign: self.gui.enable_submit_button(False) self.movement_buttons_enabled(False, False) self.update_fov(k) + self.gui.hide_crosshair() break k += 1 @@ -298,4 +313,4 @@ class XrayEyeAlign: ) self.gui.submit_fit_array(data) print(f"fit submited with {data}") - # self.flomni.flomnigui_show_xeyealign_fittab() + # self.flomni.flomnigui_show_xeyealign_fittab() \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py index 8ee5c28..7821f1a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py @@ -189,7 +189,13 @@ class PtychoReconstructor: logger.warning("Failed to compare active account to system user.") return False - def write(self, scan_list: list, next_scan_number: int, base_path: str = "~/data/raw/analysis/"): + def write( + self, + scan_list: list, + next_scan_number: int, + base_path: str = "~/data/raw/analysis/", + probe_file_propagation: float | None = None, + ): """Write a reconstruction queue file for the given scan list. Args: @@ -198,6 +204,13 @@ class PtychoReconstructor: next_scan_number (int): The current next scan number, used to name the queue file. base_path (str): Root path under which the queue folder lives. + probe_file_propagation (float, optional): Distance [m] by which the + reconstruction should numerically propagate the probe, used in + place of a physical sample z-move (e.g. when the z stage/piezo + is unavailable). Written as `p.probe_file_propagation` if given; + omitted entirely otherwise, matching the old reconstruction.mac + behavior of only writing this parameter when + progagateprobeinsteadofsample==1. """ if not self._accounts_match(): logger.warning("Active BEC account does not match system user — skipping queue file write.") @@ -214,6 +227,8 @@ class PtychoReconstructor: with open(queue_file, "w") as f: scans = " ".join(str(s) for s in scan_list) f.write(f"p.scan_number {scans}\n") + if probe_file_propagation is not None: + f.write(f"p.probe_file_propagation {probe_file_propagation:.6f}\n") f.write("p.check_nextscan_started 1\n") diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index bef6d7c..92d4240 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -116,6 +116,63 @@ class XRayEye(RPCBase): None """ + @rpc_timeout(20) + @rpc_call + def show_crosshair(self): + """ + Show the alignment target crosshair on the image view. + """ + + @rpc_timeout(20) + @rpc_call + def hide_crosshair(self): + """ + Hide the alignment target crosshair on the image view. + """ + + @property + @rpc_call + def crosshair_visible(self) -> "bool": + """ + Whether the alignment target crosshair is currently shown. + """ + + @crosshair_visible.setter + @rpc_call + def crosshair_visible(self) -> "bool": + """ + Whether the alignment target crosshair is currently shown. + """ + + @rpc_timeout(20) + @rpc_call + def set_crosshair_position(self, x: "float", y: "float"): + """ + Move the alignment target crosshair to (x, y). Does not change visibility. + + Args: + x(float): x position, in the same image/data coordinates as ROIs + (see e.g. ``omny_xray_gui.xval_x_*``). + y(float): y position, in the same image/data coordinates as ROIs + (see e.g. ``omny_xray_gui.yval_y_*``). + """ + + @rpc_call + def crosshair_position(self) -> "tuple[float, float]": + """ + Current position of the alignment target crosshair as (x, y). + """ + + @rpc_timeout(20) + @rpc_call + def reset_zoom(self): + """ + Reset the image view to fit the current frame, discarding any manual + zoom/pan. Intended to be called once at the start of an alignment + routine; live view re-enabling does not reset zoom on its own (see + on_live_view_enabled). + """ + class XRayEye2DControl(RPCBase): _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye" diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 32b20a7..3ad8b2a 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -1,5 +1,6 @@ from __future__ import annotations +import pyqtgraph as pg from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints from bec_qthemes import material_icon @@ -31,6 +32,109 @@ logger = bec_logger.logger CAMERA = ("cam_xeye", "image") +class TargetCrosshair: + """ + Fixed, RPC-positionable crosshair overlay for an image plot item. + + This is intentionally separate from bec_widgets' built-in mouse-tracking + ``Crosshair`` (toggled from the image toolbar): that one follows the + cursor and is purely a UI convenience. This crosshair never reacts to the + mouse - it marks a single target position that is shown, hidden and + moved entirely under program control (e.g. from ``xray_eye_align.py`` or + any other BEC client via RPC), so it can be used to mark a reference + position (such as a previously submitted alignment center) on the live + view while the user works through subsequent steps. + + Position is expressed in the same image/data coordinate system used by + the widget's ROIs (see ``XRayEye.submit``), so values read back from + ``omny_xray_gui.xval_x_*`` / ``yval_y_*`` can be passed in directly. + """ + + def __init__(self, plot_item: pg.PlotItem): + self.plot_item = plot_item + pen = pg.mkPen(color="#ff2e2e", width=2, style=Qt.PenStyle.DashLine) + self.v_line = pg.InfiniteLine(angle=90, movable=False, pen=pen) + self.h_line = pg.InfiniteLine(angle=0, movable=False, pen=pen) + for line in (self.v_line, self.h_line): + line.skip_auto_range = True + line.setVisible(False) + self.plot_item.addItem(line, ignoreBounds=True) + + def set_position(self, x: float, y: float): + """Move the crosshair to (x, y) in image/data coordinates.""" + self.v_line.setPos(x) + self.h_line.setPos(y) + + def position(self) -> tuple[float, float]: + """Current crosshair position as (x, y) in image/data coordinates.""" + return (self.v_line.value(), self.h_line.value()) + + def set_visible(self, visible: bool): + """Show or hide the crosshair without changing its position.""" + self.v_line.setVisible(visible) + self.h_line.setVisible(visible) + + def is_visible(self) -> bool: + return self.v_line.isVisible() + + def cleanup(self): + self.plot_item.removeItem(self.v_line) + self.plot_item.removeItem(self.h_line) + + +class ImageZoomControl(QWidget): + """ + Discrete zoom controls for an Image widget's view. + + Mouse-wheel zoom steps can be far too coarse to use precisely over a + remote desktop connection, so this provides explicit zoom in/out buttons + plus a "fit to view" button that resets pan/zoom to frame the current + image (the same operation as ``XRayEye.reset_zoom()``). + """ + + # scaleBy() factor for one "zoom in" click; >1 (its inverse) zooms out. + ZOOM_STEP_FACTOR = 0.8 + + def __init__(self, parent=None, image_widget: Image | None = None, *args, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + self._image_widget = image_widget + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + self.zoom_out_button = QToolButton(parent=self) + self.zoom_out_button.setIcon(material_icon("zoom_out")) + self.zoom_out_button.setToolTip("Zoom out") + layout.addWidget(self.zoom_out_button) + + self.zoom_in_button = QToolButton(parent=self) + self.zoom_in_button.setIcon(material_icon("zoom_in")) + self.zoom_in_button.setToolTip("Zoom in") + layout.addWidget(self.zoom_in_button) + + self.fit_view_button = QToolButton(parent=self) + self.fit_view_button.setIcon(material_icon("fit_screen")) + self.fit_view_button.setToolTip("Reset zoom/pan to fit the image") + layout.addWidget(self.fit_view_button) + + self.zoom_in_button.clicked.connect(lambda: self.zoom(self.ZOOM_STEP_FACTOR)) + self.zoom_out_button.clicked.connect(lambda: self.zoom(1 / self.ZOOM_STEP_FACTOR)) + self.fit_view_button.clicked.connect(self.reset_view) + + def zoom(self, factor: float): + """Scale the view by `factor` around the current view center.""" + if self._image_widget is None: + return + self._image_widget.plot_item.vb.scaleBy((factor, factor)) + + def reset_view(self): + """Reset pan/zoom to fit the current image.""" + if self._image_widget is None: + return + self._image_widget.auto_range(True) + + class XRayEye2DControl(BECWidget, QWidget): def __init__(self, parent=None, step_size: int = 100, *arg, **kwargs): super().__init__(parent=parent, *arg, **kwargs) @@ -142,9 +246,20 @@ class XRayEye(BECWidget, QWidget): "switch_tab", "set_dap_params_forwarding", "submit_fit_array", + "show_crosshair", + "hide_crosshair", + "crosshair_visible", + "crosshair_visible.setter", + "set_crosshair_position", + "crosshair_position", + "reset_zoom", ] PLUGIN = True + # Styling for ROIs drawn in the (single, compact-mode) alignment view. + ROI_LINE_COLOR = "blue" + ROI_LINE_WIDTH = 2 + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self._connected_motor = None @@ -157,6 +272,7 @@ class XRayEye(BECWidget, QWidget): self.get_bec_shortcuts() self._init_ui() + self.target_crosshair = TargetCrosshair(self.image.plot_item) self._make_connections() # Connection to redis endpoints @@ -200,7 +316,11 @@ class XRayEye(BECWidget, QWidget): # ROI toolbar + Live toggle (header row) self.roi_manager = ROIPropertyTree( - parent=self, image_widget=self.image, compact=True, compact_orientation="horizontal" + parent=self, + image_widget=self.image, + compact=True, + compact_orientation="horizontal", + compact_color=self.ROI_LINE_COLOR, ) header_row = QHBoxLayout() header_row.setContentsMargins(0, 0, 0, 0) @@ -242,6 +362,12 @@ class XRayEye(BECWidget, QWidget): self.motor_control_2d, 0, Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter ) + # Zoom controls (mouse-wheel zoom steps are too coarse over remote desktop) + self.zoom_control = ImageZoomControl(parent=self, image_widget=self.image) + self.control_panel_layout.addWidget( + self.zoom_control, 0, Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter + ) + # separator self.control_panel_layout.addWidget(self._create_separator()) @@ -341,6 +467,14 @@ class XRayEye(BECWidget, QWidget): lambda x: self.motor_control_2d.setProperty("step_size", x) ) self.submit_button.clicked.connect(self.submit) + # ROIPropertyTree's compact_color only styles the line color; line width + # still needs to be forced per-ROI here. + self.roi_manager.controller.roiAdded.connect(self._style_new_roi) + + @SafeSlot(object) + def _style_new_roi(self, roi): + """Force a thinner outline on newly drawn ROIs (color is set via compact_color).""" + roi.line_width = self.ROI_LINE_WIDTH def _create_separator(self): sep = QFrame(parent=self) @@ -478,6 +612,58 @@ class XRayEye(BECWidget, QWidget): else: self.tab_widget.setCurrentIndex(0) + @SafeSlot() + @rpc_timeout(20) + def show_crosshair(self): + """Show the alignment target crosshair on the image view.""" + self.target_crosshair.set_visible(True) + + @SafeSlot() + @rpc_timeout(20) + def hide_crosshair(self): + """Hide the alignment target crosshair on the image view.""" + self.target_crosshair.set_visible(False) + + @SafeProperty(bool) + def crosshair_visible(self) -> bool: + """Whether the alignment target crosshair is currently shown.""" + return self.target_crosshair.is_visible() + + @crosshair_visible.setter + @rpc_timeout(20) + def crosshair_visible(self, visible: bool): + self.target_crosshair.set_visible(visible) + + @SafeSlot(float, float) + @rpc_timeout(20) + def set_crosshair_position(self, x: float, y: float): + """ + Move the alignment target crosshair to (x, y). Does not change visibility. + + Args: + x(float): x position, in the same image/data coordinates as ROIs + (see e.g. ``omny_xray_gui.xval_x_*``). + y(float): y position, in the same image/data coordinates as ROIs + (see e.g. ``omny_xray_gui.yval_y_*``). + """ + self.target_crosshair.set_position(x, y) + + @SafeSlot() + def crosshair_position(self) -> tuple[float, float]: + """Current position of the alignment target crosshair as (x, y).""" + return self.target_crosshair.position() + + @SafeSlot() + @rpc_timeout(20) + def reset_zoom(self): + """ + Reset the image view to fit the current frame, discarding any manual + zoom/pan. Intended to be called once at the start of an alignment + routine; live view re-enabling does not reset zoom on its own (see + on_live_view_enabled). + """ + self.zoom_control.reset_view() + @SafeSlot() def get_roi_coordinates(self) -> dict | None: """Get the coordinates of the currently active ROI.""" @@ -499,6 +685,15 @@ class XRayEye(BECWidget, QWidget): if enabled: self.live_preview_toggle.checked = enabled self.image.image(device=CAMERA[0], signal=CAMERA[1]) + # Reconnecting the monitor schedules a one-shot view autorange on + # the next incoming frame (bec_widgets Image._autorange_on_next_update), + # which would silently discard any manual zoom/pan every time live + # view is re-enabled (e.g. once per step of an alignment routine). + # Suppress it; an explicit reset is available via reset_zoom() / + # the "fit to view" button. Private attribute - re-check this if + # bec_widgets' Image implementation changes. + if hasattr(self.image, "_autorange_on_next_update"): + self.image._autorange_on_next_update = False self.live_preview_toggle.blockSignals(False) return @@ -674,6 +869,7 @@ class XRayEye(BECWidget, QWidget): def cleanup(self): """Cleanup connections on widget close -> disconnect slots and stop live mode of camera.""" self._queue_idle_timer.stop() + self.target_crosshair.cleanup() if self._connected_motor is not None: self.bec_dispatcher.disconnect_slot( self.on_tomo_angle_readback, MessageEndpoints.device_readback(self._connected_motor) @@ -707,4 +903,4 @@ if __name__ == "__main__": win.resize(1000, 800) win.show() - sys.exit(app.exec_()) + sys.exit(app.exec_()) \ No newline at end of file diff --git a/csaxs_bec/device_configs/bl_detectors.yaml b/csaxs_bec/device_configs/bl_detectors.yaml index 14243e7..a7cb3e2 100644 --- a/csaxs_bec/device_configs/bl_detectors.yaml +++ b/csaxs_bec/device_configs/bl_detectors.yaml @@ -1,17 +1,6 @@ -# eiger_1_5: -# description: Eiger 1.5M in-vacuum detector -# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M -# deviceConfig: -# detector_distance: 100 -# beam_center: [0, 0] -# onFailure: raise -# enabled: True -# readoutPriority: async -# softwareTrigger: False - -eiger_9: - description: Eiger 9M detector - deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M +eiger_1_5: + description: Eiger 1.5M in-vacuum detector + deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M deviceConfig: detector_distance: 2150 beam_center: [860, 1219] @@ -20,6 +9,17 @@ eiger_9: readoutPriority: async softwareTrigger: False +# eiger_9: +# description: Eiger 9M detector +# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M +# deviceConfig: +# detector_distance: 2200 +# beam_center: [870, 1203] +# onFailure: raise +# enabled: True +# readoutPriority: async +# softwareTrigger: False + # ids_cam: # description: IDS camera for live image acquisition # deviceClass: csaxs_bec.devices.ids_cameras.IDSCamera diff --git a/csaxs_bec/device_configs/bl_endstation.yaml b/csaxs_bec/device_configs/bl_endstation.yaml index 09e644a..02601f9 100644 --- a/csaxs_bec/device_configs/bl_endstation.yaml +++ b/csaxs_bec/device_configs/bl_endstation.yaml @@ -855,6 +855,9 @@ ebsupport: enabled: true readoutPriority: baseline softwareTrigger: false + userParameter: + one_reflection: -93.0 + two_reflections: 10.9787 fttrx1: description: FTS1 translation X @@ -1039,7 +1042,7 @@ gain_bpm_xbox2: gain_lsb: galilrioesxbox.digital_out.ch0 # Pin 10 -> Galil ch0 gain_mid: galilrioesxbox.digital_out.ch1 # Pin 11 -> Galil ch1 gain_msb: galilrioesxbox.digital_out.ch2 # Pin 12 -> Galil ch2 - coupling: galilrioesxbox.digital_out.ch3 # Pin 13 -> Galil ch3 + coupling_ref: galilrioesxbox.digital_out.ch3 # Pin 13 -> Galil ch3 speed_mode: galilrioesxbox.digital_out.ch4 # Pin 14 -> Galil ch4 enabled: true readoutPriority: baseline @@ -1051,10 +1054,10 @@ bpm_xbox2_slowrb: description: BPM Xbox 2 (First Xbox in ES hutch) readback deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM deviceConfig: - left_top: galilrioesxbox.analog_in.ch0 - right_top: galilrioesxbox.analog_in.ch1 - right_bot: galilrioesxbox.analog_in.ch2 - left_bot: galilrioesxbox.analog_in.ch3 + left_top_ref: galilrioesxbox.analog_in.ch0 + right_top_ref: galilrioesxbox.analog_in.ch1 + right_bot_ref: galilrioesxbox.analog_in.ch2 + left_bot_ref: galilrioesxbox.analog_in.ch3 enabled: true readoutPriority: monitored onFailure: retry @@ -1068,7 +1071,7 @@ gain_bim_xbox3: gain_lsb: galilrioesxbox.digital_out.ch6 # Pin 10 -> Galil ch0 gain_mid: galilrioesxbox.digital_out.ch7 # Pin 11 -> Galil ch1 gain_msb: galilrioesxbox.digital_out.ch8 # Pin 12 -> Galil ch2 - coupling: galilrioesxbox.digital_out.ch9 # Pin 13 -> Galil ch3 + coupling_ref: galilrioesxbox.digital_out.ch9 # Pin 13 -> Galil ch3 speed_mode: galilrioesxbox.digital_out.ch10 # Pin 14 -> Galil ch4 enabled: true readoutPriority: baseline @@ -1080,7 +1083,7 @@ bim_xbox3_slowrb: description: Beam intensity slow readback ES XBox3 deviceClass: csaxs_bec.devices.pseudo_devices.signal_forwarder.SignalForwarder deviceConfig: - signal: galilrioesxbox.analog_in.ch6 + signal_ref: galilrioesxbox.analog_in.ch6 enabled: true readoutPriority: monitored onFailure: retry @@ -1112,7 +1115,7 @@ gain_beamstop_diode: gain_lsb: galilrioesft.digital_out.ch0 # Pin 10 -> Galil ch0 gain_mid: galilrioesft.digital_out.ch1 # Pin 11 -> Galil ch1 gain_msb: galilrioesft.digital_out.ch2 # Pin 12 -> Galil ch2 - coupling: galilrioesft.digital_out.ch3 # Pin 13 -> Galil ch3 + coupling_ref: galilrioesft.digital_out.ch3 # Pin 13 -> Galil ch3 speed_mode: galilrioesft.digital_out.ch4 # Pin 14 -> Galil ch4 enabled: true readoutPriority: baseline @@ -1124,7 +1127,7 @@ beamstop_intensity: description: Beamstop intensity from Galil analog input ch6 deviceClass: csaxs_bec.devices.pseudo_devices.signal_forwarder.SignalForwarder deviceConfig: - signal: galilrioesft.analog_in.ch0 + signal_ref: galilrioesft.analog_in.ch0 enabled: true readoutPriority: monitored onFailure: retry diff --git a/csaxs_bec/device_configs/bl_frontend.yaml b/csaxs_bec/device_configs/bl_frontend.yaml index 5d21752..ee40118 100644 --- a/csaxs_bec/device_configs/bl_frontend.yaml +++ b/csaxs_bec/device_configs/bl_frontend.yaml @@ -203,10 +203,10 @@ bpm1: description: 'XBPM1 (frontend)' deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM deviceConfig: - left_top: xbpm1c1 - right_top: xbpm1c2 - right_bot: xbpm1c3 - left_bot: xbpm1c4 + left_top_ref: xbpm1c1 + right_top_ref: xbpm1c2 + right_bot_ref: xbpm1c3 + left_bot_ref: xbpm1c4 onFailure: raise enabled: true readoutPriority: monitored diff --git a/csaxs_bec/device_configs/bl_optics_hutch.yaml b/csaxs_bec/device_configs/bl_optics_hutch.yaml index 14bdaf3..0451ae2 100644 --- a/csaxs_bec/device_configs/bl_optics_hutch.yaml +++ b/csaxs_bec/device_configs/bl_optics_hutch.yaml @@ -201,48 +201,48 @@ ccm_energy: ######################## SMARACT STAGES ################################## ########################################################################## -# xbpm2x: -# description: X-ray beam position monitor 1 in OPbox -# deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor -# deviceConfig: -# axis_Id: A -# host: x12sa-eb-smaract-mcs-03.psi.ch -# limits: -# - -200 -# - 200 -# port: 5000 -# sign: 1 -# enabled: true -# onFailure: retry -# readOnly: false -# readoutPriority: baseline -# connectionTimeout: 20 -# userParameter: -# init_position: 22.5 -# in_position: -1.5 -# # bl_smar_stage to use csaxs reference method. assign number according to axis channel -# bl_smar_stage: 0 +xbpm2x: + description: X-ray beam position monitor 1 in OPbox + deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor + deviceConfig: + axis_Id: A + host: x12sa-eb-smaract-mcs-03.psi.ch + limits: + - -200 + - 200 + port: 5000 + sign: 1 + enabled: true + onFailure: retry + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + init_position: 22.5 + in_position: -1.5 + # bl_smar_stage to use csaxs reference method. assign number according to axis channel + bl_smar_stage: 0 -# xbpm2y: -# description: X-ray beam position monitor 1 in OPbox -# deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor -# deviceConfig: -# axis_Id: B -# host: x12sa-eb-smaract-mcs-03.psi.ch -# limits: -# - -200 -# - 200 -# port: 5000 -# sign: 1 -# enabled: true -# onFailure: retry -# readOnly: false -# readoutPriority: baseline -# connectionTimeout: 20 -# userParameter: -# in_position: -1.0 -# # bl_smar_stage to use csaxs reference method. assign number according to axis channel -# bl_smar_stage: 1 +xbpm2y: + description: X-ray beam position monitor 1 in OPbox + deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor + deviceConfig: + axis_Id: B + host: x12sa-eb-smaract-mcs-03.psi.ch + limits: + - -200 + - 200 + port: 5000 + sign: 1 + enabled: true + onFailure: retry + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in_position: -1.0 + # bl_smar_stage to use csaxs reference method. assign number according to axis channel + bl_smar_stage: 1 scinx: description: scintillator in OPbox @@ -266,47 +266,47 @@ scinx: bl_smar_stage: 2 in_position: -12.5 -poly: - description: polarizer holder in OPbox - deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor - deviceConfig: - axis_Id: B - host: x12sa-eb-smaract-mcs-03.psi.ch - limits: - - -200 - - 200 - port: 5000 - sign: 1 - enabled: true - onFailure: retry - readOnly: false - readoutPriority: baseline - connectionTimeout: 20 - userParameter: - # bl_smar_stage to use csaxs reference method. assign number according to axis channel - init_position: -23 - bl_smar_stage: 1 +# poly: +# description: polarizer holder in OPbox +# deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor +# deviceConfig: +# axis_Id: B +# host: x12sa-eb-smaract-mcs-03.psi.ch +# limits: +# - -200 +# - 200 +# port: 5000 +# sign: 1 +# enabled: true +# onFailure: retry +# readOnly: false +# readoutPriority: baseline +# connectionTimeout: 20 +# userParameter: +# # bl_smar_stage to use csaxs reference method. assign number according to axis channel +# init_position: -23 +# bl_smar_stage: 1 -polrot: - description: rotation of crytal of the polarizer - deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor - deviceConfig: - axis_Id: A - host: x12sa-eb-smaract-mcs-03.psi.ch - limits: - - -200 - - 200 - port: 5000 - sign: 1 - enabled: true - onFailure: retry - readOnly: false - readoutPriority: baseline - connectionTimeout: 20 - userParameter: - in_position: -1.0 - # bl_smar_stage to use csaxs reference method. assign number according to axis channel - bl_smar_stage: 0 +# polrot: +# description: rotation of crytal of the polarizer +# deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor +# deviceConfig: +# axis_Id: A +# host: x12sa-eb-smaract-mcs-03.psi.ch +# limits: +# - -200 +# - 200 +# port: 5000 +# sign: 1 +# enabled: true +# onFailure: retry +# readOnly: false +# readoutPriority: baseline +# connectionTimeout: 20 +# userParameter: +# in_position: -1.0 +# # bl_smar_stage to use csaxs reference method. assign number according to axis channel +# bl_smar_stage: 0 # dmm1_trx_readback_example: # This is the same template as for i.e. bpm4i # description: 'This is an example of a read-only Epics signal' @@ -356,7 +356,7 @@ galilrioop: # gain_lsb: galilrioop.digital_out.ch0 # Pin 10 -> Galil ch0 # gain_mid: galilrioop.digital_out.ch1 # Pin 11 -> Galil ch1 # gain_msb: galilrioop.digital_out.ch2 # Pin 12 -> Galil ch2 -# coupling: galilrioop.digital_out.ch3 # Pin 13 -> Galil ch3 +# coupling_ref: galilrioop.digital_out.ch3 # Pin 13 -> Galil ch3 # speed_mode: galilrioop.digital_out.ch4 # Pin 14 -> Galil ch4 # enabled: true # readoutPriority: monitored @@ -368,10 +368,10 @@ galilrioop: # description: BPM Xbox 1 (OP hutch) readback # deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM # deviceConfig: -# left_top: galilrioop.analog_in.ch0 -# right_top: galilrioop.analog_in.ch1 -# right_bot: galilrioop.analog_in.ch2 -# left_bot: galilrioop.analog_in.ch3 +# left_top_ref: galilrioop.analog_in.ch0 +# right_top_ref: galilrioop.analog_in.ch1 +# right_bot_ref: galilrioop.analog_in.ch2 +# left_bot_ref: galilrioop.analog_in.ch3 # enabled: true # readoutPriority: monitored # onFailure: retry @@ -385,7 +385,7 @@ gain_diodes_xbox1: gain_lsb: galilrioop.digital_out.ch6 # Pin 10 -> Galil ch0 gain_mid: galilrioop.digital_out.ch7 # Pin 11 -> Galil ch1 gain_msb: galilrioop.digital_out.ch8 # Pin 12 -> Galil ch2 - coupling: galilrioop.digital_out.ch9 # Pin 13 -> Galil ch3 + coupling_ref: galilrioop.digital_out.ch9 # Pin 13 -> Galil ch3 speed_mode: galilrioop.digital_out.ch10 # Pin 14 -> Galil ch4 enabled: true readoutPriority: baseline @@ -397,7 +397,7 @@ diode_horizontal_xbox1_slowrb: description: Slow readback diode horizontal XBox OP (polarization diagnostics) deviceClass: csaxs_bec.devices.pseudo_devices.signal_forwarder.SignalForwarder deviceConfig: - signal: galilrioop.analog_in.ch6 + signal_ref: galilrioop.analog_in.ch6 enabled: true readoutPriority: monitored onFailure: retry @@ -408,7 +408,7 @@ diode_vertical_xbox1_slowrb: description: Slow readback diode vertical XBox OP (polarization diagnostics) deviceClass: csaxs_bec.devices.pseudo_devices.signal_forwarder.SignalForwarder deviceConfig: - signal: galilrioop.analog_in.ch7 + signal_ref: galilrioop.analog_in.ch7 enabled: true readoutPriority: monitored onFailure: retry @@ -750,6 +750,9 @@ kbhtry: deviceTags: - cSAXS - optics + userParameter: + one_reflection: -5.0 + two_reflections: -0.95 kbhyaw: description: "KB Horizontal Focusing Mirror, yaw" diff --git a/csaxs_bec/device_configs/main.yaml b/csaxs_bec/device_configs/main.yaml index 7aef1d2..8f3fb43 100644 --- a/csaxs_bec/device_configs/main.yaml +++ b/csaxs_bec/device_configs/main.yaml @@ -19,8 +19,8 @@ detectors: #sastt: # - !include ./sastt.yaml -# flomni: -# - !include ./ptycho_flomni.yaml +flomni: + - !include ./ptycho_flomni.yaml #omny: # - !include ./ptycho_omny.yaml @@ -28,7 +28,7 @@ detectors: #lamni: # - !include ./ptycho_lamni.yaml -user setup: - - !include ./user_setup.yaml +# user setup: +# - !include ./user_setup.yaml diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index 5321e12..0b29b8c 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -19,8 +19,11 @@ feyex: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: -16.267 + in: -16.453 out: -1 + fttrx_in: 2.3 + fttrx_out: -24 + feyey: description: Xray eye Y deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -38,7 +41,7 @@ feyey: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: -10.467 + in: -10.09 fheater: description: Heater Y deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -72,7 +75,8 @@ foptx: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: -13.761 + #170 micron, 60 nm + in: -13.831 fopty: description: Optics Y deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -90,8 +94,9 @@ fopty: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: 0.552 - out: 0.752 + #170 micron, 60 nm + in: 0.42 + out: 0.57 foptz: description: Optics Z deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -152,7 +157,7 @@ fsamy: host: mpc2844.psi.ch limits: - 2 - - 3.1 + - 3.3 port: 8081 sign: 1 enabled: true @@ -161,7 +166,7 @@ fsamy: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: 2.75 + in: 3 ftracky: description: Laser Tracker coarse Y deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -227,7 +232,7 @@ ftransy: readoutPriority: baseline connectionTimeout: 20 userParameter: - sensor_voltage: -1.1 + sensor_voltage: -1.6 ftransz: description: Sample transer Z deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -296,8 +301,12 @@ fosax: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: 9.124 - out: 5.3 + #170 micron, 60 nm, 7.6 kev + #in: 8.7568 + #out: 5.1 + #170 micron, 60 nm, 7.9 kev + in: 8.731922 + out: 5.1 fosay: description: OSA Y deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor @@ -315,7 +324,11 @@ fosay: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: 0.367 + #170 micron, 60 nm, 7.6 kev + #in: -0.0235 + #170 micron, 60 nm, 7.6 kev + in: -0.0422 + fosaz: description: OSA Z deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor @@ -333,7 +346,11 @@ fosaz: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: 8.5 + #170 micron, 60 nm, 7.6 kev + #in: 8.5 + #out: 6 + #170 micron, 60 nm, 7.9 kev, foptz 15.9 + in: 11.9 out: 6 ############################################################ @@ -439,8 +456,8 @@ cam_xeye: deviceConfig: camera_id: 11 bits_per_pixel: 24 - num_rotation_90: 3 - transpose: false + num_rotation_90: 0 + transpose: true force_monochrome: true m_n_colormode: 1 enabled: true @@ -511,33 +528,33 @@ calculated_signal: ############################################################ #################### OMNY Pandabox ######################### ############################################################ -# omny_panda: -# readoutPriority: async -# deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY -# deviceConfig: -# host: omny-panda.psi.ch -# signal_alias: -# FMC_IN.VAL1.Min: cap_voltage_fzp_y_min -# FMC_IN.VAL1.Max: cap_voltage_fzp_y_max -# FMC_IN.VAL1.Mean: cap_voltage_fzp_y_mean -# FMC_IN.VAL2.Min: cap_voltage_fzp_x_min -# FMC_IN.VAL2.Max: cap_voltage_fzp_x_max -# FMC_IN.VAL2.Mean: cap_voltage_fzp_x_mean -# INENC1.VAL.Max: interf_st_fzp_y_max -# INENC1.VAL.Mean: interf_st_fzp_y_mean -# INENC1.VAL.Min: interf_st_fzp_y_min -# INENC2.VAL.Max: interf_st_fzp_x_max -# INENC2.VAL.Mean: interf_st_fzp_x_mean -# INENC2.VAL.Min: interf_st_fzp_x_min -# INENC3.VAL.Max: interf_st_rotz_max -# INENC3.VAL.Mean: interf_st_rotz_mean -# INENC3.VAL.Min: interf_st_rotz_min -# INENC4.VAL.Max: interf_st_rotx_max -# INENC4.VAL.Mean: interf_st_rotx_mean -# INENC4.VAL.Min: interf_st_rotx_min -# PCAP.GATE_DURATION.Value: pcap_gate_duration_value -# deviceTags: -# - detector -# enabled: true -# readOnly: false -# softwareTrigger: false +omny_panda: + readoutPriority: async + deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY + deviceConfig: + host: omny-panda.psi.ch + signal_alias: + FMC_IN.VAL1.Min: cap_voltage_fzp_y_min + FMC_IN.VAL1.Max: cap_voltage_fzp_y_max + FMC_IN.VAL1.Mean: cap_voltage_fzp_y_mean + FMC_IN.VAL2.Min: cap_voltage_fzp_x_min + FMC_IN.VAL2.Max: cap_voltage_fzp_x_max + FMC_IN.VAL2.Mean: cap_voltage_fzp_x_mean + INENC1.VAL.Max: interf_st_fzp_y_max + INENC1.VAL.Mean: interf_st_fzp_y_mean + INENC1.VAL.Min: interf_st_fzp_y_min + INENC2.VAL.Max: interf_st_fzp_x_max + INENC2.VAL.Mean: interf_st_fzp_x_mean + INENC2.VAL.Min: interf_st_fzp_x_min + INENC3.VAL.Max: interf_st_rotz_max + INENC3.VAL.Mean: interf_st_rotz_mean + INENC3.VAL.Min: interf_st_rotz_min + INENC4.VAL.Max: interf_st_rotx_max + INENC4.VAL.Mean: interf_st_rotx_mean + INENC4.VAL.Min: interf_st_rotx_min + PCAP.GATE_DURATION.Value: pcap_gate_duration_value + deviceTags: + - detector + enabled: true + readOnly: false + softwareTrigger: false diff --git a/csaxs_bec/device_configs/test_config.yaml b/csaxs_bec/device_configs/test_config.yaml index 3df835a..b269875 100644 --- a/csaxs_bec/device_configs/test_config.yaml +++ b/csaxs_bec/device_configs/test_config.yaml @@ -12,10 +12,10 @@ bpm1: readoutPriority: baseline deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM deviceConfig: - blade_t: galilrioesxbox.analog_in.ch0 - blade_r: galilrioesxbox.analog_in.ch1 - blade_b: galilrioesxbox.analog_in.ch2 - blade_l: galilrioesxbox.analog_in.ch3 + left_top_ref: galilrioesxbox.analog_in.ch0 + right_top_ref: galilrioesxbox.analog_in.ch1 + right_bot_ref: galilrioesxbox.analog_in.ch2 + left_bot_ref: galilrioesxbox.analog_in.ch3 enabled: true readOnly: false softwareTrigger: true diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py index 9948784..b50cb06 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -454,33 +454,36 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): NOTE! This logic currently works for any step scan, but has to be extended for fly scans. """ while not self._scan_done_thread_kill_event.is_set(): - while self._start_monitor_async_data_emission.wait(): - try: - if ( - hasattr(self.scan_parameters, "num_points") - and self.scan_parameters.num_points is not None - ): - if self.scan_parameters.scan_type == "software_triggered": - logger.info( - f"Software triggered scan: {self._current_data_index}/{self.scan_parameters.num_points} points received." - ) - if self._current_data_index == self.scan_parameters.num_points: - for callback in self._scan_done_callbacks: - callback(exception=None) - else: - if self._current_data_index >= 1: - for callback in self._scan_done_callbacks: - callback(exception=None) - - time.sleep(0.02) # 20ms delay to avoid busy loop - except Exception as exc: # pylint: disable=broad-except - content = traceback.format_exc() - logger.error( - f"Exception in monitoring thread of complete for {self.name}:\n{content}" - "Running callbacks to avoid deadlock." - ) - for callback in self._scan_done_callbacks: - callback(exception=exc) + # 20ms delay to avoid busy loop + if not self._start_monitor_async_data_emission.wait(timeout=0.02): + continue # Wait for the event to be set before checking data emission + try: + if ( + hasattr(self.scan_parameters, "num_points") + and self.scan_parameters.num_points is not None + ): + if self.scan_parameters.scan_type == "software_triggered": + logger.info( + f"Software triggered scan: {self._current_data_index}/{self.scan_parameters.num_points} points received." + ) + if self._current_data_index == self.scan_parameters.num_points: + for callback in self._scan_done_callbacks: + callback(exception=None) + else: + if self._current_data_index >= 1: + for callback in self._scan_done_callbacks: + callback(exception=None) + # 20ms delay to avoid busy loop + if self._scan_done_thread_kill_event.wait(timeout=0.02): + continue + except Exception as exc: # pylint: disable=broad-except + content = traceback.format_exc() + logger.error( + f"Exception in monitoring thread of complete for {self.name}:\n{content}" + "Running callbacks to avoid deadlock." + ) + for callback in self._scan_done_callbacks: + callback(exception=exc) def _status_callback(self, status: StatusBase, exception=None) -> None: """Callback for status completion.""" @@ -580,6 +583,7 @@ 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._start_monitor_async_data_emission.clear() with suppress_mca_callbacks(self): self.stop_all.put(1) self.erase_all.put(1) diff --git a/csaxs_bec/devices/jungfraujoch/eiger.py b/csaxs_bec/devices/jungfraujoch/eiger.py index 07c27c2..042da1c 100644 --- a/csaxs_bec/devices/jungfraujoch/eiger.py +++ b/csaxs_bec/devices/jungfraujoch/eiger.py @@ -274,16 +274,14 @@ class Eiger(PSIDeviceBase): start_time = time.time() self.scan_parameters = fetch_scan_info(self.scan_info) - # TODO: Check mono energy from device in BEC - # Setting incident energy in keV - + # TODO Reactivate try: incident_energy = self._get_beam_energy(self.device_manager) if self._incident_energy is None: self._incident_energy = round(float(incident_energy), 3) elif not np.isclose( - self._incident_energy, incident_energy, atol=0.01 - ): # 10 keV tolerance + self._incident_energy, incident_energy, atol=0.1 + ): # 0.1 keV tolerance logger.warning( f"Incident energy changed from {self._incident_energy} keV to {incident_energy} keV for device {self.name}. " ) @@ -328,7 +326,7 @@ class Eiger(PSIDeviceBase): beam_x_pxl=int(self._beam_center[0]), beam_y_pxl=int(self._beam_center[1]), detector_distance_mm=self.detector_distance, - incident_energy_ke_v=incident_energy, + incident_energy_ke_v=self._incident_energy, ) logger.debug(f"Setting data_settings: {yaml.dump(data_settings.to_dict(), indent=4)}") prep_time = time.time() @@ -388,12 +386,12 @@ class Eiger(PSIDeviceBase): f"JungfrauJoch broker status: {yaml.dump(broker_status.to_dict(), indent=4)}" ) if broker_status.message_severity == "error": # Raise on error - # raise EigerError( - # f"Device {self.name} acquisition completed with error status from JungfrauJoch broker: {yaml.dump(broker_status.to_dict(), indent=4)}" - # ) - logger.warning( + raise EigerError( f"Device {self.name} acquisition completed with error status from JungfrauJoch broker: {yaml.dump(broker_status.to_dict(), indent=4)}" ) + # logger.warning( + # f"Device {self.name} acquisition completed with error status from JungfrauJoch broker: {yaml.dump(broker_status.to_dict(), indent=4)}" + # ) # Call API endpoint to get statistics statistics: MeasurementStatistics = ( self.jfj_client.api.statistics_data_collection_get(_request_timeout=5) @@ -450,9 +448,6 @@ class Eiger(PSIDeviceBase): Returns: float: The beam energy in keV. """ - if hasattr(device_manager, "devices") and hasattr(device_manager.devices, "ccm_energy"): - energy = device_manager.devices.ccm_energy.read()[ - device_manager.devices.ccm_energy.name - ]["value"] - - return energy + return device_manager.devices.ccm_energy.read()[device_manager.devices.ccm_energy.name][ + "value" + ] diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py index 550cc36..b1b7b4a 100644 --- a/csaxs_bec/devices/omny/galil/galil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py @@ -333,7 +333,11 @@ class GalilController(Controller): def galil_show_all(self) -> None: for controller in self._controller_instances.values(): - if isinstance(controller, GalilController): + if any( + GalilController.__name__ == entry.__name__.split(".")[-1] + for entry in controller.__class__.mro() + ): + # if isinstance(controller, GalilController): controller.describe() @staticmethod diff --git a/csaxs_bec/devices/pseudo_devices/bpm.py b/csaxs_bec/devices/pseudo_devices/bpm.py index a6c22b7..74aad13 100644 --- a/csaxs_bec/devices/pseudo_devices/bpm.py +++ b/csaxs_bec/devices/pseudo_devices/bpm.py @@ -72,10 +72,10 @@ class BPM(PSIPseudoDeviceBase): def __init__( self, name, - left_top: str, - right_top: str, - right_bot: str, - left_bot: str, + left_top_ref: str, + right_top_ref: str, + right_bot_ref: str, + left_bot_ref: str, device_manager=None, scan_info=None, **kwargs, @@ -83,16 +83,16 @@ class BPM(PSIPseudoDeviceBase): super().__init__(name=name, device_manager=device_manager, scan_info=scan_info, **kwargs) # Get all blade signal objects from utility method signal_t = self.left_top.get_device_object_from_bec( - object_name=left_top, signal_name=self.name, device_manager=device_manager + object_name=left_top_ref, signal_name=self.name, device_manager=device_manager ) signal_r = self.right_top.get_device_object_from_bec( - object_name=right_top, signal_name=self.name, device_manager=device_manager + object_name=right_top_ref, signal_name=self.name, device_manager=device_manager ) signal_b = self.right_bot.get_device_object_from_bec( - object_name=right_bot, signal_name=self.name, device_manager=device_manager + object_name=right_bot_ref, signal_name=self.name, device_manager=device_manager ) signal_l = self.left_bot.get_device_object_from_bec( - object_name=left_bot, signal_name=self.name, device_manager=device_manager + object_name=left_bot_ref, signal_name=self.name, device_manager=device_manager ) # Set compute methods for blade signals and virtual signals diff --git a/csaxs_bec/devices/pseudo_devices/bpm_control.py b/csaxs_bec/devices/pseudo_devices/bpm_control.py index 63cb198..288732e 100644 --- a/csaxs_bec/devices/pseudo_devices/bpm_control.py +++ b/csaxs_bec/devices/pseudo_devices/bpm_control.py @@ -50,7 +50,7 @@ class BPMControl(PSIPseudoDeviceBase): """ BPM amplifier control pseudo device. It is responsible for controlling the gain and coupling for the BPM amplifier. It relies on signals from a device - in BEC to be available. For cSAXS, these are most liikely to be from the + in BEC to be available. For cSAXS, these are most likely to be from the GalilRIO device that controls the BPM amplifier. Args: @@ -97,7 +97,7 @@ class BPMControl(PSIPseudoDeviceBase): gain_lsb: str, gain_mid: str, gain_msb: str, - coupling: str, + coupling_ref: str, speed_mode: str, device_manager: DeviceManagerDS | None = None, scan_info: ScanInfo | None = None, @@ -116,7 +116,7 @@ class BPMControl(PSIPseudoDeviceBase): object_name=gain_msb, signal_name=self.name, device_manager=device_manager ) self._coupling = self.gain.get_device_object_from_bec( - object_name=coupling, signal_name=self.name, device_manager=device_manager + object_name=coupling_ref, signal_name=self.name, device_manager=device_manager ) self._speed_mode = self.gain.get_device_object_from_bec( object_name=speed_mode, signal_name=self.name, device_manager=device_manager diff --git a/csaxs_bec/devices/pseudo_devices/signal_forwarder.py b/csaxs_bec/devices/pseudo_devices/signal_forwarder.py index 2d79ba0..69ad94e 100644 --- a/csaxs_bec/devices/pseudo_devices/signal_forwarder.py +++ b/csaxs_bec/devices/pseudo_devices/signal_forwarder.py @@ -20,11 +20,11 @@ class SignalForwarder(PSIPseudoDeviceBase): doc="Forwarded signal", ) - def __init__(self, name, signal: str, device_manager=None, scan_info=None, **kwargs): + def __init__(self, name, signal_ref: str, device_manager=None, scan_info=None, **kwargs): super().__init__(name=name, device_manager=device_manager, scan_info=scan_info, **kwargs) src = self.signal.get_device_object_from_bec( - object_name=signal, signal_name=self.name, device_manager=device_manager + object_name=signal_ref, signal_name=self.name, device_manager=device_manager ) self.signal.set_compute_method(self._compute_signal, signal=src) diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 40948da..1de6bcb 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -62,7 +62,7 @@ _To bypass the fine alignment: `flomni.feye_out`_ 1. `flomni.tomo_parameters()` Adjust the ptychographic scan parameters for performing an alignment scan. Typically FOVX = FOVX(Xrayeye)+20 mu, shell step = beamsize/2.5, number of projections and tomo mode are ignored in the alignment scans. -1. `flomni.tomo_alignment_scan()` perform the alignment scan. When the first scan is running, switch to a matlab session and run `SPEC_ptycho_align` again. Click left and right. The third click can define the height of the scan, but is not needed and ignored by default. The widest horizontal field of view will be printed at the end of the matlab session. +1. `flomni.tomo_alignment_scan()` perform the alignment scan. When the first scan is running, switch to a matlab session and run `BEC_ptycho_align` again. Click left and right. The third click can define the height of the scan, but is not needed and ignored by default. The widest horizontal field of view will be printed at the end of the matlab session. 1. `flomni.read_alignment_offset()` Load alignment parameters calculated in matlab. ### Tomographic Measurement diff --git a/tests/tests_devices/test_pseudo_devices.py b/tests/tests_devices/test_pseudo_devices.py index 60c59a2..336e67c 100644 --- a/tests/tests_devices/test_pseudo_devices.py +++ b/tests/tests_devices/test_pseudo_devices.py @@ -43,7 +43,7 @@ def bpm_control(patched_dm): "gain_lsb": "gain_lsb", "gain_mid": "gain_mid", "gain_msb": "gain_msb", - "coupling": "coupling", + "coupling_ref": "coupling", "speed_mode": "speed_mode", }, needs=["gain_lsb", "gain_mid", "gain_msb", "coupling", "speed_mode"], @@ -55,7 +55,7 @@ def bpm_control(patched_dm): gain_lsb="gain_lsb", gain_mid="gain_mid", gain_msb="gain_msb", - coupling="coupling", + coupling_ref="coupling", speed_mode="speed_mode", device_manager=patched_dm, ) @@ -150,10 +150,10 @@ def bpm(patched_dm_bpm): enabled=True, readoutPriority="baseline", deviceConfig={ - "left_top": "left_top", - "right_top": "right_top", - "right_bot": "right_bot", - "left_bot": "left_bot", + "left_top_ref": "left_top", + "right_top_ref": "right_top", + "right_bot_ref": "right_bot", + "left_bot_ref": "left_bot", }, needs=["left_top", "right_top", "right_bot", "left_bot"], ) @@ -161,10 +161,10 @@ def bpm(patched_dm_bpm): try: bpm = BPM( name=name, - left_top="left_top", - right_top="right_top", - right_bot="right_bot", - left_bot="left_bot", + left_top_ref="left_top", + right_top_ref="right_top", + right_bot_ref="right_bot", + left_bot_ref="left_bot", device_manager=patched_dm_bpm, ) patched_dm_bpm.devices._add_device(bpm.name, bpm)