The JungfrauJoch broker reports packet loss during data collection as a plain
error, which fails the scan. Since 2026-09-03 that error has been suppressed on
the beamline by an uncommitted edit, through a hardcoded flag on the Eiger base
class: it applied to every Eiger at once, it could be reached neither from the
client nor from deviceConfig, and it left nothing in the log.
The tolerance is now a real parameter, raise_on_missing_packets:
- named in Eiger, Eiger9M and Eiger1_5M, so that a deviceConfig key actually
reaches the device. bec_server intersects config keys with the named
parameters of the class, so a flag reachable only through **kwargs is
silently dropped -- the same trap as readout_time (f450f29) and prefix
(10be2b5). A test pins the signatures.
- exposed through USER_ACCESS as get_/set_raise_on_missing_packets, so a
beamtime can change its mind without a redeployment. Like every runtime
value it is shared between clients and does not survive a server restart;
deviceConfig is what makes a choice stick.
- counted, and logged at warning level whenever an error is let through, so
that "which scans were affected?" has an answer. get_missing_packet_events()
returns the count.
What is tolerated is narrower than it looks: the frame-count check below still
raises when statistics.images_collected falls short of the trigger count. Only
"the broker flagged packet loss but delivered the expected number of images"
gets through, and a test pins that a short acquisition still fails.
The default stays False, i.e. tolerate, so the running beamtime is unaffected.
It should become True once the 9M's packet loss is understood, with
raise_on_missing_packets: false in that detector's deviceConfig if it still
needs it. That is one constant to change, RAISE_ON_MISSING_PACKETS.
The wording of the broker message is the only handle available, as there is no
error code for it. If JungfrauJoch rephrases it the match stops working and the
error raises again, which is the safe direction to fail in.
test_eiger_on_complete_error_message was skipped as failing "because the error
should be skipped for now due to HW issues". With the tolerance scoped to the
missing-packet message it passes again, and is no longer skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Eiger is triggered rather than gated for stability, and the pulse train is
shaped so its internal timer coincides with the gate -- it is meant to behave as
if gated, so that every detector in a scan integrates the same window. Nothing
in the code said so, which is why sending the full exp_time as image_time_us
looked reasonable and stayed wrong until a scope showed the 173 us overhang.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLnmUurqcNd1FiDY5M2uZr
Readout is a property of the detector, not a beamline constant: a 9M has more
modules than a 1.5M, and the Falcon needs 3 ms against the delay generator's
200 us. The per-model constants therefore stay, with comments saying the
duplication is deliberate so nobody consolidates them again. They all hold 2e-4
today only because no measured per-model value exists yet.
Writing a test for the deviceConfig override exposed that it never worked. Both
subclasses passed readout_time to super() while also forwarding **kwargs, so
supplying it raised "got multiple values for keyword argument" -- and through
BEC it never even got that far, because readout_time was not a named parameter
of the subclass signature and the device server drops config keys it cannot see
(the same rule behind the recent prefix incident). Both subclasses now name it
with the model constant as default, and a test asserts the signatures keep it.
Also documents frame_time_us in DetectorSettings as required-but-ignored for the
Eiger, and warns that its 500 is microseconds while every other time in the
module is seconds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLnmUurqcNd1FiDY5M2uZr
scan_info's exp_time is the trigger PERIOD. DDG2 gates for exp_time minus a
readout time, but the Eiger was sent the full exp_time as image_time_us, so
the detector acquired 180 us past the falling edge of its own gate and
finished only 20 us before the next trigger -- that 20 us being JungfrauJoch's
internal board-readout allowance, and the entire margin available. Measured on
a scope as a 172.8 us overhang.
Four independent notions of "readout time" existed:
500 us EIGER*_READOUT_TIME_US validation floor only, no effect on anything
200 us DDG2 DEFAULT_READOUT_TIMES["ab"] sets the gate width
20 us JungfrauJoch deployment config applied internally by JFJoch
- scan parameter readout_time honoured by the Falcon, ignored by DDG2
They are now one. DDG2 derives the gap from the scan's readout_time, floored by
its configured value (scans default it to 0), and exposes effective_readout_times().
The Eiger subtracts the same number from image_time_us. The Falcon already used
the scan value, so it needs no change.
The Eiger's 500 us constant is split in two, because it was doing two jobs: a
MIN_EXP_TIME validation floor keeps today's behaviour exactly, while the new
EIGER_READOUT_TIME defaults to 2e-4 to match the delay generator. The _US suffix
on constants holding seconds is dropped in all three modules.
Both devices log the effective exposure window at on_stage, so a period/exposure
mismatch is visible in the logs rather than only on an oscilloscope.
NOTE: this shortens the delivered exposure by the readout time -- 200 us, i.e.
0.5% at 40 ms -- for scans that do not set readout_time. Users should be told
before this is deployed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLnmUurqcNd1FiDY5M2uZr
The gap between consecutive detector triggers is exp_time minus a readout
time that was a module constant fixed at 0.2 ms. Some detectors need more:
FalconcSAXS declares MIN_READOUT = 3 ms, fifteen times longer. Nothing
reconciled the two -- DDG2 does not know which detectors are in the scan,
and the falcon only validates its exposure time, never the gap -- so a
detector that cannot keep up silently dropped frames.
There was also no way to change it. on_stage recomputes the pulse width
from the constant on every scan, so a value set by hand from the client did
not survive to the next acquisition.
The readout times are now per-instance, settable two ways: a readout_times
key in deviceConfig for a per-deployment default, and set_readout_times()
via USER_ACCESS for a change between scans. Raising the value widens the gap
and shortens the exposure by the same amount; burst_period stays at exp_time,
so the frame rate is unaffected.
Channel pair 'ab' is the one multiplexed to the detectors and normally the
only one worth changing. A value that would exceed the exposure time is
rejected, and a non-default value is logged at on_stage so a scan taken with
a widened gap is recoverable from the logs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLnmUurqcNd1FiDY5M2uZr
The areaDetector file plugin refuses to be staged until one NDArray has
passed through it, so every restart of the SITORO IOC leaves the Falcon
raising ophyd's UnprimedPlugin at the start of a scan. The generic
HDF5Plugin.warmup() cannot be used, as it drives parent.cam, which the
Falcon does not have.
prime() pushes a single spectrum through the plugin using user-advanced
pixels, so no external gate signal is required and a disconnected trigger
cable cannot mask the priming. No file is written.
The trigger configuration is captured beforehand and restored in a finally
block. on_stage never resets pixel_advance_mode, ignore_gate or
pixels_per_buffer -- only on_connected does -- so a prime that bailed out
halfway would otherwise leave the detector deaf to the gate and silently
starve every subsequent scan.
prime() and is_primed() are exposed via USER_ACCESS, so they are callable
from the BEC client as dev.falcon.prime().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLnmUurqcNd1FiDY5M2uZr
The four-digit code is already the currency of this module: it is what
_all_combinations builds, what fil_trans prints as the selected
combination, and what _fil_trans_report prints for what is currently in
the beam. Requiring commas on the way back in meant reading a code off
the screen and retyping it as four separate arguments.
fil_comb now takes 1411, "1411", (1, 4, 1, 1) or [1, 4, 1, 1], so a
reported combination can be pasted straight back.
A code of the wrong length is rejected with the length it needs rather
than falling through to "expects 4 positions, got 1", which was the
unhelpful error a code would have produced before. bool is excluded from
the integer branch so fil_comb(True) does not quietly become "True".
Digits outside 1..6 are left to the existing per-unit range check, which
names the offending unit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kLGTqTyXzTT4swt3CfTkV
Two ways the filter table disagreed with filter.mac rev 1.19.
The Si thicknesses were nominal where spec used values measured at
18.58 keV: 400/200/3200/100/1600/800 against 345.6/234.6/3303/137.5/
1661.5/833.8. Si100 vs Si137.5 is 37% out, and at 17 keV the pair
Si200+Si1600 gives T=0.060 where the measured Si234.6+Si1661.5 gives
0.052 -- a 16% error, larger than any distinction the search was trying
to make between neighbouring combinations.
The Zr and Cu calibration foils were selectable. filter.mac flagged them
disabled so that selection by transmission could not pick them, and at
17 keV fil_trans(0.05) duly returned 5246 -- a combination built on the
Zr foil, 1 keV below its K edge, where its transmission moves with any
small change in energy.
They are excluded through a new _EXCLUDE_FROM_SEARCH rather than by
clearing their 'enabled' flag, because those two things are not the same
and the flag is load-bearing elsewhere: _fil_trans_report substitutes
T=1.0 for a disabled position, so clearing the flag would have made the
report silently ignore a foil parked in the beam -- precisely the state
an edge scan puts it in. The foils stay enabled, stay addressable via
fil_comb as they were in spec, and are skipped only by the automatic
search.
At 17 keV fil_trans(0.05) now returns 2146 (Si345.6 + Ti200 + Ti20,
T=0.0494) and fil_trans(0.01) returns 2234 (T=0.0099), both matching an
independent calculation over the same CXRO tables.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kLGTqTyXzTT4swt3CfTkV
The DMM-through / CCM-active check now lives in _attenuation_allowed and
fil_trans calls it, so there is one copy rather than two drifting ones.
A guard that is duplicated is a guard that gets fixed in one place only.
Behaviour is unchanged: the same three PVs are read with the same
fallbacks, the same warning is printed, the same default-NO prompt is
raised through OMNYTools.yesno, the same "Safe fallback" of refusing
when no prompt is available applies, and fil_trans(1) still bypasses the
check entirely.
Split from the previous commit so that this half can be reverted on its
own if it misbehaves at the beamline, without taking fil_comb with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kLGTqTyXzTT4swt3CfTkV
fil_trans selects by transmission, which is the wrong tool when a
particular foil is wanted: it optimises against the thickness table and
which combination wins is not something to rely on. The alternative was
four hand-copied stage coordinates, where the out positions differ per
unit (25.0 / 25.5 / 25.8 / 25.0) and getting one wrong leaves the stage
off any tabulated slot -- _fil_trans_report matches within 0.1, so the
combination then reports as unidentified.
fil_comb takes one slot number per unit, 1..6 with 1 = out, as the SPEC
fil_comb did, and looks the coordinates up. It reuses
_position_transmission, _print_combination and _execute_combination
unchanged, so it reports and moves exactly as fil_trans does, including
the dry run and the default-yes prompt.
Every slot is validated before the safety prompt is reached, so a typo
raises rather than putting a question about an unexecutable combination
in front of someone. Disabled slots -- Fe5 and the redundant opens --
raise too: _all_combinations skips them, so fil_comb must refuse them
rather than move somewhere fil_trans would never choose.
_attenuation_allowed is added here but not yet wired into fil_trans;
that is the next commit, so this one cannot change existing behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kLGTqTyXzTT4swt3CfTkV
Adds a Print button to OMNY_SampleStorage that renders an A4 PDF
replica of the on-screen stage/gripper/magazine grid (via
bec_lib.pdf_writer.PDFWriter) and sends it to the local CUPS queue
WSLA_X12SA via `lp`. Checks printer availability with a quick local
`lpstat` query first, so a host without that queue gets a plain
"printer not available" message instead of a raw CUPS error; the
actual `lp` call still offers Retry/Ignore on failure, mirroring the
existing P-touch label-print fail-soft pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgQoxFiJRZi1FnmnF8epKh
make_flomni_for_golden_resume() builds a bare Flomni via object.__new__,
bypassing __init__, so it never got measured_log or a working
sample_get_measured_log_key -- both added by the measured-sample-tracking
commit for the P-touch label printer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records whether a tomo scan was started/completed for the sample
currently on the stage (a new _MeasuredSampleLog, redis-backed via
BEC global vars, keyed by the packed name|owner identity so it
survives ftransfer_get_sample/put_sample moves between slots). When a
tray slot's occupant is cleared or replaced via
ftransfer_modify_storage() (CLI) or the sample-storage widget's
Clear/Change-name actions (GUI), and that sample was measured, offer
to print an account/date/samplename label via PTouchLabelPrinter.
Also surfaces measured status in ftransfer_show_all() and the
widget's slot cells, including the stage line.
GUI failures retry via QMessageBox Retry/Ignore (a modal dialog runs
its own Qt event loop, so this can't hang the process the way the
CLI's input()-based ensure_ready() would) instead of losing the label
on the first failed attempt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fills the named text objects of a pre-transferred P-touch Template and
triggers a print over raw TCP:9100, validated end-to-end against the
real printer at BRN94DDF8AAB8EC.psi.ch. Shared OMNY_shared utility, not
an ophyd device, per docs/developer/ptouch_label_printer_plan.md.
Not yet wired into flomni.py -- that's the next step.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fosa_in() moved fosax/fosay/fosaz straight to their configured "in"
positions with no collision check. ffzp_info() already computed OSA-to-pin
clearance but never gated the move on it. Add a shared
_osa_remaining_space() helper (also used to deduplicate ffzp_info()'s own
calculation) and raise FlomniOpticsError in fosa_in() when the computed
clearance at the target position is <= 0.
Root cause of the specific collision seen: stage init's _align_setup()
hardcoded foptz to 23 instead of its calibrated "in" value of 17
(ptycho_flomni.yaml), 6 mm off from what fosaz's own "in" calibration
assumes. Hardcode foptz to 17 instead, and lock foptz.limits to +-0.1 mm
around it in both set_limits() and _align_setup() so any future move away
from 17 requires deliberately widening the limits first. Also corrects
the stale "in: 23" in ptycho_flomni.yaml's foptz userParameter to 17.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ftransfer_gripper_move() silently returned on "No" to the stage-out
confirmation, but its return was indistinguishable from success to its
callers (ftransfer_get_sample/ftransfer_put_sample), which then proceeded
to command the physical get/mount sequence on the controller -- moving
the gripper without the stage ever having moved out or the gripper being
positioned at the transfer coordinates. Now raises FlomniError instead,
which propagates out of both callers and stops the transfer before any
controller command is sent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reflects the single-URL omny.psi.ch config (myfritz/omny-test list retired)
and replaces the omny-test.psi.ch-specific .svg note with the known
permission and intermittent-WAF issues on omny.psi.ch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cuts over the webpage status-page mirror, TomoIDManager's sample-counter
registration, and the samples-folder PDF upload to omny.psi.ch exclusively,
replacing v1p0zyg2w9n2k9c1.myfritz.net and omny-test.psi.ch everywhere.
Note: omny.psi.ch currently has known server-side issues (a permission
error on /upload.php, and a WAF blocking /samples/* outright) tracked
separately with PSI admins -- this switch is expected to be broken until
those are fixed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add omny.psi.ch as a third upload target alongside production and the
omny-test.psi.ch trial mirror.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add DataViewer, ported from debye_bec's bec_widgets/widgets/data_viewer,
adapted to this repo's parent()-first __init__ convention required by
bw-generate-cli.
Drop the "Widget" suffix from SampleStorage, SlitControl, and TomoParams
for naming consistency with DataViewer, then further rename for OMNY
namespacing and Designer-list ordering:
- SampleStorage -> OMNY_SampleStorage
- TomoParams -> OMNY_TomoParams
- XRayEye -> OMNY_XRayEye (XRayEye2DControl untouched)
- ConsoleButtonsWidget -> z_ConsoleButtonsWidget (sorts last)
Update the live gui.<area>.new("...") string-based widget lookups in
flomni/LamNI gui_tools.py that construct XRayEye/ConsoleButtonsWidget by
name, so they keep resolving after the rename. Delete now-stale generated
plugin/register/pyproject trios and regenerate everything via
`bw-generate-cli --target csaxs_bec` (client.py, designer_plugins.py, and
fresh per-widget trios under the new snake_case names).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZHQaRmthXmyfn2pxP3dF9
A job's params dict only has whatever _TOMO_SCAN_PARAM_NAMES held when it
was snapshotted by tomo_queue_add(); a job persisted before a key was
added (e.g. fovx/fovy, tomo_circfov) raised KeyError on the hard p['key']
lookup, making the whole queue un-inspectable instead of just missing a
field.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
self.dev.fsamroy/.osamroy on the scan server is a generic bec_lib
Positioner proxy rebuilt from serialized device info, not the real
ophyd instance. tolerance was only a plain __init__ attribute, so it
was dropped during serialization and flomni_rotation()/rotation()
crashed with AttributeError: 'Positioner' object has no attribute
'tolerance'. Register tolerance in USER_ACCESS so it serializes and
is reachable via RPC like controller already is.
_refresh_busy_banner() toggled the banner's visibility synchronously
on every poll tick and scan-queue push message, so brief blips in the
underlying queue status made it flicker on/off.
Show busy immediately, but hold the banner up for 5s of continuous
idle before clearing it, mirroring the debounce pattern already used
for XRayEye's queue-guarded toggles (_queue_idle_timer).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
find_rotation_center()'s y/n prompts now go through OMNYTools.yesno()
(omny_general_tools.py) instead of a raw input() in x_ray_eye_align.py,
but these tests were still patching input() on the old module -- so
the real input() got called under pytest's captured stdin and raised
"reading from stdin while output is captured".
Patch input() on omny_general_tools instead, where yesno() actually
calls it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Several confirmation prompts hand-rolled their own input() parsing
instead of using the established yesno() helper, including one
weaker variant (no default, no retry, case-sensitive) and a "Close
the shutter now?" prompt duplicated verbatim four times.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
XQ#NEWPAR dispatches asynchronously on controller thread 0 and returns as
soon as it starts, not once it finishes; the dispatch time varies with
controller load. drive_axis_to_limit/find_reference followed it with a
fixed sleep before starting XQ#FES/XQ#FRM (also thread 0), which has
already needed bumping twice (0.1->0.3, 0.3->0.2) and still raced on
lgalil: sending XQ#FES while thread 0 was still busy got a '?' reply
(Galil error 19, thread already running).
Replace the fixed sleep with _wait_for_thread_idle(0), reusing the
existing is_thread_active() primitive (already used the same way in
hard_abort_and_restore_positioning_mode) to poll until thread 0 is
actually free before dispatching the next routine.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
str.strip(prefix) strips individual characters, not a literal prefix, so
replies made entirely of characters in the prefix (e.g. ":CLS0,0" for
losax) collapsed to '' and crashed float('') in describe(). Other replies
could silently truncate to a wrong value instead. Since the prefix is
already validated by _message_starts_with(), removeprefix() is a safe
drop-in fix for all affected parsers in SmaractController.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The status webpage's tomo-queue accordion auto-expanded a job's details
panel as soon as its status became "running", overriding whatever
open/closed state the user had selected. Panel state now depends only
on the existing DOM snapshot of user selections.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A Ctrl+C during lsamrot/fsamroy/osamroy rotation leaves the cached
user_setpoint at the target even though the axis never finished moving
(stop() halts the motor mid-move). The next scan then wrongly concluded
"no rotation required" from setpoint equality alone, leaving LamNI's
air bearings unclamped (confirmed via galil show all) and the beamline
unable to scan until the setpoint was nudged manually.
lamni_rotation() now also requires
lgalil_is_air_off_and_orchestra_enabled() before skipping, since that
flag only goes true once the Galil #CENROT sequence (air-clamp plus
centering) has fully completed. flomni_rotation()/omny_rotation() use
a readback-vs-tolerance check instead, since those axes have no
air-bearing clamp step.
Along the way, fixed lgalil_is_air_off_and_orchestra_enabled() itself:
it did bool(socket_put_and_receive(...)) directly on the raw Galil
reply string, which is always truthy, so it could never report
anything but True. Confirmed via HW testing in LamNI.
previous_subtomo_number resets to -1 on every tomo_scan() call in the
golden-ratio branches (tomo_type 2/3), including a resume. That makes
the first loop iteration look like a genuine sub-tomogram transition,
firing the 0-deg reference shot before the actual resume angle even
though the rotation isn't passing through 0 deg at that moment.
Mirrors tomo_type 1's existing start_angle-is-None resume guard:
suppress the shot only for the first, possibly-spurious transition
right after a resume, then let later genuine transitions in the same
call fire normally. Same fix applied to both lamni.py and flomni.py,
which share byte-for-byte identical golden-ratio logic here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Flomni's tomo_scan() warns and prompts if the X-ray eye isn't out and
the FZP/OSA optics aren't in before scanning; LamNI had no equivalent,
letting a tomogram start with the eye still in the beam or the optics
retracted. Ports the same check, wired into the existing
force/interactive gating so it never blocks on input() during
unattended/queued runs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
find_rotation_center_smear_experimental() rotated 45->0 for its post-shift
verification sweep, needlessly re-treading ground the preceding full smear
sweep already covered and adding a long extra blocking move that could
starve the GUI heartbeat past _gui_call_with_retry's budget, surfacing as
RuntimeError: GUI is not alive. Sweep straight back to 0 instead, and widen
the retry budget (8x1.5s -> 25x2.0s) so update_frame() can ride out long
blocking moves on slower hardware.
Also tidy a stray comment-block artifact in ptycho_lamni.yaml and update
lamni.md docs for the current xrayeye_rotation_center_calibration_* API.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
xrayeye_rotation_center_calibration_isolated/smear_experimental() reproducibly
crashed with "RuntimeError: GUI is not alive" when keep_shutter_open=True, right
after a long blocking device move (interferometer feedback reset, live rotation
sweep). Root cause: bec_widgets' GUI liveness check is a Redis heartbeat with a
10s TTL refreshed from the same Qt event loop that renders live-view frames;
with live view left on continuously, a long blocking move can starve that
heartbeat past its TTL even though the GUI process is still alive. Add
_gui_call_with_retry() and use it at the on_live_view_enabled(True) call sites
that follow these blocking waits, so the transient false negative is retried
instead of crashing the calibration.
Also reorder the sample-name prompt in find_rotation_center() and
find_rotation_center_smear_experimental() to run before the alignment GUI is
shown -- showing the GUI first steals OS focus, forcing the operator to click
back to the terminal to answer the prompt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The scan already treats fov_circular==0 as "disabled" and defaults to
it, but the GUI spinbox floor of 0.1 made 0 unreachable. Lower the
floor to 0.0 and label the field so the meaning is explicit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The stage_x_rot/fovy and stage_y_rot/fovx comparisons look swapped,
but are correct: alpha bakes in a fixed ~90 deg mechanical offset
between the piezo axes and the angle=0 beam frame that fovx/fovy are
defined in, so stage_x_rot actually tracks the beam-frame y-extent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>