Skip to content

TCube laser: closed-loop (power) mode and the DiodeLaser interface, verified on the 642 nm rig - #66

Merged
kalidke merged 27 commits into
mainfrom
laser-642-power-mode
Sep 29, 2026
Merged

kalidke merged 27 commits into
mainfrom
laser-642-power-mode

Conversation

@AliKNS

@AliKNS AliKNS commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This adds a closed-loop ("power") mode for the TCube laser, the DiodeLaser interface it needs, and the hardware facts learned bringing it up on the 642 nm rig. Original work by @AliKNS; review changes are on top.

It targets main as a non-breaking 0.2.5 change (decisions 0033 and 0035). The version stays main's -DEV. Every 0.2.4 line keeps working and means what it meant, including TCubeLaser("serial"; max_current = ..., properties = ...) and setpower(laser, mA). Closed loop is opt-in by keyword. test/tcube_output_order.jl, 0.2.4's own TCube cases, passes unchanged on an open-loop laser built without mode and driven by setpower.

  • Types. DiodeLaser <: LightSource, the ConstantCurrent and ConstantPhotocurrent mode types, and TCubeLaser{M} with a PhotodiodeLoop. The mode keyword defaults to ConstantCurrent(), which is 0.2.4's behaviour.
  • Closed loop is chosen with mode = ConstantPhotocurrent(). It requires wa_calibration, tia_range, tec_stabilised, properties and max_current. The last is the clamp initialize programs, and it is the only real protection in that mode.
  • New calls: setcurrent!, setoutputpower!, setlevel!, measured_current, measured_photocurrent, indicated_output_power and loop_status.
  • Deprecated, and still working:
    • setpower on a ConstantCurrent laser forwards to setcurrent!. On a ConstantPhotocurrent laser it throws, naming setoutputpower!, so an mA call can never become an mW one.
    • properties.power.
    • The 2-argument export_state.
  • export_state adds the new keys and keeps 0.2.4's six.
  • TCubeLaser's default properties stay 0.2.4's ("mW", 0, 100). In open loop they are labels only.
  • Mode-dispatched panels: mA in open loop and mW at the laser output in closed loop, with the configured range shown. An out-of-range entry is refused with a red border. Opening a panel sends no command and makes no read. The output toggle follows properties.is_on.
  • SimDiodeLaser takes the same keywords as TCubeLaser, minus the serial. The contract test and the API map walk the type hierarchy to its leaves.
  • Closed-loop ramp and lock check are per-laser keywords:
    • ramp_step_mW defaults to Inf, a single write; ramp_step_s sets the step interval.
    • lock_check_s = 0.2 and lock_ratio = 1.5: a photocurrent above 1.5x the request after 0.2 s zeroes and disables the output, then throws.
  • CALIBRATION.md keeps how to calibrate and a new "Controller facts (TLD001)" section. The rig diary and ceiling sections are cut here, for the rig's own repository to keep.

Safety changes from review (all on the fake SDK, none yet on hardware)

  • Every disable is preceded by a zero, including a failed light_on, and a failed send, confirm or lock check in setoutputpower!. If a disable fails, properties.is_on stays true and the log says the output may still be on.
  • initialize zeroes and disables the output before the mode command, in both modes.
  • Closed-loop light_on and setoutputpower! re-read the status word and the current limit before emitting. They refuse if the loop bit is gone, or if the limit is above max_current or more than 0.5 mA above the programmed clamp.
  • The clamp is recorded only after the whole closed-loop initialize succeeds.

What an open-loop 0.2.4 caller now sees (CHANGELOG, Changed)

None of these is yet run on hardware; each needs the 642 nm rig check.

  1. initialize starts background polling (LD_StartPolling). shutdown and a failed initialize stop it.
  2. initialize zeroes the setpoint and disables the output before the mode command, so properties.is_on is false afterwards.
  3. initialize may lower the max-current potentiometer when the controller's limit is above max_current. By construction it never raises it.
  4. Two cases now warn that the ceiling is enforced in software only, and initialize carries on, as in 0.2.4:
    • a max_current below the potentiometer's floor (about 17.25 mA);
    • a lowering that fails.
  5. light_on and setcurrent! wait up to 1 s for the controller's setpoint read-back and throw if it does not confirm. light_on then zeroes and disables the output.
  6. setcurrent! with the output off sends nothing, and light_on applies it after the enable. When the driver recorded the output off, a fresh status read decides.
  7. gui(laser) on an open-loop TCubeLaser opens the diode-laser current panel (mA).

