Skip to content

feat(laser-engine): Cephla laser engine v2 (400 µm standard and 50 µm Dragonfly variants) - #693

Open
hongquanli wants to merge 46 commits into
masterfrom
feat/laser-engine-v2
Open

hongquanli wants to merge 46 commits into
masterfrom
feat/laser-engine-v2

Conversation

@hongquanli

@hongquanli hongquanli commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Adds Squid support for the Cephla laser engine v2: a multi-line engine (carrier board rev 1, Teensy 4.1 firmware, text protocol over USB) in two variants, the 400 µm fiber standard engine and the 50 µm Dragonfly engine, which adds a 560 nm fiber laser behind an AOM. The legacy v1 engine (control/squid_laser_engine.py) keeps working unchanged.

Configuration

The machine .ini selects the engine with one key and gives its USB serial number with another, for every version:

[GENERAL]
laser_engine = v2                   # v1 | v2 | blank = none
laser_engine_sn = <Teensy USB serial>

use_squid_laser_engine / squid_laser_engine_sn are still read (as v1, with one warning to switch); use_squid_laser_engine = True together with laser_engine = v2 is an error. Full setup: software/docs/laser-engine.md.

How Squid drives the engine

  • One LightSource (LaserEngineV2LightSource): intensity over USB (per-line set-points, linear in the line's drive; calibration to % of power comes in a follow-up PR), exposure on the Squid controller TTL lines D1–D5 cabled to the engine's TTL1–5. Software shutter mode is refused: a software gate would leave a line emitting between exposures.
  • Readiness: lines are armed and enabled on use, and an acquisition waits until its lines are ready (TEC in window, set-point reached). A background poll is the engine's host heartbeat and reports faults, interlocks and a lost link.
  • MultiPointWorker tells the engine which channels an acquisition uses (note_use; v1 has none), so the 560 idle-off cannot trip mid-run.
  • Small changes outside the new files: lighting.py maps 560 nm to D3 by default and adds LightSourceType.CephlaLaserEngineV2; gui_hcs.py and microscope.py select on LASER_ENGINE; the v1 engine gains an on_startup() hook that does what prepare_for_use did before (wake every channel).

Dragonfly 560 nm line

  • The operator sets the 560 laser power (mW) in the Laser Engine tab; the power and the idle-off time are kept in cache/laser_engine_v2.yaml, not in the machine .ini. A simulated session never saves them.
  • Squid's 560 intensity drives the AOM amplitude (engine line 3 analog, 0–5 V): linear in AOM volts. 0 % is always 0 V.
  • The engine's fail-safe shutter is used for safety only: it opens when line 3 is ready, stays open through the line-3 ramp of an intensity change, and is closed in every other state and before any source restart. It never moves per exposure; the AOM's digital input does exposure timing.
  • The 560 source driver is an optional module, control.laser_engine_v2_560_driver, supplied separately and not part of this repository. Without it the 560 line reports "not configured" and the other lines work normally.

UI and tools

  • Laser Engine tab: engine state, arm / disarm / fault reset / wake / sleep, recent notices, a per-line table (wavelength, state, set-point, max, reason) and the 560 box. It is compact and scrolls instead of squashing when the side panel is short.
  • Simulator for --simulation: a fake engine and a fake 560 source.
  • Bench app: python3 tools/laser_engine_v2_bench.py (connection, service panel, the same tab), with a simulate option.

Testing

  • 9 new test files, 179 tests (tests/control/test_laser_engine_v2*.py), each file in its own process: all pass. test_squid_laser_engine.py (v1) passes unchanged.
  • Full suite against master 37d2e00, file by file: no test changes outcome. The failures already on master (test_MultiPointController 4, test_laser_af_settings 4, widgets_fluidics/test_protocol_tab 2, squid/test_stage 1) are unchanged.
  • test_HighContentScreeningGui.py with a display: 7/7, as on master.
  • Squid started in --simulation with laser_engine = v2: the engine connects and every line comes up ready.
  • Not yet: the first full run on the Dragonfly hardware (560 power setting, shutter on key-off / sleep / disarm, AOM volts on GATED3). The engine itself has been brought up on the bench with its firmware tools.

38 commits including review rounds; squash-merging is suggested.

🤖 Generated with Claude Code

hongquanli and others added 30 commits October 5, 2026 21:25
…col fake for the rev 1 engine

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rce status)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ect-time reset, cold-boot retry, 1 Hz STAT? heartbeat

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…on use, line map from the TTL port map

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ty on the TTL port's line, ramp-settled, wake on use

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rt at its minimum, clamp, idle-off, AOM shutter modes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…check-then-act race on enable/disable

