IDSCamera's live-mode loop had a hardcoded 0.2s sleep (nominal 5 Hz
cap) between frame grabs. Expose it as live_mode_poll_interval_s
(default 0.2, unchanged for existing cameras) and lower it for LamNI's
cam_xeye (0.02s) so exposure/acquisition time, not this artificial
sleep, becomes the real limit on smear-sweep capture rate.
RtLamniReadbackSignal._socket_get() printed the same raw socket
response twice in a row with nothing modifying it in between --
leftover debug output, not by design.
feedback_enable_with_reset() silently rotates lsamrot back to 0 as part
of the interferometer reset; when the stage is far from 0 this can take
a while with no feedback in the client session. A plain print() there
only reaches the device-server console, not the client, so use
connector.send_client_info() instead.
- Remove the manual fit-parameter reload docs: fit params already load
automatically, and the fallback path pointed at the retired
~/Data10/specES1 layout.
- Fix stale slits/slit0wh references (slits takes no argument; the
gap monitor is now slit1).
- Stop writing the plain-text xrayeye_alignmentvalues archive in
write_output() -- it was only for external fitting scripts and now
collides with xrayeye_alignmentvalues being a directory used for the
per-run HDF5 archives.
Both prompts appear while lsamrot happens to be at 0 deg at that point in
the procedure, but the click itself isn't specific to 0 deg -- just the
current position.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
loptics_out() already drives lsamrot to 0 via
RtLamniController.feedback_enable_with_reset() when rtx is enabled, so the
explicit tomo_rotate(0) that followed in find_rotation_center() and
find_rotation_center_smear_experimental() was reissuing a move that had
mostly already happened -- align() already skips this same step (see its
commented-out disable/rotate/re-enable at the equivalent spot).
_tomo_rotate_if_needed() keeps the still-needed pieces: it still corrects
small residual drift from feedback briefly running before
_disable_rt_feedback(), and still performs the move when rtx is disabled
(loptics_out() then never touches lsamrot at all).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Default was 0.5 (LamniGalilMotor's built-in fallback); this is the
tolerance used both by the normal move-success check and by the new
auto-retry logic. Applied to both the real deployment and the
simulated deployment config.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A safety thread on the LAMNI Galil controller supervises air pressure and
capacitive-sensor feedback for the rotation stage; a spurious trip drops
allaxref and sets allaxrer/caperr, failing every subsequent lsamrot move
until manually cleared with allaxrer=1/caperr=0/allaxref=1.
LamniGalilMotor.move() now resets those latches and retries once, scoped
to lsamrot only via LamniGalilController.reset_axis_errors() -- other
axes on the shared controller keep today's behavior unchanged, and a
persisting fault still surfaces as a failure after the single retry.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
update_frame() (reached via lamni.leye_in() -> xrayeye_update_frame())
used self.gui (lamni.xeyegui) directly, but that's None until the widget
has been opened by lamnigui_show_xeyealign() -- which align() and
find_rotation_center() call first, but update_frame() never did. If it's
the first X-ray-eye call of a session, this raised
AttributeError: 'NoneType' object has no attribute 'on_live_view_enabled'.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Neither rate was previously measured anywhere, making it hard to tell a
real acquisition bottleneck from a display/overhead one.
- IDSCamera.get_live_fps() (new USER_ACCESS entry): rolling-buffer
measurement of the live-mode push rate in _live_mode_loop(), readable as
dev.cam_xeye.get_live_fps(). SimIDSCamera inherits it unchanged, so it
works against the simulated deployment too.
- XrayEyeAlign._smear_sweep() now tracks timestamps of the first and most
recent distinct frame it captures, storing the computed rate as
self.last_smear_fps and printing it in the sweep-done summary line.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three follow-ups from real-hardware testing of the smear aid:
1. Switching the "Smear preview channel" toggle off then back on showed
the live camera feed, not the previous composite. Root cause:
Image.image()'s connect_slot subscription only delivers stream
entries published *after* connecting (bec_lib's default
register(from_start=False, newest_only=False) bookmarks the
then-current last entry and only forwards newer ones) -- so nothing
re-renders on reconnect until the next sweep pushes a new frame.
Added a permanent, independent subscription (newest_only=True) that
keeps the last smear_preview payload cached regardless of which
channel is currently displayed; set_live_view_signal() now force-
renders that cache via main_image.set_data() when switching back to
"smear_preview", instead of waiting for a push that may never come.
Naturally "resets" once a new sweep starts pushing fresh composites.
2. The new switch row wasn't column-aligned with the existing
shutter/camera-running row (each used an independent QHBoxLayout
sized by its own labels' widths). Merged both rows into one
QGridLayout with right-aligned label columns, so all four toggles
line up regardless of label text length. Shutter now pairs with
Smear integrating, Camera running with Smear preview channel.
3. find_rotation_center_smear_experimental() now archives each run to
a timestamped HDF5 file (~/data/raw/logs/xrayeye_smear_calibration/),
mirroring align()'s existing _save_alignment_data() pattern: the FZP
reference, one composite image + one clicked centre per iteration,
and the final result -- collecting data towards eventually
automating center detection from the composite offline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A Ctrl-C interrupting an in-flight RPC call (e.g. mid
set_live_view_signal) could leave the XRayEye widget stuck showing the
smear-preview channel afterward -- and since lamnigui_show_xeyealign()
reuses the existing xeyegui widget across calls rather than recreating
it, that stale state would silently carry into the *next* alignment
run too (including production align()/find_rotation_center(), since
on_live_view_enabled() now respects whichever channel is selected).
Fix: lamnigui_show_xeyealign() unconditionally resets both
set_live_view_signal("image") and set_smear_active(False) every time
it's called -- both are cheap no-ops if already in the default state,
and this is the single shared entry point for every alignment
procedure, so one fix covers all of them.
Also add two GUI-facing controls to XRayEye, in a new row below the
shutter/camera-running toggles:
- "Smear preview channel" -- an interactive toggle mirroring
set_live_view_signal(), so the operator has a GUI-only recovery path
independent of the ipython client (e.g. if it's hung/disconnected).
- "Smear integrating" -- a read-only status indicator (disabled
ToggleSwitch) reflecting whether _smear_sweep() is currently
accumulating a composite, driven entirely by the automation.
set_live_view_signal() keeps the interactive toggle's visual state in
sync regardless of who changed the channel (automation or operator) --
single source of truth. _smear_sweep() brackets accumulation with
set_smear_active(True)/(False), turning it off as soon as the sweep
ends (independent of the channel switch, which stays on the composite
until the operator's click is collected). Both new XRayEye methods
required regenerating csaxs_bec/bec_widgets/widgets/client.py via
bw-generate-cli --target csaxs_bec.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
csaxs_bec/bec_widgets/widgets/client.py is a checked-in generated file
(via `bw-generate-cli --target csaxs_bec`) containing the client-side
RPC stub for each plugin widget's USER_ACCESS surface -- BECGuiClient
resolves widget classes by name against this exact file, not via a
dynamic import of the real widget class, so it must be regenerated
whenever a plugin widget's USER_ACCESS changes.
Picks up XRayEye.set_live_view_signal (added for the LamNI smear
calibration aid) plus other plugin widgets that had drifted out of
sync already (SAXSWidget.move_to_selected_row, a SampleStorageWidget/
TomoParamsWidget docstring update) -- unrelated to this session's work
but caught by the same regeneration pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The simulated speckle pattern rotated exactly around image center --
the same point the FZP crosshair sits at -- so the calibration was
trivially already correct in sim, never exercising the actual
find-and-correct-a-miscalibration path.
Add an opt-in axis_offset_range_px/axis_offset_seed: when set, the
"true" rotation axis the speckle field orbits is offset from image
center by a random amount (fresh each device construction unless a
seed is given, so each sim session gets a new miscalibration to find).
Wired into LamNI's cam_xeye sim config (sim_axis_offset_range_px: 40,
no seed -- random each session); default (0.0) preserves prior
behavior exactly for any other SimIDSCamera user.
Also drop the synthetic crosshair lines baked into
make_speckle_test_pattern's pixel data -- a real camera image has no
such marking; the GUI already draws its own crosshair overlay
(TargetCrosshair) on top of the live image.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The "camera running" toggle (IDSCamera.live_mode_enabled) was flapping
on/off during the smear sweep, and on real hardware that's genuinely
slow (starting/stopping the live acquisition thread has real driver
overhead, unlike the simulator). Root cause: composite pushes shared
the same device_preview channel the live thread continuously publishes
to, so live_mode_enabled had to be toggled off around every push to
keep the composite from being instantly overwritten.
Give the composite its own channel instead:
- IDSCamera gains a smear_preview PreviewSignal (no rotation_90/
transpose configured -- composite data is already display-oriented)
and push_smear_preview(), mirroring push_preview_image() but fully
decoupled from the live channel.
- XRayEye gains set_live_view_signal() to switch which camera signal
the live Image view displays, defaulting to the normal channel for
every fresh GUI instance; on_live_view_enabled() now uses whichever
signal is currently selected instead of a hardcoded constant.
- _smear_sweep() switches the GUI to "smear_preview" for the sweep and
pushes composite updates there -- live_mode_enabled is never touched
at all (just ensured on at the start, exactly like _live_sweep()).
The GUI is deliberately left on the composite after a successful
sweep until the caller has collected the operator's click, then
switched back to "image"; on KeyboardInterrupt it's switched back
immediately.
This removes the resume_live/hold_display_s toggle-avoidance mechanism
added in def9bf4, which is no longer needed with a dedicated channel.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Real-HW-adjacent (sim) testing surfaced three issues with the smear
calibration aid:
1. The composite reverted to the plain rotating live view right before
the click prompt. find_rotation_center_smear_experimental() called
update_frame() after _smear_sweep(), which grabs a fresh raw live
frame -- overwriting the composite it just built. Also,
_push_smear_composite always resumed live mode, so even without that
bug the composite would only stay visible for hold_display_s.
Fixed: _push_smear_composite gained resume_live=False for the final
push; _smear_sweep now takes keep_shutter_open and closes the
shutter itself at the end, so the composite (not a fresh grab) stays
frozen on screen for the click. The stray update_frame() call is
removed.
2. The composite appeared rotated relative to the live view.
get_last_image() frames already have num_rotation_90/transpose
applied (PreviewSignal.put() applies it to whatever it stores), so
composites built from those frames got the transform applied AGAIN
by push_preview_image()'s own self.image.put() call. Fixed:
push_preview_image() now undoes the transform first (reverse order,
since transpose is applied last forward) so the net effect is a
single transform, matching a live frame.
3. Feature request: the sim camera's rotation-coupled pattern was a
single eccentric gaussian blob -- a better analog for the isolated/
point-particle case than the extended/textured case this feature
actually targets. Added generate_speckle_grains/
make_speckle_test_pattern (sim_cameras.py): a fixed, seeded field of
small gaussian "grains" at a range of radii, rotating rigidly
together as the live angle changes -- producing several concentric
arcs in the composite instead of one, much closer to a real textured
sample. New sim_speckle_count/sim_speckle_seed config, defaults
requiring no yaml changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The simulated cam_xeye (SimIDSCamera/_SimIDSBackend) served a single
static Gaussian blob, cached at construction -- rotating lsamrot in a
simulated session produced no visible motion, so the smear/composite
rotation-center calibration aid had nothing to smear into an arc.
Add an opt-in sim_rotation_coupling config (mirrors the existing
sim_coarse_coupling idiom in sim_lamni.py's measured_positions()): when
set, _SimIDSBackend regenerates a fresh frame on every get_image_data()
call with an eccentric blob (make_rotating_test_pattern) tracking the
coupled axis' live simulated Galil position instead of serving the
cached static frame. Wired into LamNI's cam_xeye sim config only
(host/port/axis matching lsamrot's own sim config); flomni's cam_xeye
and any other SimIDSCamera user are unaffected since the new params
default to None (byte-identical output to before, confirmed by test).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dev.cam_xeye.image.put(composite) failed on real hardware --
PreviewSignal is a BECMessageSignal subclass, and BEC hardcodes
rpc_access=False for that signal_info, so the client-side device proxy
never exposes `image` as an attribute at all (confirmed: this is a
structural BEC constraint, not specific to this device -- no existing
RPC surface, device or widget, offers a raw-array setter either).
Add IDSCamera.push_preview_image(data), a small device-server-side
method (alongside the existing get_last_image()) that does
self.image.put(data) from within the device-server process, where
PreviewSignal is a normal ophyd attribute. _push_smear_composite now
calls dev.cam_xeye.push_preview_image(composite) instead of writing to
the signal directly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds an EXPERIMENTAL, operator-only alternative to the extended-sample
rotation-center click: instead of judging the center from a single
instant of live rotation, accumulate a max-projection composite while
lsamrot rotates continuously through a (tunable, default 360 deg)
sweep. Off-axis features smear into circular arcs; the common center
of curvature is the rotation axis, an easier target to click than one
live frame. No automatic circle fitting -- purely a visual aid feeding
the same _collect_click mechanism the production feature already uses.
Rotation is issued non-blocking via scans.mv() (new local wrapper,
sibling of this file's existing blocking umv()) and frames are grabbed
via get_last_image() in a single-threaded loop polling ScanReport.status
-- the same thing ScanReport.wait() does internally -- so no background
thread or device-server change is needed to grab frames while the motor
is actively moving. The composite is pushed to the GUI through the
camera's existing PreviewSignal (dev.cam_xeye.image), duty-cycling
live_mode_enabled off/on around each push so the background 5 Hz live
thread can't immediately overwrite it.
New entry point: lamni.xrayeye_rotation_center_calibration_smear_experimental().
Purely additive -- does not modify find_rotation_center()/_live_sweep()
or the two production entry points.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Reword the extended-loop accept/iterate prompt: the old either/or
phrasing made a "yes" answer ambiguous about which half it affirmed.
- Freeze a single frame (and close the shutter/live-view per
keep_shutter_open) before requesting the rotation-centre click, same
as every other click site in this file -- previously the shutter and
5 Hz live view stayed on for the whole time the operator was
deciding where to click, exposing the sample for no benefit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
lamni_move_to_scan_center's shift_x/shift_y are absolute offsets from the
currently-configured lsamx_center/lsamy_center (unchanged until the operator
confirms at the end of the procedure), not incremental deltas from wherever
the stage currently sits. The extended-sample iteration loop was passing
only the latest click's delta each time, so each new iteration partially
undid the previous one's correction instead of adding to it. Fixed by
accumulating the total shift across iterations and always applying the
running total.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
rotation_center_calibration_start(sample_type="isolated") didn't match the
xrayeye_* naming convention used by the other X-ray eye entry points, and
the sample_type string enum was easy to forget (both the parameter name
and its accepted values). Split into two entry points instead:
lamni.xrayeye_rotation_center_calibration_isolated(keep_shutter_open=False)
lamni.xrayeye_rotation_center_calibration_extended(keep_shutter_open=False)
Both delegate to a shared _xrayeye_rotation_center_calibration() private
helper (single copy of the KeyboardInterrupt cleanup). Updated the user
docs to match; XrayEyeAlign.find_rotation_center()'s own sample_type
parameter is unchanged since it's internal shared implementation, not
what was being objected to.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three refinements found while testing find_rotation_center() against the
simulated LamNI:
- Drop the trailing tomo_rotate(0) at the end of find_rotation_center():
both the isolated path (explicit rotate-back before applying the
correction) and the extended path (verification sweep ends at 0) already
leave lsamrot at 0 by then, so it was a pure duplicate move.
- Simplify _live_sweep() to rotate directly to each waypoint instead of
stepping through artificial step_deg-sized sub-moves with settle pauses
in between -- a single tomo_rotate() already produces continuous physical
rotation, so the stepping added nothing but jerkier motion and extra
queue overhead.
- Add _ensure_at_configured_center(), called at the start of
find_rotation_center() before lfzp_in(). lamni_move_to_scan_center's
interferometer-drift safety check (LamNIFermatScan.py) assumes lsamx/
lsamy start near their configured center and silently skips the entire
corrective move if that drift exceeds 150um -- exactly the situation
find_rotation_center() is meant to handle, since its purpose is
recalibrating a potentially-stale center. Confirmed this was a real gap:
a run where the stage hadn't started at its configured center produced a
correct absolute target (verified via the interferometer reading) but a
confusingly small apparent motor delta.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both align() and find_rotation_center() moved the FZP in and immediately
asked the operator to click its centre, but only confirmed OSA was out
afterwards (via loptics_out(), which runs after that click). If OSA was
left in from a previous scan setup, it would still be in the beam path
during the FZP reference click. Add an explicit losa_out() call right
before lfzp_in() in both procedures; it's a cheap, skip-if-already-out
no-op otherwise.
Found while testing find_rotation_center() against the simulated LamNI.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
lsamx_center/lsamy_center have to be re-measured by hand for nearly every
new sample (the rotation stage is tilted on top of lsamx/lsamy, so the
effective axis position shifts with sample thickness/mounting). Add
XrayEyeAlign.find_rotation_center()/lamni.rotation_center_calibration_start()
to automate this using the existing X-ray eye GUI and lamni_move_to_scan_center
interferometer-feedback move:
- "isolated" sample: click particle centre at 0 and 180 deg, use the
midpoint (angle-tilt-independent) as the rotation axis position.
- "extended" sample: live 0->180->0 sweep for visual identification, single
click, then a verification sweep and an accept/iterate prompt.
Both write the result to lsamx/lsamy's "center" userParameter after
confirmation. Heavily verbose logging throughout since this is fundamentally
an interactive, hardware-in-the-loop procedure that can't be fully exercised
by automated tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
renderInstrument() checked setup.temperatures for truthiness, but an
empty JS object is truthy, so LamNI's deliberately-empty temperatures
dict (it has no monitoring device) still rendered an empty
"Temperatures" heading/table. flomni only worked by coincidence since
its _TEMP_MAP always has entries. Adds a hasTemps check
(Object.keys(...).length>0), mirroring the existing hasParams idiom
one line above for the same card.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
HttpUploader now accepts a single URL or a list, fanning every upload
(dir/changed/single-file/cleanup) out to each configured server with an
independent per-URL mtime cache, so a newly added mirror backfills on
its own and a failing one doesn't block the others. Session-auth
endpoints (session_query/set_password) still derive from the primary
URL only.
flomni.py/lamni.py now mirror to https://omny-test.psi.ch/upload.php
alongside production, marked TEMP for easy removal once that server
is evaluated. Adds OMNY_shared/AI_docs/WEBPAGE_UPLOAD_MECHANISM.md
consolidating the upload mechanism (the prior doc predates this file
and still called LamNI a stub).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Lets testing/debugging sessions running under a mismatched BEC account
still exercise the real upload path on demand -- e.g. to confirm uploads
work from the allowed subnet, or to confirm the remote server's IP
whitelist rejects uploads from outside it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EpsStatusGenerator was given its own upload_url when embedded in
WebpageGeneratorBase, spinning up a second HttpUploader that redundantly
re-scanned and re-POSTed the same shared output directory the base
generator's own uploader already covers every cycle. It now always embeds
with upload_url=None. Also adds .svg to HttpUploader's suffix whitelist so
eps_synoptic.svg is actually picked up (matches a corresponding fix already
applied server-side in upload.php/auth_gate.php).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Lands the standalone EPS status prototype (OMNY_shared/eps/) as tracked
files and wires it into WebpageGeneratorBase so it starts alongside the
experiment status page, sharing one output dir/port. Demo-vs-real data is
now auto-detected from the host's subnet (129.129.122.x = on the X12SA
beamline) instead of requiring an explicit --demo flag, independent of the
existing account-mismatch local-only fallback. Cross-links the two page
families via a new EPS nav button in status.html's header (the EPS pages
already linked back). Also moves HttpUploader/LocalHttpServer into a
shared web_common.py, picking up an SSRF fix (allow_redirects=False) that
the inline copy lacked.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test_lamni_account_change_reset.py and test_lamni_tomo_angles.py set
builtins.__dict__["bec"] directly with no cleanup, so a leaked fake
account ("e22222") from an earlier test file was still in place when
test_x_ray_eye_align.py later constructed a real LamNI(), tripping
_maybe_reset_params_on_account_change()'s interactive yesno() prompt
and failing under pytest's captured stdin. Use monkeypatch.setitem so
pytest reverts the global after each test, matching the pattern already
used in test_lamni_tomo_alignment_scan.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a worked example to both lamni.md and flomni.md showing a hook
loaded from a file that temporarily bumps tomo_countingtime and
tomo_shellstep every 5th projection within sub-tomogram 2 (tomo_type 1),
restoring both in a finally block -- demonstrating that a hook can
change any scan parameter, not just move devices like the existing
polarizer example. Notes that progress["subtomo"]/["subtomo_projection"]
work the same way for tomo_types 2/3, just with an open-ended
sub-tomogram count there instead of type 1's fixed 8.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed this script no longer works and needs a rewrite. Commented
out the subprocess.run() call in write_pdf_report(), mirroring Flomni
which already has the identical call disabled the same way. omny.py's
own copy is left untouched (out of scope for this migration).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Flomni.add_sample_database() and LamNI.add_sample_database() were
byte-for-byte identical -- both just forward to
self.tomo_id_manager.register(), a TomoIDManager from
omny/omny_general_tools.py that each setup already constructs
independently in its own __init__. Moved the method into
TomoQueueMixin (OMNY_shared), the base class both already inherit,
same "moved code, not changed logic" precedent as the tomo-queue
backend and webpage generator. omny.py's own separate, older
add_sample_database() (different URL, no e-account fallback) is
untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Flomni's ring-progress center label already showed "Est. finish" and
the active at-each-angle hook name; LamNI's equivalent
(_lamnigui_update_progress()) was missing both. Ported the same
formatting/display logic over so the two setups' progress displays
match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
write_pdf_report() didn't mention the active at_each_angle_hook at all,
unlike Flomni's equivalent which already appends the hook's description
and source to both the PDF and its scilog entry. Ported that pattern
over using the same _describe_active_hook()/_active_hook_source() shared
helpers lamni already uses elsewhere (end-of-scan summary,
tomo_parameters() printout) -- no new logic needed, just the missing
call site.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
LamNI's tomo_scan() wrote a separate scilog entry ("Starting subtomo:
N...") every time the sub-tomogram number changed -- 8 extra entries
per tomo_type-1 scan, and one per sub-tomogram traversed for types 2/3
-- leftover behavior carried over from omny.py during the flomni->lamni
port. Flomni itself never does this: it only writes at scan start (PDF
report) and scan end (timing summary). Removed the three
_write_subtomo_to_scilog() call sites and the now-unused method so
LamNI's scilog behavior matches Flomni's.
Also fixed a stale test (test_tomo_queue_reacquire_rejects_when_another_job_is_incomplete)
that encoded the old, buggy tomo_queue_reacquire() invariant (rejecting
on ANY other job being incomplete/running) rather than the fixed one
from the prior commit (only an EARLIER job is a real conflict) --
split it into two tests covering both directions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_format_projections() computed type 3's count with type 1's fixed
"N*8 sub-tomograms" formula, ignoring golden_max_number_of_projections
-- the value that actually bounds a type-3 run (lamni.py/flomni.py's
type-3 scan loop has no sub-tomogram cap, it stops only once
ii > golden_max_number_of_projections, same as type 2). This produced
an ~8x-inflated "Projections" column (e.g. 240 instead of 30) for any
type-3 job. Merged type 3 into type 2's branch since both share the
same uncapped/golden_max_number_of_projections model.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The single-resumable-job invariant check scanned all other jobs, but
jobs after job_index get force-reset to pending anyway -- so a later
incomplete/running job was never a real conflict, only an earlier one
is (since earlier jobs are left untouched).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
register_at_each_angle_hook() stores a function object by reference, so
editing a hook's def and forgetting to re-register left the old version
running silently -- easy to miss mid-beamtime. Detect it via
func.__globals__ (a redefined top-level def, or a reloaded module,
mutates the same namespace dict in place) and auto-adopt the new
definition with a printed note instead of failing or staying stale.
Centralizes the shared lookup/error logic (previously duplicated in
lamni.py and flomni.py) into TomoQueueMixin._resolve_at_each_angle_hook().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The tool is called BEC_ptycho_align now, not SPEC_ptycho_align.m as
the previous docs/comments said -- fixes the references introduced in
the previous commit (12b2538) in lamni.py's docstrings/print message
and the user docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the awkward documented workaround (configure a full tomo_type
1 setup with 96 projections, then launch just sub_tomo_scan(1, 0)) with
a dedicated command, ported from flomni's tomo_alignment_scan(): adjust
tomo_parameters() (FOV/step/counting time), then call
lamni.tomo_alignment_scan() directly -- no tomo_type/sub-tomogram
bookkeeping involved, matching flomni's clean two-step workflow.
Runs 12 points evenly spaced across the full 360 degrees (lamni has no
180-degree symmetry the way flomni does, so unlike flomni's 5-point/
180-degree scan, this covers the full circle -- point count matches
what the old workaround's docs defaulted to, endpoint=False since
360==0 degrees). Aborts if x-ray-eye alignment hasn't been done yet
(tomo_fit_xray_eye unset), mirroring flomni's equivalent guard.
write_alignment_scan_numbers() writes the same 4-line scan-number/
angle/offset log flomni's version does, to
~/data/raw/logs/ptychotomoalign_scannum.txt for SPEC_ptycho_align.m,
also printed at the console (flomni's own console-print equivalent is
dead/commented-out code; lamni's actually prints).
Scope note: flomni's version conditionally skips its eye-out/optics-in
transition when already in measurement condition with feedback
running, to avoid an unneeded interferometer reset -- lamni has no
equivalent helpers for that check, so this calls leye_out()
unconditionally instead. Left as a possible follow-up, not in scope
here.
Item 6 of csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/
FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md. Documented in
docs/user/ptychography/lamni.md's "Fine alignment" section.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>