Hardware verification (642 nm rig, TLD001 64849775, power meter before the fibre; on @AliKNS's 10f1aba)

  • Open loop at 70–130 mA: the current follows the setpoint exactly, giving 1.69–57.7 mW.
  • Closed loop at 1, 2, 3, 5, 10, 20, 40 and 70 mW gave 0.56, 1.55, 2.54, 4.52, 9.30, 19.04, 38.74 and 68.59 mW (224.2 W/A holds).
  • The diode is an Ushio HL6366DG, rated 80 mW. The meter position sees 0.93 of the head power. The rig's ceiling is 70 mW / 150 mA.
  • Found and fixed on the rig, none of it visible to the fake SDK:
    • The TLD001 ignores setpoints sent with the output off. They are now sent right after enabling and zeroed before disabling.
    • The setpoint read-back only refreshes under polling.
    • The photocurrent is signed, with 0x8000 meaning over range.
    • The max-current pot needs adjust mode, and the header's position scale is wrong. The clamp is the controller's reported limit.
  • Unexplained and intermittent: a setpoint jumped from 0 locked the loop at about 21 mW three times, then jumps worked at 10, 40 and 70 mW. A single write is the default. The ramp is the opt-in fallback, and the lock check catches the lock.

Rig checks owed on this head, beyond the open-loop list above:

  • the lock check's 1.5x / 0.2 s threshold;
  • the two extra request/read round trips on every closed-loop light_on and setoutputpower!;
  • the re-check's half-step slack;
  • the GUI toggle correction after a failed light_off.

Test plan

  • Local suite at fe5bb10 (kitt, Julia 1.13): 1722 pass, 3 broken, 0 fail. test/tcube_output_order.jl passes 39/39, unedited.
  • lab/tests on fe5bb10: Core pass, 1725 tests; Core on Julia 1.11.9 pass.
  • MicroscopeAdapt's suite against this head (it passed against 10f1aba).
  • 642 nm rig run of the checks above.
  • Downstream rig GUI check after merging.

🤖 Generated with Claude Code

AliKNS and others added 8 commits September 28, 2026 21:08
…-checked on the 642 nm rig (0.3.0)

Implements dev/output/plan-laser-modes.md (rev 3): DiodeLaser under
LightSource; ConstantCurrent / ConstantPhotocurrent mode types;
TCubeLaser{M} with a PhotodiodeLoop; setcurrent!, setoutputpower!,
setlevel!, measured_current, measured_photocurrent,
indicated_output_power and loop_status; mode-dispatched current and
power panels (range shown, out-of-range textbox turns red, no command or
read on open); SimDiodeLaser twin; contract test and API map walk to the
device leaves. setpower throws for DiodeLaser; mode is a required
keyword (plan section 8.1 contingency). Brings in the Kinesis boolean
split from fix/revert-cppbool.

Hardware-checked on TLD001 64849775 with a power meter before the fibre:
open loop at 70/90/110 mA is correct, and closed loop regulates from 1 to
5 mW (measured = requested - 0.46 mW, so the 224.2 W/A calibration holds).
Above ~5 mW closed loop held ~21 mW, because the controller's photodiode
reading clips at ~3213 counts; that needs the PD range / TIA gain set-up
redone (CALIBRATION.md). Fixed from the rig: setpoints are ignored with the
output off (now sent right after enabling, zeroed before disabling);
polling starts first; signed photocurrent with 0x8000 = over range; pot
needs adjust mode and the clamp is the controller's reported limit.

Adds CALIBRATION.md with the calibration procedure, today's measurements
and the manual's PD range / gain procedure.

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

A setpoint jumped from 0 locks the TLD001's loop at ~21 mW / 90 mA whatever
was requested (3 of 3 at 10 mW), while the same target reached in steps
regulates exactly, as the Kinesis application does (9.30 mW). light_on and
setoutputpower! now ramp upward closed-loop steps, RAMP_STEP_mW = 3 mW every
RAMP_STEP_S = 10 ms (40 mW in ~0.2 s; the USB write is the floor). Measured
with a power meter before the fibre: 10 / 20 / 40 mW requested gave 9.30 /
19.04 / 38.74 mW. The mechanism is not known; the ramp is empirical.

The photodiode UNDER-range flag warns instead of refusing: it was set at
1 mW while the loop regulated correctly. CALIBRATION.md records the 09-29
session, the PD range / gain check (1 mA in range, 386 uA at the limit) and
corrects the previous day's diagnosis: the 98 uA "clip" was this lock, not
the photodiode channel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…llback; verified to 70 mW

Jumps from 0 to 10, 40 and 70 mW regulated correctly later the same night
(9.30/9.31/9.30/9.31 mW at 10 mW, 68.5 mW at 70 mW), so the default is one
write, the fastest. The ramp that worked around the earlier lock (3 of 3
failures at 10 mW, cause unknown) stays in the driver as an opt-in fallback,
RAMP_STEP_mW[] = 3.0 / RAMP_STEP_S[] = 0.01, verified 10 of 10 at 10-70 mW.
Closed loop measured over 1-70 mW: 0.56, 1.55, 2.54, 4.52, 9.30, 19.04,
38.74, 68.59 mW for 1, 2, 3, 5, 10, 20, 40, 70 mW requested (224.2 W/A).
Tests cover both the default and the ramp.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…150 mA

Measured at the laser head (before the filter): 22.23 mW at 90 mA vs 20.67 at
the usual position (T = 0.93), 73.5 mW at 70 mW requested, 75.9 at 72. The
diode is an Ushio HL6366DG (80 mW rated, 90 mW absolute maximum), so 70 mW at
the usual position is ~94 % of rating; max_current 150 mA leaves the loop
headroom over the ~142 mA it needs. Also records today's 13 s at the limit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e fake SDK

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 29, 2026 08:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kalidke

kalidke commented Sep 29, 2026

Copy link
Copy Markdown
Member

Ali, three things from Keith's review of MicroscopeControl.

  1. Laser safety issue in 0.2.3. Setting power while the output is off is ignored, so the next "on" runs at whatever was stored (the 642 once sat 13 s at 160 mA this way). A 0.2.4 fix is coming today. Until you have it, set power only after turning the output on, and check the reading.
  2. We're pushing fixes to this TCube laser: closed-loop (power) mode and the DiodeLaser interface, verified on the 642 nm rig #66 branch, on top of your commits and never overwriting them. Please pull before pushing from the rig.
  3. After 0.2.4, development moves to main, which becomes 0.2.5-DEV. The 0.3rc1 branch goes away, and release branches are used only for safety backports.

kalidke and others added 17 commits September 29, 2026 11:21
Resolved so that 0.2.4's released TCube safety behaviour holds on this
branch's API:
- light_on keeps this branch's structure (require_clamp, intended_code,
  send_setpoint_ramped) with 0.2.4's failure handling: if the setpoint fails
  after the enable, the disable's status is checked, is_on becomes false only
  if it succeeded and true otherwise, and both failures are logged (review
  blocker 1). Open loop checks drive_current against the ceiling before the
  enable, and light_on warns when it sends 0 because nothing was requested.