Review round 1 findings (deliberate deviation from the task-6 brief's code):
- A failed disable() was logged and forgotten: _after_poll returned early whenever
  _source_want_on was False, so the source kept emitting and idle-off/disarm/L3-error-off
  silently stopped working. _after_poll now re-queues a disable (guarded by a new
  _source_disable_pending flag, cleared by source_step whether the disable succeeded or
  raised) whenever the source still reads ready/starting but is not wanted, and also
  retries closing the held-open shutter in that branch.
- _source_enable_pending was cleared before the post-enable poll refreshed
  _source_status, so a wake_up/wait_until_ready call landing in that window still saw
  SOURCE_OFF and queued a second enable. It is now cleared only after judge_source, in a
  finally so an exception in poll() cannot leave it stuck.
- The check-then-set in _wake_source spanned a serial round-trip (LINE3:SET), letting a
  concurrent _disable_source/_set_source_power interleave. Added _source_lock and moved
  the flag/queue bookkeeping in _wake_source, _disable_source, _set_source_power and
  source_step under it, holding it only around state changes, never around source I/O.

Covering tests added to tests/control/test_laser_engine_rev1_source.py (24 total):
test_failed_disable_is_retried, test_enable_is_not_repeated_while_the_first_start_is_polled
(t6_probe3 idea) and test_wake_and_disable_interleaved_stay_consistent; the first two fail
against the pre-fix code, confirmed by re-running them with the fix stashed out.
test_acquisition_use_keeps_the_source_on now rewinds engine._source_last_use instead of
sleeping, removing a scheduling dependency.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ighten the enable/disable race guards

Review round 2 findings (controller-directed deviation from round 1's code):
- Important: the "source unwanted but still emitting/starting" reconcile lived in
  _after_poll, which the poll thread stops calling once poll_once() returns None (link
  lost). A disable queued by _on_lost that then fails was never retried. Moved the
  reconcile to the end of source_step, using that step's own fresh status, so it keeps
  running on the source thread after the link is gone. _after_poll now only closes the
  held-open shutter (which needs the engine link).
- Minor: _source_disable_pending is now cleared in source_step's finally, after the
  post-step poll/judge, the same deferred pattern as _source_enable_pending/ran_enable,
  so a disable that source_step itself just ran uses this step's own fresh status
  (rather than a stale one) before deciding whether to re-queue.
- Minor: _wake_source now captures `src = self._source` once at the top and widens the
  enable_pending reset-on-exception guard to cover the whole body, so a concurrent
  _close_source() cannot leave the flag stuck True via a `self._source is None` race.

Covering tests in tests/control/test_laser_engine_rev1_source.py (26 total):
replaced test_wake_and_disable_interleaved_stay_consistent (passed before and after
round 1, per the re-review) with test_concurrent_wake_during_line3_set_enables_once and
test_disable_racing_the_enable_put_stays_consistent (probe_rr's B/C ideas); added
test_failed_disable_after_link_loss_is_retried, confirmed to fail against the round-1
code (source stays enabled after 3 source_step() calls with no poll_once() in between)
and pass with this fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… wiring, per-FOV use hook, simulation mode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…otices, startup, 560 idle-off, AOM shutter mode)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…construction

The tab only filled startup_label on the next status_updated, so it showed
nothing for up to a poll even though the engine's startup bring-up had
usually already begun before the tab was built. Seed it from
engine.bringup_state in __init__, same as the notices already do from
engine.notices. Factor the "startup: <state>"/"" formatting into
_startup_text() so __init__ and _on_status share it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…um (off by default; needs 560_aom.csv)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…USED lines refused at once; half-open link reset

- _poll_loop: any exception from one poll is logged and reported through _signal_lost (banner, 560 off,
  waits return False) instead of ending the heartbeat thread with only a stderr traceback.
- wait_until_ready raises at once for a line with nothing on it in this variant (UNUSED).
- open(): a failure after *IDN? (VAR?, HOST:TIMEOUT, line configuration, connect reset) closes the link and
  clears it, so a later open() configures from scratch.
- close(): the poll thread join outlasts one STAT? at the link's 3 s read timeout.
- Startup drop notice reports the refusal itself, not the line's status text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ts 200 mW minimum

Ruling F-0: exactly 0 % on the DF source line switches the source off and L3 reads READY (dark by request,
like any other line at 0 %); any request above 0 % but below the minimum keeps the clamp + warn-once
(ruling 4). The first 0 % posts one notice.

- _set_source_power: 0 % sets _source_dark, clears the request, disables the source; above 0 % clears it
  and re-judges the source's last reading, so the wake that follows sees SOURCE_OFF.
- _wake_source: never enables a dark source (checked before LINE3:SET and again under the lock before the
  enable is queued); a refused LINE3:SET is raised as LaserEngineRev1Error("L3", reason).
- _judge_source: a dark source that reads off is READY; uses the minimum power of the source being judged
  (close() may clear self._source). The source_step reconcile keeps acting on the source's own reading.
- LightSource.set_intensity: on the DF source line the request is sent first and 0 % wakes nothing, so
  0 -> 10 % starts the source again; docstring states how long it can block.
- Source comments state behaviour instead of the review history; the source_step docstring says a
  redundant disable is possible and harmless.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…deterministic race and idle tests

- test_l3_error_disables_source and test_put_to_sleep_l3_disables_source cover two of the 560 off triggers.
- test_disable_racing_the_enable_put_stays_consistent joins its helper thread after wake_up, before the
  source step, so the assertion does not depend on scheduling.
- test_acquisition_use_keeps_the_source_on uses a 0.5 s idle window (the 1 s rewinds still exceed it).
- Test docstrings, section headers and the FakeSource.fail_disable comment state behaviour instead of the
  review history.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ocking helper

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…AOM when it is in the path (replaces 0 % = dark)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…cepts it; AOM lock; stronger 0 % tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rectly

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… the AOM; warn on every below-minimum API request; fake mirrors the firmware pause fix

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ceiled floor for slider and spinbox

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ource reports no limits; floor tests describe behaviour, count check has a lower bound

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Engine tab, service panel, log) for testing the engine without a microscope

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…intensity the engine did not accept; wheel guard; link-lost handling

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s in the path; gate boxes follow the engine's reported gate; link lost during connect greys the panel

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nsity, shutter safety-only; no-AOM paths and the GUI floor removed

Owner rulings 2026-10-06 (replace the 560 parts of 09-27/28):
- 560 laser power set in mW in the Laser Engine tab, clamped to the source's limits, remembered in the
  machine .ini ([GENERAL] laser_engine_rev1_source_power_mw); unset = the source's minimum.
  New: EngineOptions.source_power_mw, _def.LASER_ENGINE_REV1_SOURCE_POWER_MW, set_source_power_mw(),
  source_power_setpoint_mw, save_source_power_mw(). Start stays "enable at the minimum, then the setting".
- Squid's 560 intensity drives the AOM amplitude (LINE3:SET, 5 V x % / 100, or 560_aom.csv when present);
  never the laser power. The wake sets the AOM to the requested % (0 V until one is asked).
- Shutter is safety only: SHUT:SRC MCU at connect; open while L3 is READY, closed whenever the source is off.
- Removed: aom_in_path / shutter_with_aom / aom_attenuation (+ _def flags), set_shutter_with_aom,
  intensity_floor_percent and the GUI floor (live_controller.py and widgets.py back to master),
  test_intensity_floor.py, the below-minimum clamp warning.
- Laser Engine tab redesigned: state pill, lines table (wavelength / state / set-point / max / reason),
  560 nm laser box with the power control. Bench: AOM options removed; bench TTL map for the tab.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…h any AOM calibration; simulation never saves the 560 power; Set takes a typed value

Review of 2d45724:
- Shutter: _after_poll opens it at line 3 READY, keeps it during STARTING (every AOM LINE3:SET ramps), closes it in every
  other state (NEEDS KEY, source off by itself, line off, paused). _wake_source closes it before queuing the enable,
  so a restart is never behind an open shutter.
- 0 % -> 0 V even when 560_aom.csv starts at a transmitting row (np.interp clamped to that row); the display inverse
  gives 0 % for 0 V.
- build_simulated_engine marks the engine simulated; the tab's default saver then leaves the machine .ini alone.
- Set calls interpretText() first: a value typed without Enter is the one applied and saved.
- tests/control/test_intensity_cap.py back to origin/master (dead floor stub).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ost; a source starting by itself closes the shutter

Re-review of d70c360:
- N1: _set_held_shutter swallows a connection loss, so _wake_source went on to queue the enable after _on_lost had
  switched the source off (and no poll thread was left to idle it off). The enable is now refused under _source_lock
  when _lost is set.
- N2: STARTING keeps the shutter only while the source itself reads ready (a line-3 ramp from an AOM set-point); a
  source that reads starting (off and on again between two polls) closes it until READY.
- Docstring and warning text match; the refusal-based restart test replaced by the link-loss case (DF firmware never
  refuses a close).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…scrolls instead of squashing

The tab needed 540 px; in a full Squid column the panel gave it ~340, so every row was squeezed (spinboxes to nothing).
- Compact: the 560 box in three rows (state + power; power setting, limits, idle-off; the shutter note), the Lines
  table in a plain frame with tight rows, the latest two notices shown (ten in the tooltip), the startup line hidden
  once it reads done. Size hint 548 -> 312 px (macOS).
- The content sits in a frameless QScrollArea: a panel shorter than the tab scrolls, rows keep their height.
- sizeHint = the content at the tab's width (wrapped lines included) + an allowance for the tab pane, which Squid's
  resizeCurrentTab leaves out of its cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
hongquanli and others added 16 commits October 7, 2026 19:00
…ne v2

The modules, classes and tests were named after the carrier board revision
(rev 1). The product is Cephla laser engine v2; the board revision stays only
in the firmware ID string (Cephla,LaserEngineCarrier-rev1).

- control/laser_engine_rev1*.py -> control/laser_engine_v2*.py, the bench
  tool and the tests likewise (git mv)
- LaserEngineRev1* -> LaserEngineV2*, EngineRev1Status -> EngineV2Status,
  LightSourceType.CephlaLaserEngineRev1 -> CephlaLaserEngineV2
- operator-facing texts say "laser engine v2"

No behaviour change; the machine .ini keys follow in the next commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the machine .ini

[GENERAL] laser_engine = v1 | v2 (blank or none = no engine) and
laser_engine_sn = <USB serial number> replace use_laser_engine_rev1 and
laser_engine_rev1_sn. The legacy v1 keys are still read:
use_squid_laser_engine = True alone selects v1 (with one warning to use
laser_engine = v1 instead), squid_laser_engine_sn is the v1 serial number
when laser_engine_sn is not set, and use_squid_laser_engine = True with
laser_engine = v2 is refused at startup, as is any other laser_engine value.

_def._resolve_laser_engine replaces _validate_laser_engine_flags on both
config paths (.ini and the legacy configuration*.txt). microscope.py and
gui_hcs.py now select on LASER_ENGINE and open LASER_ENGINE_SN; nothing on
master reads the legacy names at call time, so no aliases are kept.

The 560 nm source key laser_engine_rev1_source_sn goes: the optional driver
module (control.laser_engine_v2_560_driver, open_560_source(), supplied
separately and not in this repository) finds the source by its own USB IDs.
build_from_config(sn, options); the bench app's "560 source SN" field goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… cache, not the machine .ini

They are operator settings changed in the Laser Engine tab, so they now live
in cache/laser_engine_v2.yaml (keys power_560_mw, idle_off_560_min) next to
Squid's other GUI state. control/laser_engine_v2_settings.py loads them
(defaults when the file or a value is missing or unusable: the source's
minimum power, 30 min idle-off, 0 = 24 h) and saves them (never raises:
logs and returns False, keeps the other key).

- the tab saves the power on Set (as before) and the idle-off whenever its
  spinbox changes; a simulated engine still saves nothing, with the same
  "not saved ... this session only" notice
- Squid and the bench app build the engine's options from the cache
  (options_from_cache); the bench's own idle-off spinbox goes, the tab's
  control is the one that is kept
- removed: laser_engine_rev1_source_power_mw, laser_engine_rev1_source_idle_off_min
  and the machine .ini writer save_source_power_mw
- every test points the cache at a temporary directory

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng references

Comments and docstrings referred to internal decision and task numbers that a
public reviewer cannot look up ("ruling 4", "Task 6", dated decisions, "fix
round 1"). Each now states the reason itself, e.g. the idle-off: the 560 source
switches off after this many minutes unused, 0 = 24 h, so it is never left on
indefinitely; the shutter: open only at line 3 READY so the beam is blocked
whenever the source is not known to be ready. The module header names the
variants (400R / 400HP, DF). Comments only: no code change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ys), cabling, the tab, the DF 560 line, simulation, bench app

software/docs/laser-engine.md, in the style of docs/filter-wheel.md. It notes
that the 560 nm source driver is supplied separately and is not in this
repository.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Its changes are now saved to the cache, so scrolling the tab past it must not change it (the power spinbox already ignored
the wheel).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…id; idle-off applied on Enter; shutter wording

- laser_engine_v2_settings: any error reading cache/laser_engine_v2.yaml (I/O, YAML, not UTF-8) gives the defaults, and an
  int too large for a float is not a number, so a v2 machine still starts and the next save replaces the file.
- The idle-off spinbox has keyboard tracking off: typing 120 applies and saves 120 on Enter, not 1, 12, 120.
- Module docstring and docs/laser-engine.md: the shutter stays open through the line-3 ramp of an AOM set-point while the
  source reads ready (as the code does), closed in every other state.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…I lint) formats it

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…when its window is deleted

