(developer.lamni_smear_architecture)= # LamNI smear rotation-center aid: architecture notes The experimental continuous-rotation "smear" calibration aid (see {ref}`the user-facing description `) touches all three layers of a typical BEC GUI feature — a **device**, an **ipython-client plugin**, and a **GUI widget** — running as three separate processes talking over BEC's Redis-backed message bus. It's a useful worked example for how those layers connect, and it surfaced a couple of non-obvious pitfalls worth documenting so the next person doesn't have to re-discover them. ## The three tiers Three separate processes, connected over BEC's Redis-backed message bus: ```text ipython client (XrayEyeAlign, LamNI) │ ├── device RPC ──────► IDSCamera / SimIDSCamera (device server process) │ dev.cam_xeye.push_smear_preview(...) │ │ │ image / smear_preview │ │ PreviewSignal (pub/sub) │ ▼ └── widget RPC ──────► XRayEye widget (GUI server process) self.gui.set_live_view_signal(...) ``` - **`IDSCamera`** (`csaxs_bec/devices/ids_cameras/ids_camera.py`, simulated counterpart `csaxs_bec/devices/sim/sim_cameras.py`) runs in the device server process and owns the actual camera hardware (or its simulation). - **`XrayEyeAlign`** / **`LamNI`** (`csaxs_bec/bec_ipython_client/plugins/LamNI/`) run in the operator's ipython client process and orchestrate the calibration procedure — rotating the sample, grabbing frames, driving the GUI. - **`XRayEye`** (`csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`) is a Qt widget running in its own GUI server process, displaying the live image and collecting the operator's click. Devices and widgets each expose a `USER_ACCESS` list of methods callable remotely; the ipython client reaches them as `dev.cam_xeye.(...)` and `self.gui.(...)` respectively. Image data itself flows over a separate publish/subscribe channel (`PreviewSignal`), not through those RPC calls. ## Camera interface: two preview channels `IDSCamera` exposes two `PreviewSignal` components: - **`image`** — the live-acquisition channel. A background thread (`_live_mode_loop`) continuously grabs frames and `put()`s them here at ~5 Hz while `live_mode_enabled` is `True`. Configured per-device with `num_rotation_90`/`transpose` (e.g. `num_rotation_90=3` for `cam_xeye`), applied automatically inside `PreviewSignal.put()`. - **`smear_preview`** — a second, independent channel, added for this feature, with **no** rotation/transpose configured. Nothing else writes to it, so publishing here never races with the live thread. `PreviewSignal` (and every `BECMessageSignal` subclass) has `rpc_access` hardcoded off, so it is **not** reachable as a plain attribute from client code — `dev.cam_xeye.image.put(...)` raises `AttributeError` even though the signal exists on the real device. The supported pattern is a plain device method that does the `.put()` server-side, where the signal *is* a normal ophyd attribute: - `get_last_image()` — read side; returns `self.image.get().data`, i.e. already display-oriented (rotation/transpose already applied by whichever `.put()` call stored it). - `push_preview_image(data)` — write side for `image`. Since data built from `get_last_image()` frames has already been transformed once, this method undoes the transform first so `self.image.put()`'s own transform doesn't apply it a second time (compensation is reverse-order: transpose first, then rotate by `-num_rotation_90`). - `push_smear_preview(data)` — write side for `smear_preview`. No compensation needed or applied, since that channel has no transform configured. **Why two channels, not one:** the first working version pushed composites through `push_preview_image` (the `image` channel), which meant briefly setting `live_mode_enabled = False` around every push so the live thread couldn't immediately overwrite the composite, then setting it back to `True` afterward. That's instant in simulation, but on real hardware toggling `live_mode_enabled` starts/stops the actual acquisition thread and has real, non-trivial latency — repeated dozens of times over a sweep, this was the dominant cost and showed up as the GUI's "camera running" indicator visibly flapping. Giving the composite its own channel means `live_mode_enabled` is never toggled during a sweep at all — it's set to `True` once at the start (if not already), exactly like the production `_live_sweep()`, and left alone. ## GUI interface: switching the displayed channel `XRayEye.set_live_view_signal(signal="image")` switches which `(device, signal)` pair the widget's `Image` view is subscribed to (via the same `image()`/`disconnect_monitor()` calls `on_live_view_enabled()` already used, now parameterized instead of hardcoded to `"image"`). Every freshly constructed `XRayEye` defaults to `"image"`, so opening the GUI or running any other alignment routine is unaffected unless something explicitly calls `set_live_view_signal("smear_preview")`. The smear sweep (`XrayEyeAlign._smear_sweep`) switches to `"smear_preview"` at the start and pushes composite updates there as the rotation progresses — but **deliberately does not switch back to `"image"` itself** on a successful return. The composite has to stay on screen until the operator's click has actually been collected (`find_rotation_center_smear_experimental` switches back right after `_collect_click()` returns); switching back inside `_smear_sweep()` would show the operator a live raw frame instead of the composite at exactly the moment they need to click on it. On `KeyboardInterrupt` mid-sweep there's nothing to click on, so that path switches back to `"image"` immediately — both inside `_smear_sweep()`'s own handler and, as a second safety net, in `lamni.py`'s top-level `except KeyboardInterrupt` in case the interrupt lands after the sweep but before the click completes. ## How the sweep itself works `_smear_sweep()` rotates continuously and accumulates frames without stepping the motor: 1. `scans.mv(dev.lsamrot, target)` (the file's `mv()` wrapper, sibling of the existing blocking `umv()`) issues the rotation **non-blocking** — it returns a `ScanReport` immediately rather than waiting for the move to finish. 2. A plain loop polls `report.status` (exactly what `ScanReport.wait()` does internally) and, while it isn't `"COMPLETED"`, grabs a frame via `get_last_image()` and folds it into a running `np.maximum` composite — all single-threaded, since the rotation is progressing concurrently on the server regardless of what the client does between polls. 3. Every `display_update_interval_s`, the current composite is pushed to `smear_preview` so the operator watches it grow; off-axis features trace arcs as the sample turns, and the common center of curvature of those arcs is the rotation axis. 4. Once the move completes, a final push leaves the finished composite displayed, the shutter is closed (unless `keep_shutter_open`), and the operator submits a click — reusing the exact same `_collect_click()` / `_compute_shift_to_fzp()` / `_apply_rotation_center_shift()` machinery and cumulative-shift accounting as the production `..._extended()` path. ## Pitfall: regenerate the RPC client stub after changing `USER_ACCESS` :::{important} Whenever you add or rename a method in a plugin widget's `USER_ACCESS` list (e.g. `XRayEye.USER_ACCESS` in `x_ray_eye.py`), you must run: ```bash bw-generate-cli --target csaxs_bec ``` and commit the resulting changes to `csaxs_bec/bec_widgets/widgets/client.py` (and `designer_plugins.py`). Skipping this step does **not** raise an error at import time or widget-construction time — it fails much later, and confusingly, as an `AttributeError` raised from deep inside `bec_widgets`'s RPC plumbing, even after fully restarting both the ipython client and the GUI server process. ::: The reason: `csaxs_bec/bec_widgets/widgets/client.py` is a **checked-in generated file** containing one RPC stub class per plugin widget, mirroring its `USER_ACCESS` at the time `bw-generate-cli` was last run. The ipython client's `BECGuiClient._add_widget()` resolves a widget's class by **name lookup against that generated module** (`getattr(client, state["widget_class"], None)`) — not by dynamically importing the real widget class — so a stale generated file means the new method genuinely doesn't exist anywhere the client can find it, no matter how fresh the actual `XRayEye` class on disk is. This is exactly the same kind of generated-stub step core `bec_widgets` widgets also need (their generated file lives in the `bec_widgets` package itself); plugin widgets just have their own copy living inside `csaxs_bec`.