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`.
+1 -1
View File
@@ -35,7 +35,7 @@ Pass `keep_shutter_open=True` if it's hard to relocate the sample between steps,
**Experimental: continuous-rotation "smear" aid**
`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0, keep_shutter_open=False)` is an **experimental** alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` (default a full circle) while accumulating a max-projection ("star-trail") composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. `sweep_deg` can be reduced below 360 (even below 180) if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`, and the same iterate/verify/apply flow follows. Not yet merged into the production branch; try it from `experimental/rotation_center_smear`.
`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0, keep_shutter_open=False)` is an **experimental** alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` (default a full circle) while accumulating a max-projection ("star-trail") composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. The composite builds up live on screen as the sweep progresses (pushed to its own preview channel, so the "camera running" indicator stays steady throughout — see {ref}`the developer notes <developer.lamni_smear_architecture>` for why that matters) and stays frozen once the sweep ends, until you submit your click. `sweep_deg` can be reduced below 360 (even below 180) if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`, and the same iterate/verify/apply flow follows. Not yet merged into the production branch; try it from `experimental/rotation_center_smear`.
**Manual fallback**