CI (one pytest process) segfaulted in test_objectives_widget.py: the deferred delete of a bench window ran in that
test's event loop, and LogPane's destroyed -> lambda slot had already been freed by PyQt (PyQtSlot::call on freed
memory). LogPane no longer hooks its own destroyed signal: its log handler holds the pane weakly and goes quiet once
the pane is gone (qtpy.compat.isalive), and a new LogPane removes such handlers from the squid logger. detach() on window
close is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… live counts as 560 use; variant after cold start; remap re-sends intensity

- Shutter race: a poll that read L3 READY could reopen the shutter after another thread restarted the 560 (source still
  starting). The open decision + SHUT:OPEN, a restart's enable-pending + close and a switch-off now share _shutter_lock,
  and the open re-checks at send time: source wanted, no restart pending, source reads ready (not starting), and no
  restart or switch-off since that poll's snapshot (_shutter_gen). Lock order _aom_lock, _shutter_lock, _source_lock.
- Live idle-off: live has no per-frame call into the engine, so a long live session idled the 560 off. The engine polls
  in_use_provider every heartbeat; gui_hcs wires LiveController.illumination_wavelengths_in_use (new, [] when not live).
  MultiPointWorker also notes use per image, for single FOVs longer than the idle-off.
- Cold start: VAR? read in open() can say UNPROGRAMMED before the expanders answer (firmware reads the straps then), which
  skipped SHUT:SRC MCU and the 560 source. The variant is read again once the line commands succeed. The simulator
  answers VAR? the same way.
