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>
169 lines
8.9 KiB
Markdown
169 lines
8.9 KiB
Markdown
(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`.
|