Skip to content

Add a beam-steering interface with Galvo and EOD devices - #81

Open
parzival1401 wants to merge 6 commits into
mainfrom
beam-steering
Open

parzival1401 wants to merge 6 commits into
mainfrom
beam-steering

Conversation

@parzival1401

Copy link
Copy Markdown

Experiment scripts drive the galvo with raw setdac calls against a specific
Triggerscope, and the EOD with raw DAQmx writes against a specific NI channel, so the
hardware choice is baked into every script and angles are converted by hand each time.

This adds a BeamSteerer interface that separates three things:

device what is steered — Galvo, EOD
backend what emits the two voltages — TriggerscopeBackend, DAQmxBackend
calibration AngleCalibration, the 2×2 mrad/V map between them

Moving a galvo from the Triggerscope to the NI card is a one-line change, with nothing
downstream touched.

Control

Devices take volts or milliradians. The calibration is a 2×2 mrad/V matrix plus a
zero-angle offset, so X/Y cross-coupling and a non-zero rest point are both captured.

The EOD additionally models the high-voltage amplifier, so it can be commanded in DAQ
volts (setvoltage) or in crystal volts (setcrystalvoltage). Which of the two a
calibration was measured against is an explicit field (calibration_basis), because
getting it wrong is a silent factor-of-gain error on every angle — the monitor-based
analysis the lab already uses produces the :crystal form.

Safety

Out-of-range commands throw before anything reaches hardware, so a bad command never
half-applies. gridscan range-checks the whole grid before its first move, so a scan
that would run off the end fails immediately rather than halfway through. Per CLAUDE.md
the three SteeringBackend stubs throw "not implemented" rather than returning nothing.

Also

Four step-through dev scripts — test_galvo.jl, test_EOD.jl, test_triggerscope.jl,
and a new section appended to test_nidaq.jl — each doing setup, write a voltage, read
it back.

Version 0.2.6 → 0.2.7-DEV: a new interface and devices are a feature, so Z.

Testing

No hardware required. test/beam_steering.jl runs against a recording backend and covers
calibration round-trips, limit enforcement, the amplifier conversions, the scan paths,
and that the backend stubs throw.

Local suite: 2242 passed, including 856 Interface Contract. The one failure and one
error are test/skills.jl's symlink containment check, which needs Administrator
privileges on Windows; that file is unchanged from main.

Known gap

BeamSteerer is not in test/contract.jl's interfaces tuple, so Galvo and EOD are
not covered by the core-method contract. Adding them requires a gui(::BeamSteerer),
which is deliberately not in this PR. Happy to add it here or as a follow-up.

Noticed, not touched

ThorCamCSC's getdata loops over sequence_frames, which is not in scope in that
function — it is a parameter of sequence(). It would throw UndefVarError if a
sequence were read back. Unrelated to this change, so it is left alone and reported here
rather than folded in.

🤖 Generated with Claude Code

parzival1401 and others added 2 commits October 7, 2026 16:03
Experiment scripts drive the galvo with raw setdac calls against a specific
Triggerscope, and the EOD with raw DAQmx writes against a specific NI channel,
so the hardware choice is baked into every script and angles are converted by
hand each time.

This adds a BeamSteerer interface that separates three things: the device being
steered (Galvo, EOD), the backend emitting the two voltages (TriggerscopeBackend,
DAQmxBackend), and the AngleCalibration mapping between them. Moving a galvo
from the Triggerscope to the NI card is a one-line change with nothing
downstream touched.

Devices take volts or milliradians. The calibration is a 2x2 mrad/V matrix plus
a zero-angle offset, so X/Y cross-coupling and a non-zero rest point are both
captured.

The EOD additionally models the high-voltage amplifier, so it can be commanded
in DAQ volts (setvoltage) or in crystal volts (setcrystalvoltage). Which of the
two a calibration was measured against is an explicit field, because getting it
wrong is a silent factor-of-gain error on every angle.

Out-of-range commands throw before anything reaches hardware, and gridscan
range-checks the whole grid before its first move, so a scan that would run off
the end fails immediately rather than halfway through. Per CLAUDE.md the three
SteeringBackend stubs throw "not implemented" rather than returning nothing.

Also adds four step-through dev scripts -- test_galvo.jl, test_EOD.jl,
test_triggerscope.jl and a new section in test_nidaq.jl -- each doing setup,
write a voltage, read it back.

Version 0.2.6 -> 0.2.7-DEV: a new interface and devices are a feature, so Z.

Tested without hardware against a recording backend (test/beam_steering.jl):
calibration round-trips, limit enforcement, the amplifier conversions, the scan
paths, and that the backend stubs throw. Local suite 2242 passed, including 856
Interface Contract. The one failure and one error are test/skills.jl's symlink
containment check, which needs Administrator privileges on Windows; that file is
unchanged from main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t exists

The "Backends" testset constructed a Triggerscope4 unconditionally. Its
constructor builds a LibSerialPort.SerialPort eagerly, and sp_get_port_by_name
throws SP_ERR_ARG where the named port does not exist, so the testset errored on
CI. It passed locally only because Windows accepts a name for a port that is not
there, which is what made this look safe when it was checked there.

The range table and every NI-backend assertion are unconditional: neither
touches a device. The Triggerscope assertions now run only when a Triggerscope4
can be constructed, and log why they were skipped otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kalidke

