Files
csaxs_bec/docs/developer/lamni_smear_architecture.md
T
x01dcandClaude Sonnet 5 831e75675c 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>
2026-07-24 16:08:01 +02:00

8.9 KiB

(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:

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:

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.