- zero_then_disable (light_off, shutdown) is 0.2.4's: one write of 0 with no
  status read and no read-back wait, logged if it fails, then the disable
  (review should-fix 4). Three assertions of this branch's call sequences
  change accordingly, and "a zero that cannot be confirmed" becomes "a zero
  that fails".
- The fake SDK keeps this branch's controller model; 0.2.4's names (stored,
  output_on, enable_log, reset!(stored=)) are views of it, so
  test/tcube_output_order.jl runs unchanged.
- Project.toml takes main's 0.2.5-DEV; README and skill pins stay at v0.2.4;
  rig-causes takes 0.2.4's row for the ignored-setpoint hazard.

tcube_output_order.jl errors at this commit: it builds TCubeLaser without
`mode` and calls setpower, which the next commits restore (decision 0035).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s failed-disable path

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ds in open loop, old export_state keys and properties.power kept (decision 0035)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…sweep 0.3.0 references

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…lamp above max_current

Both modes zero and disable the output before the mode command. Open loop
lowers the max-current pot only when the controller's limit exceeds
max_current, never raises it. The closed-loop enter_mode! drops its own
disable and clears pd.max_current_clamp first.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…re emitting

require_clamp reads the status word and the controller's limit afresh and
refuses if the loop bit is gone or the limit is above the programmed clamp.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…driver knows, and detect a loop lock