kalidke commented Oct 28, 2026

Copy link
Copy Markdown
Member

Review of d2a45f8. The evaluation ran on 2026-10-14, and the head has not changed since.

The abstraction is good:

  • the device, the voltage backend and the calibration are kept separate;
  • the 2×2 calibration math is right in both bases (angles = M (v − offset), v = M \ a + offset), and the EOD's override routes setangle and gridscan's preflight through the same conversion;
  • every user command is range-checked before any write.

It is non-breaking. Full suite on the head (kitt, Julia 1.13): 2249 tests, 2246 pass, 3 known-broken, 0 fail.

Changes before merge:

  1. resolve_limits should refuse a limit beyond the backend's range, by throwing or clamping instead of warning. Today a ±2.5 V Triggerscope with xlimits = (-10, 10) and setvoltage(g, 5, 0) sends DAC1,98302, which is outside the 16-bit protocol. Triggerscope: per-command serial timeout, a per-scope lock, out-of-range DAC voltages throw #92 makes the Triggerscope driver itself refuse that code, but the limit should still be refused here, and the NI backend has no such check.
  2. The EOD needs a crystal-voltage ceiling of its own. Either require explicit xlimits/ylimits, or add a max_crystal_volts keyword and enforce it. The defaults inherit the DAQ's ±10 V, which is ±200 V on the crystal at gain 20; crystal_limits only reports it.
  3. TriggerscopeBackend: drop the clearall in openbackend!. CLEAR_ALL erases the board's sequence tables (Triggerscope interface spec), so initializing a steerer on a shared Triggerscope (owns_device = false) wipes sequences that other devices programmed. Nothing in the backend uses sequences.
  4. DAQmxBackend cleanup:
    • release the X task if creating the Y task fails;
    • refuse a second initialize while the backend is open;
    • give stop! and clear! separate try blocks in closebackend!;
    • make a failed park throw from shutdown after releasing the tasks, instead of @warn and recording (0, 0).
  5. dev/test_galvo.jl and dev/test_triggerscope.jl pass protocol = MM_PROTOCOL, which is not defined anywhere in src/, so both scripts fail before constructing the scope.
  6. Trim the exports to the device API. About 20 helpers (checklimits, resolve_limits, tocrystal, todaq, amplifier_factor, angle_setpoint_voltage, rangelimits, …) widen the namespace that every downstream using MicroscopeControl shares. Leave them qualified.
  7. Register the interface in this PR. Add BeamSteerer to test/contract.jl's interfaces and to src/skills.jl's list, with a minimal gui.jl panel. An interface outside both lists is invisible to the contract test and to the generated API map that the instrument repos install.

Smaller, fix if convenient:

  • if X is written and Y throws, the cached voltage pair is stale; mark it unknown or roll it back;
  • the limit is checked before quantization, so the code sent can exceed the limit by up to one LSB.

Main has moved on since this branch was cut (it is now 0.2.7-DEV, with the camera, stage, light-source and Meadowlark work). Merging main into this branch conflicts only in test/runtests.jl's include list, in one hunk.

Hardware checks on the MINFLUX microscope before relying on it:

  • Galvo on the Triggerscope: initialize, a voltage grid and an angle grid, then shutdown; scope both axes at start and stop; after change 3, confirm that another device's programmed sequence survives.
  • EOD on NI AO (HVA200): confirm gain 20 and the inverting polarity on the monitor; check setcrystalvoltage against the measured HV; check that the limits enforce the crystal's rated maximum.
  • NI: two AO tasks coexist; re-initialize after a pulled USB cable; outputs read 0 V after shutdown.
  • Calibration: one measured AngleCalibration in each basis, round-tripped through setangle and the camera centroid.

parzival1401 and others added 4 commits October 30, 2026 12:13
- An explicit limit outside the backend's range now throws instead of
  warning (a ±2.5 V Triggerscope with ±10 V limits sent DAC1,98302), and
  limits are snapped inward onto the backend's grid (snap_limits), so rounding
  to a DAC code can no longer exceed a limit by one LSB.
- EOD requires max_crystal_volts and holds its DAQ limits to
  max_crystal_volts / gain: omitted limits are narrowed to it, explicit limits
  beyond it throw.
- TriggerscopeBackend no longer sends CLEAR_ALL on open, which erased
  sequences other devices on a shared board had programmed. A failed open
  closes a port it opened.
- DAQmxBackend releases the X task when the Y task fails, refuses a second
  open, stops and clears each task in its own try, and a failed park throws
  after the tasks are released. The Triggerscope backend does the same.
- A device refuses a second initialize; shutdown of a closed device does
  nothing; a failed write or park leaves the recorded voltages NaN instead
  of a stale or (0, 0) pair. gridscan returns to its start when the scan
  throws and rethrows the scan's error.
- Exports trimmed to the device API; the helpers are public, reached as
  MicroscopeControl.<name>.
- BeamSteerer is registered in test/contract.jl (interfaces, shadowed
  generics, a BeamSteerer contract testset) and in the API map, with a
  gui panel. The shared device methods move from interface_functions.jl,
  which the API map reads as throwing stubs, into shared_methods.jl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ystal_volts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll and panel fields

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants