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:
@@ -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.
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user