Deletes the RAMP_STEP_mW and RAMP_STEP_S globals (new in #66, never released)
in favour of ramp_step_mW, ramp_step_s, lock_check_s and lock_ratio keywords.
light_on ramps from 0 and setoutputpower! from the previous request's code,
not from the stale LD_GetLaserSetPoint. check_lock refuses a measured
photocurrent above lock_ratio x the request and the output is disabled.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…the click

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d, drop SETPOINT_NEEDS_OUTPUT

Docstring examples use the placeholder serial and the documented diode
rating. CALIBRATION.md keeps the current calibration and gains a short
Controller facts section; the session diary and the ceiling section are
cut (to dev/output/t16-pr66-calibration-rig-history.md). The stale
bench-table comment in TCubeLaserControl.jl is deleted. The
SETPOINT_NEEDS_OUTPUT constant's text now lives on send_setpoint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d only after full success

require_clamp refused only a limit more than 1 mA above the recorded clamp,
which is more than one pot step (0.83 mA): at a 160 mA ceiling a pot one step
up (160.74 mA) passed. It now refuses a limit above max_current, or more than
half a step above the clamp. enter_mode! records the clamp only after the whole
closed-loop sequence succeeded, so a failure after programming the pot (at
LD_SetClosedLoopMode, say) leaves it NaN. setoutputpower!'s docstring said an
under-range flag with the output on refuses; the code warns, as the rig showed
the loop still regulates there. Lane 2 report items 1, 2 and 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mode defaults to ConstantCurrent(), and closed loop requires max_current and
accepts the ramp and lock keywords (stored, not simulated), so one
construction line serves a rig and its simulated twin (captain's ruling).
Tests: the power-mode re-check at a 160 mA ceiling (one pot step) and a
re-initialize failing at LD_SetClosedLoopMode; SimDiodeLaser() is now a
ConstantCurrent twin. Suite: 1682 pass, 3 broken, 0 fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it; initialize records a failed disable

light_on and setoutputpower! zero the setpoint before the disable on any
failure after the enable, setoutputpower! covers a send failure as well as
a lock, and a failed initial disable in initialize sets is_on true.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…hangelog entry

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… construction, never raising

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… the limit-drift wording

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…document light_on's re-ramp from 0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
kalidke and others added 2 commits September 29, 2026 12:34
…s version numbers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fresh when recorded off

Final-check nits N1 and N2 on c17d729, under the captain's ruling that no
configuration that initialized in 0.2.4 may newly fail.

N1: lower_open_loop_clamp! catches a failed search (adjust mode refused, a
position that does not read back, or even the lowest position above
max_current), re-reads the controller's limit, warns that max_current is
enforced in software only, and lets initialize go on. The search has only
ever lowered the potentiometer (raise = false), so nothing is less safe than
before it ran; a failed re-read keeps the earlier reading, an upper bound for
the same reason. This also absorbs the residual of a max_current at or above
17.25 mA but under a controller's real position-20 floor. Closed loop still
refuses, since there the clamp is the only protection.

N2: setcurrent! decides "on" from is_on, else from a fresh status read. The
polled word can still report the output on for about one poll after a
light_off, and a send then waited out its 1 s confirm and threw where 0.2.4
did not.

Tests: the fake gains stale_bits, a polled status word that lags until an
LD_RequestStatusBits. "open loop never newly fails" now proves both N1 paths
(a pot walked to its floor, and adjust mode refused): initialize succeeds,
warns, and the software ceiling holds. The stale-bit test asserts outcomes
both ways: a stale "off" with the output on sends and confirms, and a stale
"on" after light_off sends nothing and light_on then applies the request.
One assertion changed: setcurrent! with the output recorded off now makes a
fresh read (LD_RequestStatusBits, then LD_GetStatusBits).

Suite: 1722 pass, 3 broken, 0 fail. tcube_output_order.jl (0.2.4's cases,
unedited): 39/39.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kalidke
kalidke changed the base branch from 0.3rc1 to main September 29, 2026 20:20
@kalidke kalidke changed the title TCube laser closed-loop (power) mode, DiodeLaser interface, verified on the 642 nm rig (0.3.0) TCube laser: closed-loop (power) mode and the DiodeLaser interface, verified on the 642 nm rig Sep 29, 2026
@kalidke
kalidke merged commit 48cd1e0 into main Sep 29, 2026
5 checks passed
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.

3 participants