- Port remap: IlluminationController skipped the light source when only the TTL port changed, leaving the set-point on
  the old engine line. The TTL port is part of its cached identity now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…witch-offs never wait; variant after the straps

- A restart sends SHUT:OPEN 0 even when the cached STAT? says closed: that reply can be older than an open, and the
  source must never start behind an open shutter.
- _disable_source no longer takes _shutter_lock (it waited behind a shutter round trip, up to the link timeout); an open
  racing a switch-off is harmless, the next poll closes. _shutter_gen changes under _source_lock only.
- Cold start: the line writes succeed as soon as the expanders ACK, but the firmware reads the ID straps on its 1 s
  initMissing tick: VAR? is read once STAT? chips.mcp is 1 (up to EXPANDER_INIT_WAIT_S = 2.5 s, two ticks). The
  simulator reports chips.mcp, VAR? and STAT? var from one expander state, with a straps_delay_replies knob.
- The open re-check uses the status code's READY test; a not-armed snapshot from before a restart no longer switches the
  new start off; the provider's log-once resets after a success.
- The live-view provider is wired in Microscope (it owns live_controller) instead of gui_hcs, so it also covers users
  other than the GUI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…estart of the 560

A heartbeat that read L3 FAULT and was delayed could switch the source off after another caller had reset the fault,
restarted the 560 and seen READY; the source then stayed off while live still used it. The fault / not-armed switch-off
now needs a current snapshot, checked again under _source_lock where _disable_source(gen) queues the disable (an
obsolete snapshot does nothing; the next poll decides). Idle-off is measured now and is unaffected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…calibration files are not read

