docs(LamNI): document smear aid architecture and camera/GUI interfaces

Add docs/developer/lamni_smear_architecture.md covering the three-tier
device/ipython-client/GUI architecture this feature exercises: the
camera's two PreviewSignal channels (image vs smear_preview) and why
composite pushes needed their own channel, XRayEye.set_live_view_signal
and exactly when the caller must switch it back, the sweep algorithm
(non-blocking mv() + ScanReport.status polling + max-projection), and
the bw-generate-cli regeneration step required whenever a plugin
widget's USER_ACCESS changes (the cause of this session's confusing
AttributeError even after a full client/GUI restart).

Also expand the user-facing description in lamni.md with a note on the
live-updating composite display and a cross-reference to the new
developer page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-24 16:08:01 +02:00
co-authored by Claude Sonnet 5
parent e2533d286f
commit 831e75675c
3 changed files with 181 additions and 1 deletions
+12
View File
@@ -9,6 +9,7 @@ hidden: true
---
editing_docs
lamni_smear_architecture
```
@@ -30,4 +31,15 @@ editing_docs
Conventions for writing these MyST/Sphinx docs and how changes go live on Read the Docs.
```
```{grid-item-card}
:link: developer.lamni_smear_architecture
:link-type: ref
:text-align: center
:class-item: index-card
## LamNI smear aid architecture
Device/ipython-client/GUI widget interfaces for the smear rotation-center calibration aid, and the `bw-generate-cli` regeneration pitfall.
```
````
+168
View File
@@ -0,0 +1,168 @@
(developer.lamni_smear_architecture)=
# LamNI smear rotation-center aid: architecture notes
The experimental continuous-rotation "smear" calibration aid (see
{ref}`the user-facing description <user.ptychography.lamni>`) 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.<method>(...)` and
`self.gui.<method>(...)` 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`.