Three things were making the panel feel slow, all of them waiting rather
than working.
The trailing quiet window is waited out once per query, so it set the pace
of the whole sweep: at 50 ms that was 400 ms of a 590 ms poll spent
listening to silence. A reply streams at the baud rate — ~1 ms between
bytes, no measurable gap between its lines — and the deadline restarts on
every line read, so 20 ms outlasts the gap it exists for twenty times over.
A tail that still arrives late is caught by _discard_input(), which is what
actually protects the next query. Replayed against the rig transcript, a
sweep goes from 590 ms to 356 ms.
The poll interval was 1 s on top of that, so a value could be 1.6 s stale.
At 0.3 s the panel comes round about every 0.65 s.
And a poll held the port for its whole sweep, so a button pressed during
one waited for all eight queries. _work_pending() on the worker base lets a
poll drop what is left as soon as the operator queues something: a click
now waits ~135 ms for the register read in progress instead of the full
sweep, and the rest is picked up next time round.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rig transcript (tools/helios_lds_probe.py) settles where the panel's
32 mA came from, and it was never the laser: LDS reads 100 mA, answers for
itself, and takes a write of 900 mA on the first attempt. 32 is LCE — bit
5, "Door switch open" — arriving in the diode-current field.
Every reply is CRLF-terminated and padded with a blank line or two:
b'LDS = 100 mA\r\n\r\n'
b'LCE = 32\r\nBit 15..0: 0000 0000 0010 0000\r\n\r\n\r\n'
_read_line() read up to CR, so the final LF of every reply stayed in the
buffer, and the next read waited out the whole port timeout for a CR that
only the next command would bring. A second of dead air per query: replayed
against the transcript's byte timing, one status poll took 8.6 s against
the 1 s interval that schedules it. That is also what let the values drift
apart — a query whose deadline goes to a blocked read gives up while its
own reply is still on the wire, the next query flushes the port mid-line,
and the fragment it reads is " 32", the value half of LCE's reply.
Lines are now framed on CR, LF or CRLF out of a receive buffer that
_discard_input() clears along with the port, so nothing survives a flush
half-read. The same replay now polls in 0.59 s.
Tests carry the transcript's real framing (padded values, trailing blank
lines) instead of the tidied "LER = 0" it was guessed to be, plus the two
regressions: a late fragment must not become the next query's value, and a
reply must be readable without waiting out the port.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_value_in()'s last resort was to accept any line it could not attribute as
the answer to whatever had just been asked. That fallback exists for the
serial numbers, which come back bare — but it applied to every query, so a
stray "32" could be read as a diode current of 32 mA. 32 is also what a
status register reads with bit 5 set (LER "Over voltage laser diode", LCE
"Door switch open", CCE "Q-switch under/over temperature"), which is
exactly the value the panel is stuck on.
Only CSR and HSR now accept an unlabelled reply; every other read has to
see its own mnemonic in the line. A read that cannot be attributed returns
None, and the panel shows "laser: ? mA" instead of leaving the last good
value on screen looking live — a stale reading and a setpoint that refuses
to move are indistinguishable otherwise.
tools/helios_lds_probe.py is the diagnostic for the underlying question:
it talks to the controller with no reply parsing at all and prints every
byte, so the transcript says whether LDS answers for itself, whether the
write is taken, and which flags the registers hold before and after. The
status-register tables move to hardware/helios_registers.py so the probe
can decode them without importing the Qt app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The current spin box could not hold a typed value: the 1 Hz status poll
read LDS and wrote it straight into the spin box, so the operator's number
was replaced by the laser's within a second — before Set could be pressed.
The spin box is now seeded once on connect and belongs to the operator
after that; the poll's reading goes to a read-back label beside the Set
button, so the value about to be sent and the value the laser holds are
separate readouts.
The write itself was also unverified. Section 6 of the operator's manual:
"Commands or set values can be discarded by the controller unintentionally.
It is recommended to query the set value after the command is entered to
confirm the actual value." set_current_ma() wrote LDS and returned True
regardless, so a discarded write looked exactly like a good one.
_write_verified() now writes, reads back, and retries up to three times;
set_current_ma() and set_frequency_hz() use it, and HeliosWorker reports a
refusal on the status line instead of echoing the requested value.
Also from the manual, recorded but not acted on: LDS accepts 0-7000 mA
(the driver's 2000 mA ceiling is this rig's, not the protocol's), LDF has
to be re-sent after LDG changes, and the power-monitor mnemonic is HMP —
which this laser does not implement, per the operator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Collapsed Args:/Returns:/Raises: blocks that only restated the
signature (364 lines): tektronix_base 48% -> ~20% doc density,
helios_laser and uc480_camera likewise. Only docstrings whose entire
body was those sections were touched.
- Preserved verbatim the comments that carry hardware knowledge the code
can't express: uc480's USB split-transaction contention note (with its
measured fps), the IS_ALLOW_STARTER_FW_UPLOAD segfault explanation, the
QImage-copy rationale, and tektronix's NUMFRAMESACQuired warning.
- README: project structure, quick start, and every usage example now
describe code that exists (they referenced hardware/bbd202.py,
CoherentHOPSLaser, get_curve_binary, and 'python -m scanengine.app',
none of which do). Added a headless-scan example and a read-a-scan-file
example, since reuse without the GUI is the point of the refactor.
- SETUP: structure section defers to README instead of keeping a second
stale copy; documents the vendored uEye SDK and the Genesis quarantine.
- ruff is now clean repo-wide: fixed the remaining raise-from, unused
loop variables, placeholder f-strings, and a non-strict zip; the
widget-layout semicolon idiom is an explicit config ignore rather than
22 standing warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gui/qt_workers.py — one QueueWorker base replaces the per-device command
queue + dispatch + signal boilerplate. The loop blocks on the queue
instead of waking 10-20x/second forever (test_idle_worker_does_not_spin
asserts an idle worker burns ~no CPU). PollingQueueWorker adds
self-rescheduling polling: the next poll is queued only after the
previous finishes, so a device slower than the interval can't accumulate
a backlog (test_polling_never_overlaps_or_backs_up).
Helios responsiveness — the concrete bug that motivated the above: a free
running 1 s QTimer queued a status poll that took ~2 s, so the queue grew
for as long as the panel stayed connected.
- helios_laser._query reads until the CR terminator instead of sleeping a
fixed 0.05 + 0.2 s per query
- one _query_int() helper replaces five copies of parse-with-logging
- polling is now driven by the worker; HeliosWindow's QTimer is gone
- dropped __del__, which disabled the laser and wrote to the serial port
from the garbage collector at an unpredictable time
helios_test_app.py — the worker was moveToThread'd but every call site
invoked its methods directly, so all serial I/O (including the sleeps)
ran on the GUI thread; Query All froze the UI for ~2 s. Calls now go
through a queued signal to a pyqtSlot. Also: connect/disconnect cycles
leaked a QThread + worker + 9 connections each time; 16 copies of the
not-connected guard collapse to _require_connection(); the Query Power
button called a method that has never existed (AttributeError popup) and
is now disabled and documented in KNOWN_ISSUES.
DCBiasImageWidget preallocates its image and uses set_data/set_clim, so
the live preview stops rebuilding the array and the whole artist tree per
row (O(rows^2) over a scan).
bbd20x: connect() now raises when no bays respond instead of reporting
success on the wrong port; disconnect() joins with a timeout so a wedged
reader can't hang shutdown; one _channel_for() helper replaces four
copy-pasted axis mappings; hardcoded travel limits become TRAVEL_MM; the
joke error strings are gone.
gui/widgets.py adds the shared ConnectionBar / PortSelector / bounded
LogConsole / StatusGrid for the test benches to adopt. 65 tests passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- core/sras_format.py: THE v6 implementation — create_scan_file (writer,
byte-identical to the old one, enforced against the Phase-0 goldens),
SrasFile parser with frontier/truncation walk, and zero-copy mmap
load_angle/load_row views for multi-GB files
- core/scan_geometry.py: ScanPlan/AngleGeometry dataclasses, build_plan
(rotated-bbox trig from MainWindow._build_scan_params), travel-limit
validate_plan (limits now a StageLimits dataclass, not literals buried
in the worker), format_eta + EtaEstimator (bounded deque)
- core/config.py: ScanDefaults dataclass replaces the module-import-time
dict globals. FIXES: editing any main-window port used to rewrite
aui_defaults.json without helios_port, silently reverting the Helios
port every time (test_helios_port_survives_partial_update covers it).
Also drops the inert laser_freq_hz plumbing — scans always used the
LASER_FREQ_HZ constant.
- hardware/serial_util.py: shared 8N1 open + scored port enumeration
(promoted from t3r_control_panel); helios_laser and the panel use it
- sc3_aui_app.py and sras_scan_manager.py migrated onto core (three
format implementations down to one); ScanWorker now takes a ScanPlan
- tests: byte-identical writer vs golden, frontier over every truncation
variant, mmap==eager, geometry vs golden fixtures + invariants, config
round-trip. 28 passing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>