The engine no longer reads <wavelength>.csv or 560_aom.csv: those files record nothing about the engine they were
measured on, and engine lines will be calibrated with Utils > Illumination Power Calibration (a follow-up PR), with
files tied to the engine's serial number, line and ceiling. Until then an engine line's intensity is linear in its
drive: % of the line's current ceiling, on DF line 3 % of the AOM's 0-5 V input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLTvGd4x3j4ujpqZtCAMf9
…oint back after another one used the line

Two wavelengths on one port (470 and 488 nm on D2, as the default port map has them) drive one engine line. In Live
Control, 488 at 50 %, then 470 at 20 %, then 488 again sent nothing on the third switch: the controller's
unchanged-intensity check is per wavelength, and 488's entry still said 50 % while the line held 470's 20 %. The
display said 488 at 50 %; the line stayed at 20 %.

The controller now also remembers which channel's intensity was last sent while on each TTL port, and on a laser
engine v2 (whose line is the port) sends again when another channel has set the line since. An unchanged % of the
channel the line already holds is still not sent again (each set-point waits out the line's ramp). Other Software-mode
light sources are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLTvGd4x3j4ujpqZtCAMf9
hongquanli added a commit that referenced this pull request Oct 16, 2026
…o the DF laser engine integration branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLTvGd4x3j4ujpqZtCAMf9
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.

1 participant