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