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>
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 counterpartcsaxs_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 andput()s them here at ~5 Hz whilelive_mode_enabledisTrue. Configured per-device withnum_rotation_90/transpose(e.g.num_rotation_90=3forcam_xeye), applied automatically insidePreviewSignal.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; returnsself.image.get().data, i.e. already display-oriented (rotation/transpose already applied by whichever.put()call stored it).push_preview_image(data)— write side forimage. Since data built fromget_last_image()frames has already been transformed once, this method undoes the transform first soself.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 forsmear_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:
scans.mv(dev.lsamrot, target)(the file'smv()wrapper, sibling of the existing blockingumv()) issues the rotation non-blocking — it returns aScanReportimmediately rather than waiting for the move to finish.- A plain loop polls
report.status(exactly whatScanReport.wait()does internally) and, while it isn't"COMPLETED", grabs a frame viaget_last_image()and folds it into a runningnp.maximumcomposite — all single-threaded, since the rotation is progressing concurrently on the server regardless of what the client does between polls. - Every
display_update_interval_s, the current composite is pushed tosmear_previewso 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. - 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.